summaryrefslogtreecommitdiff
path: root/app/assets
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets')
-rw-r--r--app/assets/images/auth_buttons/salesforce_64.pngbin0 -> 8774 bytes
-rw-r--r--app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js1
-rw-r--r--app/assets/javascripts/api.js21
-rw-r--r--app/assets/javascripts/badges/components/badge.vue2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_metrics.js24
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js4
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts.js4
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js2
-rw-r--r--app/assets/javascripts/boards/components/board.js40
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue33
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue1
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue219
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js2
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue335
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue25
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue30
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue16
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue8
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue1
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue17
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue2
-rw-r--r--app/assets/javascripts/boards/config_toggle.js1
-rw-r--r--app/assets/javascripts/boards/ee_functions.js7
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js3
-rw-r--r--app/assets/javascripts/boards/index.js56
-rw-r--r--app/assets/javascripts/boards/mixins/modal_footer.js1
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js2
-rw-r--r--app/assets/javascripts/boards/models/issue.js6
-rw-r--r--app/assets/javascripts/boards/models/list.js16
-rw-r--r--app/assets/javascripts/boards/models/milestone.js4
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js35
-rw-r--r--app/assets/javascripts/boards/services/board_service.js97
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js149
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js1
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js64
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js62
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue5
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue10
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_button.vue3
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue15
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js1
-rw-r--r--app/assets/javascripts/commits.js5
-rw-r--r--app/assets/javascripts/commons/index.js3
-rw-r--r--app/assets/javascripts/commons/nav/user_merge_requests.js67
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue58
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue140
-rw-r--r--app/assets/javascripts/confidential_merge_request/index.js30
-rw-r--r--app/assets/javascripts/confidential_merge_request/state.js5
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/constants.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/index.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/index.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/actions.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/getters.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/index.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js)0
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/store/state.js (renamed from app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js)0
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js47
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue41
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue88
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js36
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_service.js8
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js8
-rw-r--r--app/assets/javascripts/diffs/components/app.vue2
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue54
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue244
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue19
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue32
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue107
-rw-r--r--app/assets/javascripts/diffs/components/hidden_files_warning.vue1
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue46
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue53
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue5
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue19
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue97
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue56
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue11
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue19
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js2
-rw-r--r--app/assets/javascripts/diffs/store/actions.js40
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js92
-rw-r--r--app/assets/javascripts/diffs/store/utils.js71
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue12
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue20
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue3
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue3
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue1
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue11
-rw-r--r--app/assets/javascripts/event_tracking/issue_sidebar.js2
-rw-r--r--app/assets/javascripts/event_tracking/notes.js1
-rw-r--r--app/assets/javascripts/filterable_list.js6
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue6
-rw-r--r--app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql4
-rw-r--r--app/assets/javascripts/group.js4
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue2
-rw-r--r--app/assets/javascripts/ide/components/branches/item.vue1
-rw-r--r--app/assets/javascripts/ide/components/branches/search_list.vue26
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue12
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue33
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/success_message.vue4
-rw-r--r--app/assets/javascripts/ide/components/external_link.vue2
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue1
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue1
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue28
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue76
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue5
-rw-r--r--app/assets/javascripts/ide/constants.js4
-rw-r--r--app/assets/javascripts/ide/lib/files.js5
-rw-r--r--app/assets/javascripts/ide/services/index.js8
-rw-r--r--app/assets/javascripts/ide/stores/actions.js3
-rw-r--r--app/assets/javascripts/ide/stores/getters.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js25
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js13
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js36
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js16
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js4
-rw-r--r--app/assets/javascripts/issuable_index.js17
-rw-r--r--app/assets/javascripts/issuable_init_bulk_update_sidebar.js19
-rw-r--r--app/assets/javascripts/issuable_suggestions/components/item.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue7
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/edited.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue1
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue5
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue21
-rw-r--r--app/assets/javascripts/issue_show/components/pinned_links.vue31
-rw-r--r--app/assets/javascripts/issue_show/index.js4
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue1
-rw-r--r--app/assets/javascripts/jobs/components/empty_state.vue41
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue13
-rw-r--r--app/assets/javascripts/jobs/components/job_log.vue45
-rw-r--r--app/assets/javascripts/jobs/components/manual_variables_form.vue179
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue9
-rw-r--r--app/assets/javascripts/jobs/index.js28
-rw-r--r--app/assets/javascripts/jobs/store/actions.js14
-rw-r--r--app/assets/javascripts/labels_select.js7
-rw-r--r--app/assets/javascripts/lib/utils/color_utils.js25
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js67
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js15
-rw-r--r--app/assets/javascripts/lib/utils/forms.js12
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/icons_path.js3
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js19
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js12
-rw-r--r--app/assets/javascripts/main.js24
-rw-r--r--app/assets/javascripts/main_ee.js1
-rw-r--r--app/assets/javascripts/manual_ordering.js2
-rw-r--r--app/assets/javascripts/members.js2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue124
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue23
-rw-r--r--app/assets/javascripts/monitoring/components/charts/empty_chart.vue41
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue29
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue342
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue414
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue107
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue65
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue141
-rw-r--r--app/assets/javascripts/monitoring/constants.js33
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js4
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js17
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js7
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js44
-rw-r--r--app/assets/javascripts/monitoring/utils.js65
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js27
-rw-r--r--app/assets/javascripts/mr_notes/stores/index.js2
-rw-r--r--app/assets/javascripts/mr_notes/stores/modules/index.js4
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue6
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue2
-rw-r--r--app/assets/javascripts/notebook/index.vue2
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue54
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue3
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue21
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue10
-rw-r--r--app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue51
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue51
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue27
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue16
-rw-r--r--app/assets/javascripts/notes/components/note_edited_text.vue1
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue44
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_signed_out_widget.vue19
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue44
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue47
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue3
-rw-r--r--app/assets/javascripts/notes/index.js5
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js6
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js17
-rw-r--r--app/assets/javascripts/notes/stores/actions.js57
-rw-r--r--app/assets/javascripts/notes/stores/getters.js30
-rw-r--r--app/assets/javascripts/notes/stores/utils.js2
-rw-r--r--app/assets/javascripts/operation_settings/components/external_dashboard.vue5
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js49
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index.js2
-rw-r--r--app/assets/javascripts/pages/dashboard/todos/index/todos.js4
-rw-r--r--app/assets/javascripts/pages/groups/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js4
-rw-r--r--app/assets/javascripts/pages/projects/project.js23
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue5
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue38
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js27
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js42
-rw-r--r--app/assets/javascripts/pages/search/show/refresh_counts.js24
-rw-r--r--app/assets/javascripts/pages/search/show/search.js11
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js2
-rw-r--r--app/assets/javascripts/pdf/index.vue24
-rw-r--r--app/assets/javascripts/pdf/page/index.vue8
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue17
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue59
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue33
-rw-r--r--app/assets/javascripts/persistent_user_callout.js25
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue11
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.vue25
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue6
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js8
-rw-r--r--app/assets/javascripts/privacy_policy_update_callout.js8
-rw-r--r--app/assets/javascripts/projects/gke_cluster_namespace/index.js37
-rw-r--r--app/assets/javascripts/projects/project_new.js4
-rw-r--r--app/assets/javascripts/projects/projects_filterable_list.js7
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue8
-rw-r--r--app/assets/javascripts/projects_list.js4
-rw-r--r--app/assets/javascripts/registry/components/app.vue143
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue12
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue185
-rw-r--r--app/assets/javascripts/registry/index.js10
-rw-r--r--app/assets/javascripts/registry/stores/actions.js2
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue2
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue15
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue2
-rw-r--r--app/assets/javascripts/reports/components/modal.vue7
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue9
-rw-r--r--app/assets/javascripts/reports/components/report_link.vue1
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue2
-rw-r--r--app/assets/javascripts/reports/components/summary_row.vue12
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue186
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue1
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue12
-rw-r--r--app/assets/javascripts/repository/index.js50
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.query.graphql9
-rw-r--r--app/assets/javascripts/repository/queries/getPermissions.query.graphql9
-rw-r--r--app/assets/javascripts/right_sidebar.js10
-rw-r--r--app/assets/javascripts/serverless/components/area.vue15
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue4
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue47
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue48
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue83
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue219
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue27
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue121
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue9
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue96
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue8
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue3
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue19
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue19
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue4
-rw-r--r--app/assets/javascripts/star.js2
-rw-r--r--app/assets/javascripts/test_utils/simulate_drag.js6
-rw-r--r--app/assets/javascripts/tracking.js73
-rw-r--r--app/assets/javascripts/users_select.js66
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/comment.js148
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/constants.js41
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/index.js33
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/login.js51
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/note.js35
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/utils.js48
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/wrapper.js102
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js15
-rw-r--r--app/assets/javascripts/visual_review_toolbar/index.js36
-rw-r--r--app/assets/javascripts/visual_review_toolbar/store/events.js36
-rw-r--r--app/assets/javascripts/visual_review_toolbar/store/index.js5
-rw-r--r--app/assets/javascripts/visual_review_toolbar/store/state.js78
-rw-r--r--app/assets/javascripts/visual_review_toolbar/store/utils.js15
-rw-r--r--app/assets/javascripts/visual_review_toolbar/styles/toolbar.css177
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue44
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue20
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js94
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue14
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/memory_graph.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/image.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue3
-rw-r--r--app/assets/javascripts/vue_shared/directives/autofocusonshow.js39
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js4
-rw-r--r--app/assets/javascripts/vue_shared/mixins/is_ee.js10
-rw-r--r--app/assets/stylesheets/_ee/application_ee.scss5
-rw-r--r--app/assets/stylesheets/application.scss11
-rw-r--r--app/assets/stylesheets/components/avatar.scss5
-rw-r--r--app/assets/stylesheets/components/popover.scss1
-rw-r--r--app/assets/stylesheets/components/toast.scss13
-rw-r--r--app/assets/stylesheets/csslab.scss2
-rw-r--r--app/assets/stylesheets/errors.scss14
-rw-r--r--app/assets/stylesheets/framework.scss3
-rw-r--r--app/assets/stylesheets/framework/asciidoctor.scss27
-rw-r--r--app/assets/stylesheets/framework/badges.scss2
-rw-r--r--app/assets/stylesheets/framework/callout.scss8
-rw-r--r--app/assets/stylesheets/framework/common.scss4
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss3
-rw-r--r--app/assets/stylesheets/framework/filters.scss53
-rw-r--r--app/assets/stylesheets/framework/flash.scss1
-rw-r--r--app/assets/stylesheets/framework/header.scss2
-rw-r--r--app/assets/stylesheets/framework/icons.scss3
-rw-r--r--app/assets/stylesheets/framework/lists.scss17
-rw-r--r--app/assets/stylesheets/framework/modal.scss51
-rw-r--r--app/assets/stylesheets/framework/panels.scss1
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/tooltips.scss5
-rw-r--r--app/assets/stylesheets/framework/typography.scss65
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss4
-rw-r--r--app/assets/stylesheets/highlight/common.scss13
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss5
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss4
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss4
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss4
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss3
-rw-r--r--app/assets/stylesheets/pages/boards.scss75
-rw-r--r--app/assets/stylesheets/pages/builds.scss20
-rw-r--r--app/assets/stylesheets/pages/commits.scss2
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss23
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss50
-rw-r--r--app/assets/stylesheets/pages/diff.scss33
-rw-r--r--app/assets/stylesheets/pages/groups.scss4
-rw-r--r--app/assets/stylesheets/pages/help.scss6
-rw-r--r--app/assets/stylesheets/pages/issuable.scss49
-rw-r--r--app/assets/stylesheets/pages/labels.scss4
-rw-r--r--app/assets/stylesheets/pages/members.scss40
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss38
-rw-r--r--app/assets/stylesheets/pages/notes.scss44
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss9
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss27
-rw-r--r--app/assets/stylesheets/pages/reports.scss14
-rw-r--r--app/assets/stylesheets/pages/search.scss45
-rw-r--r--app/assets/stylesheets/pages/settings.scss13
-rw-r--r--app/assets/stylesheets/pages/todos.scss2
-rw-r--r--app/assets/stylesheets/pages/users.scss105
-rw-r--r--app/assets/stylesheets/pages/wiki.scss50
-rw-r--r--app/assets/stylesheets/performance_bar.scss11
425 files changed, 7348 insertions, 3183 deletions
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png
new file mode 100644
index 00000000000..c8a86a0c515
--- /dev/null
+++ b/app/assets/images/auth_buttons/salesforce_64.png
Binary files differ
diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js
new file mode 100644
index 00000000000..ff8b4c56321
--- /dev/null
+++ b/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js
@@ -0,0 +1 @@
+export default {};
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 4f66a5d080c..136ffdf8b9d 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -14,6 +14,7 @@ const Api = {
projectPath: '/api/:version/projects/:id',
forkedProjectsPath: '/api/:version/projects/:id/forks',
projectLabelsPath: '/:namespace_path/:project_path/-/labels',
+ projectUsersPath: '/api/:version/projects/:id/users',
projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests',
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
@@ -24,6 +25,7 @@ const Api = {
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
+ userCountsPath: '/api/:version/user_counts',
usersPath: '/api/:version/users.json',
userPath: '/api/:version/users/:id',
userStatusPath: '/api/:version/users/:id/status',
@@ -107,6 +109,20 @@ const Api = {
});
},
+ projectUsers(projectPath, query = '', options = {}) {
+ const url = Api.buildUrl(this.projectUsersPath).replace(':id', encodeURIComponent(projectPath));
+
+ return axios
+ .get(url, {
+ params: {
+ search: query,
+ per_page: 20,
+ ...options,
+ },
+ })
+ .then(({ data }) => data);
+ },
+
// Return single project
project(projectPath) {
const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
@@ -312,6 +328,11 @@ const Api = {
});
},
+ userCounts() {
+ const url = Api.buildUrl(this.userCountsPath);
+ return axios.get(url);
+ },
+
userStatus(id, options) {
const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
diff --git a/app/assets/javascripts/badges/components/badge.vue b/app/assets/javascripts/badges/components/badge.vue
index ae2916e3a3b..eb720f5380b 100644
--- a/app/assets/javascripts/badges/components/badge.vue
+++ b/app/assets/javascripts/badges/components/badge.vue
@@ -3,6 +3,8 @@ import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
+ // name: 'Badge' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Badge',
components: {
Icon,
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index bfb073fdcdc..137cc7b4669 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
+import renderMetrics from './render_metrics';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
import initMRPopovers from '../../mr_popover';
@@ -17,6 +18,7 @@ $.fn.renderGFM = function renderGFM() {
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
initMRPopovers(this.find('.gfm-merge_request').get());
+ renderMetrics(this.find('.js-render-metrics').get());
return this;
};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index b23de36f860..27708504791 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -33,10 +33,12 @@ export default function renderMermaid($els) {
flowchart: {
htmlLabels: false,
},
+ securityLevel: 'strict',
});
$els.each((i, el) => {
- const source = el.textContent;
+ // Mermaid doesn't like `<br />` tags, so collapse all like tags into `<br>`, which is parsed correctly.
+ const source = el.textContent.replace(/<br\s*\/>/g, '<br>');
/**
* Restrict the rendering to a certain amount of character to
diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js
new file mode 100644
index 00000000000..252b98610b6
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import Metrics from '~/monitoring/components/embed.vue';
+import { createStore } from '~/monitoring/stores';
+
+// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-ce/issues/64369.
+export default function renderMetrics(elements) {
+ if (!elements.length) {
+ return;
+ }
+
+ elements.forEach(element => {
+ const { dashboardUrl } = element.dataset;
+ const MetricsComponent = Vue.extend(Metrics);
+
+ // eslint-disable-next-line no-new
+ new MetricsComponent({
+ el: element,
+ store: createStore(),
+ propsData: {
+ dashboardUrl,
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 35874140bf9..b2571fb840c 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -36,6 +36,10 @@ MarkdownPreview.prototype.showPreview = function($form) {
mdText = $form.find('textarea.markdown-area').val();
+ if (mdText === undefined) {
+ return;
+ }
+
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
index eade1283513..7e3515b1f4b 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts.js
@@ -4,7 +4,7 @@ import Mousetrap from 'mousetrap';
import axios from '../../lib/utils/axios_utils';
import { refreshCurrentPage, visitUrl } from '../../lib/utils/url_utility';
import findAndFollowLink from '../../lib/utils/navigation_utility';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { parseBoolean, getCspNonceValue } from '~/lib/utils/common_utils';
const defaultStopCallback = Mousetrap.stopCallback;
Mousetrap.stopCallback = (e, element, combo) => {
@@ -94,7 +94,7 @@ export default class Shortcuts {
responseType: 'text',
})
.then(({ data }) => {
- $.globalEval(data);
+ $.globalEval(data, { nonce: getCspNonceValue() });
if (location && location.length > 0) {
const results = [];
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
index 8b7e6a56d25..208c91a1f08 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_wiki.js
@@ -6,6 +6,8 @@ export default class ShortcutsWiki extends ShortcutsNavigation {
constructor() {
super();
Mousetrap.bind('e', ShortcutsWiki.editWiki);
+
+ this.enabledHelp.push('.hidden-shortcut.wiki');
}
static editWiki() {
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 45b9e57f9ab..c6122fbc686 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
-import { n__ } from '~/locale';
+import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import AccessorUtilities from '../../lib/utils/accessor';
@@ -53,12 +54,19 @@ export default Vue.extend({
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
+ caretTooltip() {
+ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ },
isNewIssueShown() {
return (
this.list.type === 'backlog' ||
(!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
);
},
+ uniqueKey() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
+ },
},
watch: {
filter: {
@@ -72,31 +80,34 @@ export default Vue.extend({
},
},
mounted() {
- this.sortableOptions = getBoardSortableDefaultOptions({
+ const instance = this;
+
+ const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
- onEnd: e => {
+ onEnd(e) {
sortableEnd();
+ const sortable = this;
+
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
- const order = this.sortable.toArray();
+ const order = sortable.toArray();
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
- this.$nextTick(() => {
+ instance.$nextTick(() => {
boardsStore.moveList(list, order);
});
}
},
});
- this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
+ Sortable.create(this.$el.parentNode, sortableOptions);
},
created() {
if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
- const isCollapsed =
- localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
+ const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
this.list.isExpanded = !isCollapsed;
}
@@ -105,16 +116,17 @@ export default Vue.extend({
showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
},
- toggleExpanded(e) {
- if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) {
+ toggleExpanded() {
+ if (this.list.isExpandable) {
this.list.isExpanded = !this.list.isExpanded;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(
- `boards.${this.boardId}.${this.list.type}.expanded`,
- this.list.isExpanded,
- );
+ localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
+
+ // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
+ // Close all tooltips manually to prevent dangling tooltips.
+ $('.tooltip').tooltip('hide');
}
},
},
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 1cbd31729cd..9f26337d153 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -1,5 +1,6 @@
<script>
-/* global ListLabel */
+import { __ } from '~/locale';
+import ListLabel from '~/boards/models/label';
import Cookies from 'js-cookie';
import boardsStore from '../stores/boards_store';
@@ -7,8 +8,8 @@ export default {
data() {
return {
predefinedLabels: [
- new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
- new ListLabel({ title: 'Doing', color: '#5CB85C' }),
+ new ListLabel({ title: __('To Do'), color: '#F0AD4E' }),
+ new ListLabel({ title: __('Doing'), color: '#5CB85C' }),
],
};
},
@@ -29,13 +30,17 @@ export default {
});
// Save the labels
- gl.boardService
+ boardsStore
.generateDefaultLists()
.then(res => res.data)
.then(data => {
data.forEach(listObj => {
const list = boardsStore.findList('title', listObj.title);
+ if (!list) {
+ return;
+ }
+
list.id = listObj.id;
list.label.id = listObj.label.id;
list.getIssues().catch(() => {
@@ -58,30 +63,36 @@ export default {
<template>
<div class="board-blank-state p-3">
- <p>Add the following default lists to your Issue Board with one click:</p>
+ <p>
+ {{
+ s__('BoardBlankState|Add the following default lists to your Issue Board with one click:')
+ }}
+ </p>
<ul class="list-unstyled board-blank-state-list">
<li v-for="(label, index) in predefinedLabels" :key="index">
<span
:style="{ backgroundColor: label.color }"
class="label-color position-relative d-inline-block rounded"
- >
- </span>
+ ></span>
{{ label.title }}
</li>
</ul>
<p>
- Starting out with the default set of lists will get you right on the way to making the most of
- your board.
+ {{
+ s__(
+ 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.',
+ )
+ }}
</p>
<button
class="btn btn-success btn-inverted btn-block"
type="button"
@click.stop="addDefaultLists"
>
- Add default lists
+ {{ s__('BoardBlankState|Add default lists') }}
</button>
<button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState">
- Nevermind, I'll use my own
+ {{ s__("BoardBlankState|Nevermind, I'll use my own") }}
</button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 179148b6887..faf722f61af 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -83,6 +83,7 @@ export default {
}"
:index="index"
:data-issue-id="issue.id"
+ data-qa-selector="board_card"
class="board-card p-3 rounded"
@mousedown="mouseDown"
@mousemove="mouseMove"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
new file mode 100644
index 00000000000..ebf48cee2ae
--- /dev/null
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -0,0 +1,219 @@
+<script>
+import { __ } from '~/locale';
+import Flash from '~/flash';
+import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
+import boardsStore from '~/boards/stores/boards_store';
+
+const boardDefaults = {
+ id: false,
+ name: '',
+ labels: [],
+ milestone_id: undefined,
+ assignee: {},
+ assignee_id: undefined,
+ weight: null,
+};
+
+export default {
+ components: {
+ BoardScope: () => import('ee_component/boards/components/board_scope.vue'),
+ DeprecatedModal,
+ },
+ props: {
+ canAdminBoard: {
+ type: Boolean,
+ required: true,
+ },
+ milestonePath: {
+ type: String,
+ required: true,
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ scopedIssueBoardFeatureEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ projectId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ groupId: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ weights: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ enableScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: false,
+ default: '#',
+ },
+ },
+ data() {
+ return {
+ board: { ...boardDefaults, ...this.currentBoard },
+ currentBoard: boardsStore.state.currentBoard,
+ currentPage: boardsStore.state.currentPage,
+ isLoading: false,
+ };
+ },
+ computed: {
+ isNewForm() {
+ return this.currentPage === 'new';
+ },
+ isDeleteForm() {
+ return this.currentPage === 'delete';
+ },
+ isEditForm() {
+ return this.currentPage === 'edit';
+ },
+ isVisible() {
+ return this.currentPage !== '';
+ },
+ buttonText() {
+ if (this.isNewForm) {
+ return __('Create board');
+ }
+ if (this.isDeleteForm) {
+ return __('Delete');
+ }
+ return __('Save changes');
+ },
+ buttonKind() {
+ if (this.isNewForm) {
+ return 'success';
+ }
+ if (this.isDeleteForm) {
+ return 'danger';
+ }
+ return 'info';
+ },
+ title() {
+ if (this.isNewForm) {
+ return __('Create new board');
+ }
+ if (this.isDeleteForm) {
+ return __('Delete board');
+ }
+ if (this.readonly) {
+ return __('Board scope');
+ }
+ return __('Edit board');
+ },
+ readonly() {
+ return !this.canAdminBoard;
+ },
+ submitDisabled() {
+ return this.isLoading || this.board.name.length === 0;
+ },
+ },
+ mounted() {
+ this.resetFormState();
+ if (this.$refs.name) {
+ this.$refs.name.focus();
+ }
+ },
+ methods: {
+ submit() {
+ if (this.board.name.length === 0) return;
+ this.isLoading = true;
+ if (this.isDeleteForm) {
+ gl.boardService
+ .deleteBoard(this.currentBoard)
+ .then(() => {
+ visitUrl(boardsStore.rootPath);
+ })
+ .catch(() => {
+ Flash(__('Failed to delete board. Please try again.'));
+ this.isLoading = false;
+ });
+ } else {
+ gl.boardService
+ .createBoard(this.board)
+ .then(resp => resp.data)
+ .then(data => {
+ visitUrl(data.board_path);
+ })
+ .catch(() => {
+ Flash(__('Unable to save your changes. Please try again.'));
+ this.isLoading = false;
+ });
+ }
+ },
+ cancel() {
+ boardsStore.showPage('');
+ },
+ resetFormState() {
+ if (this.isNewForm) {
+ // Clear the form when we open the "New board" modal
+ this.board = { ...boardDefaults };
+ } else if (this.currentBoard && Object.keys(this.currentBoard).length) {
+ this.board = { ...boardDefaults, ...this.currentBoard };
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <deprecated-modal
+ v-show="isVisible"
+ :hide-footer="readonly"
+ :title="title"
+ :primary-button-label="buttonText"
+ :kind="buttonKind"
+ :submit-disabled="submitDisabled"
+ modal-dialog-class="board-config-modal"
+ @cancel="cancel"
+ @submit="submit"
+ >
+ <template slot="body">
+ <p v-if="isDeleteForm">{{ __('Are you sure you want to delete this board?') }}</p>
+ <form v-else class="js-board-config-modal" @submit.prevent>
+ <div v-if="!readonly" class="append-bottom-20">
+ <label class="form-section-title label-bold" for="board-new-name">{{
+ __('Board name')
+ }}</label>
+ <input
+ id="board-new-name"
+ ref="name"
+ v-model="board.name"
+ class="form-control"
+ type="text"
+ :placeholder="__('Enter board name')"
+ @keyup.enter="submit"
+ />
+ </div>
+
+ <board-scope
+ v-if="scopedIssueBoardFeatureEnabled"
+ :collapse-scope="isNewForm"
+ :board="board"
+ :can-admin-board="canAdminBoard"
+ :milestone-path="milestonePath"
+ :labels-path="labelsPath"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
+ :project-id="projectId"
+ :group-id="groupId"
+ :weights="weights"
+ />
+ </form>
+ </template>
+ </deprecated-modal>
+</template>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index b1a8b13f3ac..de41698ca04 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import Sortable from 'sortablejs';
import { GlLoadingIcon } from '@gitlab/ui';
import boardNewIssue from './board_new_issue.vue';
@@ -226,8 +227,9 @@ export default {
<div
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
class="board-list-component position-relative h-100"
+ data-qa-selector="board_list_cards_area"
>
- <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues">
+ <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
<gl-loading-icon />
</div>
<board-new-issue
@@ -257,7 +259,7 @@ export default {
/>
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
- <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span>
+ <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span>
</li>
</ul>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index cc6af8e88cd..4180023b7db 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -102,9 +102,9 @@ export default {
<div class="board-card position-relative p-3 rounded">
<form @submit="submit($event)">
<div v-if="error" class="flash-container">
- <div class="flash-alert">An error occurred. Please try again.</div>
+ <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
</div>
- <label :for="list.id + '-title'" class="label-bold"> Title </label>
+ <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input
:id="list.id + '-title'"
ref="input"
@@ -122,12 +122,11 @@ export default {
class="float-left"
variant="success"
type="submit"
+ >{{ __('Submit issue') }}</gl-button
>
- Submit issue
- </gl-button>
- <gl-button class="float-right" type="button" variant="default" @click="cancel">
- Cancel
- </gl-button>
+ <gl-button class="float-right" type="button" variant="default" @click="cancel">{{
+ __('Cancel')
+ }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 2ace0060c42..ba1fe9202fc 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -22,6 +22,8 @@ export default Vue.extend({
components: {
AssigneeTitle,
Assignees,
+ SidebarEpicsSelect: () =>
+ import('ee_component/sidebar/components/sidebar_item_epics_select.vue'),
RemoveBtn,
Subscriptions,
TimeTracker,
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
new file mode 100644
index 00000000000..7296426549a
--- /dev/null
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -0,0 +1,335 @@
+<script>
+import { throttle } from 'underscore';
+import {
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownHeader,
+ GlDropdownItem,
+} from '@gitlab/ui';
+
+import Icon from '~/vue_shared/components/icon.vue';
+import httpStatusCodes from '~/lib/utils/http_status';
+import boardsStore from '../stores/boards_store';
+import BoardForm from './board_form.vue';
+
+const MIN_BOARDS_TO_VIEW_RECENT = 10;
+
+export default {
+ name: 'BoardsSelector',
+ components: {
+ Icon,
+ BoardForm,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownHeader,
+ GlDropdownItem,
+ },
+ props: {
+ currentBoard: {
+ type: Object,
+ required: true,
+ },
+ milestonePath: {
+ type: String,
+ required: true,
+ },
+ throttleDuration: {
+ type: Number,
+ default: 200,
+ },
+ boardBaseUrl: {
+ type: String,
+ required: true,
+ },
+ hasMissingBoards: {
+ type: Boolean,
+ required: true,
+ },
+ canAdminBoard: {
+ type: Boolean,
+ required: true,
+ },
+ multipleIssueBoardsAvailable: {
+ type: Boolean,
+ required: true,
+ },
+ labelsPath: {
+ type: String,
+ required: true,
+ },
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ scopedIssueBoardFeatureEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ weights: {
+ type: Array,
+ required: true,
+ },
+ enabledScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: false,
+ default: '#',
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ hasScrollFade: false,
+ scrollFadeInitialized: false,
+ boards: [],
+ recentBoards: [],
+ state: boardsStore.state,
+ throttledSetScrollFade: throttle(this.setScrollFade, this.throttleDuration),
+ contentClientHeight: 0,
+ maxPosition: 0,
+ store: boardsStore,
+ filterTerm: '',
+ };
+ },
+ computed: {
+ currentPage() {
+ return this.state.currentPage;
+ },
+ filteredBoards() {
+ return this.boards.filter(board =>
+ board.name.toLowerCase().includes(this.filterTerm.toLowerCase()),
+ );
+ },
+ reload: {
+ get() {
+ return this.state.reload;
+ },
+ set(newValue) {
+ this.state.reload = newValue;
+ },
+ },
+ board() {
+ return this.state.currentBoard;
+ },
+ showDelete() {
+ return this.boards.length > 1;
+ },
+ scrollFadeClass() {
+ return {
+ 'fade-out': !this.hasScrollFade,
+ };
+ },
+ showRecentSection() {
+ return (
+ this.recentBoards.length &&
+ this.boards.length > MIN_BOARDS_TO_VIEW_RECENT &&
+ !this.filterTerm.length
+ );
+ },
+ },
+ watch: {
+ filteredBoards() {
+ this.scrollFadeInitialized = false;
+ this.$nextTick(this.setScrollFade);
+ },
+ reload() {
+ if (this.reload) {
+ this.boards = [];
+ this.recentBoards = [];
+ this.loading = true;
+ this.reload = false;
+
+ this.loadBoards(false);
+ }
+ },
+ },
+ created() {
+ boardsStore.setCurrentBoard(this.currentBoard);
+ },
+ methods: {
+ showPage(page) {
+ boardsStore.showPage(page);
+ },
+ loadBoards(toggleDropdown = true) {
+ if (toggleDropdown && this.boards.length > 0) {
+ return;
+ }
+
+ const recentBoardsPromise = new Promise((resolve, reject) =>
+ gl.boardService
+ .recentBoards()
+ .then(resolve)
+ .catch(err => {
+ /**
+ * If user is unauthorized we'd still want to resolve the
+ * request to display all boards.
+ */
+ if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
+ resolve({ data: [] }); // recent boards are empty
+ return;
+ }
+ reject(err);
+ }),
+ );
+
+ Promise.all([gl.boardService.allBoards(), recentBoardsPromise])
+ .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
+ .then(([allBoardsJson, recentBoardsJson]) => {
+ this.loading = false;
+ this.boards = allBoardsJson;
+ this.recentBoards = recentBoardsJson;
+ })
+ .then(() => this.$nextTick()) // Wait for boards list in DOM
+ .then(() => {
+ this.setScrollFade();
+ })
+ .catch(() => {
+ this.loading = false;
+ });
+ },
+ isScrolledUp() {
+ const { content } = this.$refs;
+ const currentPosition = this.contentClientHeight + content.scrollTop;
+
+ return content && currentPosition < this.maxPosition;
+ },
+ initScrollFade() {
+ this.scrollFadeInitialized = true;
+
+ const { content } = this.$refs;
+
+ this.contentClientHeight = content.clientHeight;
+ this.maxPosition = content.scrollHeight;
+ },
+ setScrollFade() {
+ if (!this.scrollFadeInitialized) this.initScrollFade();
+
+ this.hasScrollFade = this.isScrolledUp();
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="boards-switcher js-boards-selector append-right-10">
+ <span class="boards-selector-wrapper js-boards-selector-wrapper">
+ <gl-dropdown
+ data-qa-selector="boards_dropdown"
+ toggle-class="dropdown-menu-toggle js-dropdown-toggle"
+ menu-class="flex-column dropdown-extended-height"
+ :text="board.name"
+ @show="loadBoards"
+ >
+ <div>
+ <div class="dropdown-title mb-0" @mousedown.prevent>
+ {{ s__('IssueBoards|Switch board') }}
+ </div>
+ </div>
+
+ <gl-dropdown-header class="mt-0">
+ <gl-search-box-by-type ref="searchBox" v-model="filterTerm" />
+ </gl-dropdown-header>
+
+ <div
+ v-if="!loading"
+ ref="content"
+ class="dropdown-content flex-fill"
+ @scroll.passive="throttledSetScrollFade"
+ >
+ <gl-dropdown-item
+ v-show="filteredBoards.length === 0"
+ class="no-pointer-events text-secondary"
+ >
+ {{ s__('IssueBoards|No matching boards found') }}
+ </gl-dropdown-item>
+
+ <h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
+ {{ __('Recent') }}
+ </h6>
+
+ <template v-if="showRecentSection">
+ <gl-dropdown-item
+ v-for="recentBoard in recentBoards"
+ :key="`recent-${recentBoard.id}`"
+ class="js-dropdown-item"
+ :href="`${boardBaseUrl}/${recentBoard.id}`"
+ >
+ {{ recentBoard.name }}
+ </gl-dropdown-item>
+ </template>
+
+ <hr v-if="showRecentSection" class="my-1" />
+
+ <h6 v-if="showRecentSection" class="dropdown-bold-header my-0">
+ {{ __('All') }}
+ </h6>
+
+ <gl-dropdown-item
+ v-for="otherBoard in filteredBoards"
+ :key="otherBoard.id"
+ class="js-dropdown-item"
+ :href="`${boardBaseUrl}/${otherBoard.id}`"
+ >
+ {{ otherBoard.name }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="hasMissingBoards" class="small unclickable">
+ {{
+ s__(
+ 'IssueBoards|Some of your boards are hidden, activate a license to see them again.',
+ )
+ }}
+ </gl-dropdown-item>
+ </div>
+
+ <div
+ v-show="filteredBoards.length > 0"
+ class="dropdown-content-faded-mask"
+ :class="scrollFadeClass"
+ ></div>
+
+ <gl-loading-icon v-if="loading" />
+
+ <div v-if="canAdminBoard">
+ <gl-dropdown-divider />
+
+ <gl-dropdown-item v-if="multipleIssueBoardsAvailable" @click.prevent="showPage('new')">
+ {{ s__('IssueBoards|Create new board') }}
+ </gl-dropdown-item>
+
+ <gl-dropdown-item
+ v-if="showDelete"
+ class="text-danger"
+ @click.prevent="showPage('delete')"
+ >
+ {{ s__('IssueBoards|Delete board') }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+
+ <board-form
+ v-if="currentPage"
+ :milestone-path="milestonePath"
+ :labels-path="labelsPath"
+ :project-id="projectId"
+ :group-id="groupId"
+ :can-admin-board="canAdminBoard"
+ :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
+ :weights="weights"
+ :enable-scoped-labels="enabledScopedLabels"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ />
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index a8516f178fc..7f554c99669 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -124,7 +124,7 @@ export default {
return `${this.rootPath}${assignee.username}`;
},
avatarUrlTitle(assignee) {
- return `Avatar for ${assignee.name}`;
+ return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
},
showLabel(label) {
if (!label.id) return false;
@@ -160,9 +160,10 @@ export default {
:title="__('Confidential')"
class="confidential-icon append-right-4"
:aria-label="__('Confidential')"
- /><a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{
- issue.title
- }}</a>
+ />
+ <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>
+ {{ issue.title }}
+ </a>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
@@ -204,13 +205,13 @@ export default {
placement="bottom"
class="board-issue-path block-truncated bold"
>{{ issueReferencePath }}</tooltip-on-truncate
- >#{{ issue.iid }}
+ >
+ #{{ issue.iid }}
</span>
<span class="board-info-items prepend-top-8 d-inline-block">
- <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate
- v-if="issue.timeEstimate"
- :estimate="issue.timeEstimate"
- /><issue-card-weight
+ <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" />
+ <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
+ <issue-card-weight
v-if="issue.weight"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
@@ -230,7 +231,8 @@ export default {
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
- <span class="bold d-block">Assignee</span> {{ assignee.name }}
+ <span class="bold d-block">{{ __('Assignee') }}</span>
+ {{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
@@ -240,9 +242,8 @@ export default {
:title="assigneeCounterTooltip"
class="avatar-counter"
data-placement="bottom"
+ >{{ assigneeCounterLabel }}</span
>
- {{ assigneeCounterLabel }}
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
index 091700de93f..66f59009714 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ b/app/assets/javascripts/boards/components/modal/empty_state.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
@@ -20,19 +21,20 @@ export default {
computed: {
contents() {
const obj = {
- title: "You haven't added any issues to your project yet",
- content: `
- An issue can be a bug, a todo or a feature request that needs to be
- discussed in a project. Besides, issues are searchable and filterable.
- `,
+ title: __("You haven't added any issues to your project yet"),
+ content: __(
+ 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.',
+ ),
};
if (this.activeTab === 'selected') {
- obj.title = "You haven't selected any issues yet";
- obj.content = `
- Go back to <strong>Open issues</strong> and select some issues
- to add to your board.
- `;
+ obj.title = __("You haven't selected any issues yet");
+ obj.content = sprintf(
+ __(
+ 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.',
+ ),
+ { startTag: '<strong>', endTag: '</strong>' },
+ );
}
return obj;
@@ -51,16 +53,16 @@ export default {
<div class="text-content">
<h4>{{ contents.title }}</h4>
<p v-html="contents.content"></p>
- <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">
- New issue
- </a>
+ <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">{{
+ __('New issue')
+ }}</a>
<button
v-if="activeTab === 'selected'"
class="btn btn-default"
type="button"
@click="changeTab('all')"
>
- Open issues
+ {{ __('Open issues') }}
</button>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
index d4afd9d59da..5f100c617a0 100644
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ b/app/assets/javascripts/boards/components/modal/footer.vue
@@ -1,8 +1,8 @@
<script>
+import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer';
import Flash from '../../../flash';
-import { __ } from '../../../locale';
+import { __, n__ } from '../../../locale';
import ListsDropdown from './lists_dropdown.vue';
-import { pluralize } from '../../../lib/utils/text_utility';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
import boardsStore from '../../stores/boards_store';
@@ -11,7 +11,7 @@ export default {
components: {
ListsDropdown,
},
- mixins: [modalMixin],
+ mixins: [modalMixin, footerEEMixin],
data() {
return {
modal: ModalStore.store,
@@ -24,8 +24,8 @@ export default {
},
submitText() {
const count = ModalStore.selectedCount();
-
- return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`;
+ if (!count) return __('Add issues');
+ return n__(`Add %d issue`, `Add %d issues`, count);
},
},
methods: {
@@ -42,7 +42,7 @@ export default {
const req = this.buildUpdateRequest(list);
// Post the data to the backend
- gl.boardService.bulkUpdate(issueIds, req).catch(() => {
+ boardsStore.bulkUpdate(issueIds, req).catch(() => {
Flash(__('Failed to update issues, please try again.'));
selectedIssues.forEach(issue => {
@@ -68,11 +68,11 @@ export default {
<button :disabled="submitDisabled" class="btn btn-success" type="button" @click="addIssues">
{{ submitText }}
</button>
- <span class="inline add-issues-footer-to-list"> to list </span>
+ <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span>
<lists-dropdown />
</div>
<button class="btn btn-default float-right" type="button" @click="toggleModal(false)">
- Cancel
+ {{ __('Cancel') }}
</button>
</footer>
</template>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index 1cfa6d39362..8cd4840d3d6 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -1,4 +1,6 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
+import { __ } from '~/locale';
import ModalFilters from './filters';
import ModalTabs from './tabs.vue';
import ModalStore from '../../stores/modal_store';
@@ -30,10 +32,10 @@ export default {
computed: {
selectAllText() {
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
- return 'Select all';
+ return __('Select all');
}
- return 'Deselect all';
+ return __('Deselect all');
},
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
@@ -57,7 +59,7 @@ export default {
type="button"
class="close"
data-dismiss="modal"
- aria-label="Close"
+ :aria-label="__('Close')"
@click="toggleModal(false)"
>
<span aria-hidden="true">×</span>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index 28d2019af2f..1802b543687 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -123,7 +123,9 @@ export default {
class="empty-state add-issues-empty-state-filter text-center"
>
<div class="svg-content"><img :src="emptyStateSvg" /></div>
- <div class="text-content"><h4>There are no issues to show.</h4></div>
+ <div class="text-content">
+ <h4>{{ __('There are no issues to show.') }}</h4>
+ </div>
</div>
<div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column">
<div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
index 2d2920e312e..7430fc96654 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ b/app/assets/javascripts/boards/components/modal/tabs.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 8274647744f..e8d25e84be1 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import $ from 'jquery';
import _ from 'underscore';
import Icon from '~/vue_shared/components/icon.vue';
@@ -27,7 +28,7 @@ export default {
},
computed: {
selectedProjectName() {
- return this.selectedProject.name || 'Select a project';
+ return this.selectedProject.name || __('Select a project');
},
},
mounted() {
@@ -67,13 +68,15 @@ export default {
<li>
<a href='#' class='dropdown-menu-link' data-project-id="${
project.id
- }" data-project-name="${project.name}">
- ${_.escape(project.name)}
+ }" data-project-name="${project.name}" data-project-name-with-namespace="${
+ project.name_with_namespace
+ }">
+ ${_.escape(project.name_with_namespace)}
</a>
</li>
`;
},
- text: project => project.name,
+ text: project => project.name_with_namespace,
});
},
};
@@ -81,7 +84,7 @@ export default {
<template>
<div>
- <label class="label-bold prepend-top-10"> Project </label>
+ <label class="label-bold prepend-top-10">{{ __('Project') }}</label>
<div ref="projectsDropdown" class="dropdown dropdown-projects">
<button
class="dropdown-menu-toggle wide"
@@ -92,9 +95,9 @@ export default {
{{ selectedProjectName }} <icon name="chevron-down" />
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
- <div class="dropdown-title">Projects</div>
+ <div class="dropdown-title">{{ __('Projects') }}</div>
<div class="dropdown-input">
- <input class="dropdown-input-field" type="search" placeholder="Search projects" />
+ <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
</div>
<div class="dropdown-content"></div>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
index 4ab2b17301f..b84722244d1 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
@@ -76,7 +76,7 @@ export default Vue.extend({
<template>
<div class="block list">
<button class="btn btn-default btn-block" type="button" @click="removeIssue">
- Remove from board
+ {{ __('Remove from board') }}
</button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/config_toggle.js b/app/assets/javascripts/boards/config_toggle.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/boards/config_toggle.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/boards/ee_functions.js b/app/assets/javascripts/boards/ee_functions.js
new file mode 100644
index 00000000000..583270fcae5
--- /dev/null
+++ b/app/assets/javascripts/boards/ee_functions.js
@@ -0,0 +1,7 @@
+export const setPromotionState = () => {};
+
+export const setWeigthFetchingState = () => {};
+export const setEpicFetchingState = () => {};
+
+export const getMilestoneTitle = () => ({});
+export const getBoardsModalData = () => ({});
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index 6b54e8baefb..b1b4b1c5508 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -2,7 +2,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable
import FilteredSearchContainer from '../filtered_search/container';
import FilteredSearchManager from '../filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store';
-import { isEE } from '~/lib/utils/common_utils';
export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
@@ -10,7 +9,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
page: 'boards',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
- isGroup: isEE(),
+ isGroup: IS_EE,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index a020765f335..3bded4a3258 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -6,28 +6,38 @@ import { __ } from '~/locale';
import './models/label';
import './models/assignee';
-import FilteredSearchBoards from './filtered_search_boards';
-import eventHub from './eventhub';
+import FilteredSearchBoards from '~/boards/filtered_search_boards';
+import eventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
-import './models/issue';
-import './models/list';
-import './models/milestone';
-import './models/project';
-import boardsStore from './stores/boards_store';
-import ModalStore from './stores/modal_store';
-import BoardService from './services/board_service';
-import modalMixin from './mixins/modal_mixins';
-import './filters/due_date_filters';
-import Board from './components/board';
-import BoardSidebar from './components/board_sidebar';
-import initNewListDropdown from './components/new_list_dropdown';
-import BoardAddIssuesModal from './components/modal/index.vue';
+import 'ee_else_ce/boards/models/issue';
+import 'ee_else_ce/boards/models/list';
+import '~/boards/models/milestone';
+import '~/boards/models/project';
+import boardsStore from '~/boards/stores/boards_store';
+import ModalStore from '~/boards/stores/modal_store';
+import BoardService from 'ee_else_ce/boards/services/board_service';
+import modalMixin from '~/boards/mixins/modal_mixins';
+import '~/boards/filters/due_date_filters';
+import Board from 'ee_else_ce/boards/components/board';
+import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
+import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
+import BoardAddIssuesModal from '~/boards/components/modal/index.vue';
import '~/vue_shared/vue_resource_interceptor';
import {
NavigationType,
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
+import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
+import toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
+import {
+ setPromotionState,
+ setWeigthFetchingState,
+ setEpicFetchingState,
+ getMilestoneTitle,
+ getBoardsModalData,
+} from 'ee_else_ce/boards/ee_functions';
+import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
let issueBoardsApp;
@@ -78,13 +88,14 @@ export default () => {
},
},
created() {
- gl.boardService = new BoardService({
+ boardsStore.setEndpoints({
boardsEndpoint: this.boardsEndpoint,
recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
});
+ gl.boardService = new BoardService();
boardsStore.rootPath = this.boardsEndpoint;
eventHub.$on('updateTokens', this.updateTokens);
@@ -125,6 +136,7 @@ export default () => {
});
boardsStore.addBlankState();
+ setPromotionState(boardsStore);
this.loading = false;
})
.catch(() => {
@@ -139,6 +151,8 @@ export default () => {
const { sidebarInfoEndpoint } = newIssue;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
+ setWeigthFetchingState(newIssue, true);
+ setEpicFetchingState(newIssue, true);
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data)
.then(data => {
@@ -153,6 +167,8 @@ export default () => {
} = convertObjectPropsToCamelCase(data);
newIssue.setFetchingState('subscriptions', false);
+ setWeigthFetchingState(newIssue, false);
+ setEpicFetchingState(newIssue, false);
newIssue.updateData({
humanTimeSpent: humanTotalTimeSpent,
timeSpent: totalTimeSpent,
@@ -165,6 +181,7 @@ export default () => {
})
.catch(() => {
newIssue.setFetchingState('subscriptions', false);
+ setWeigthFetchingState(newIssue, false);
Flash(__('An error occurred while fetching sidebar data'));
});
}
@@ -199,12 +216,15 @@ export default () => {
el: document.getElementById('js-add-list'),
data: {
filters: boardsStore.state.filters,
+ ...getMilestoneTitle($boardApp),
},
mounted() {
initNewListDropdown();
},
});
+ boardConfigToggle(boardsStore);
+
const issueBoardsModal = document.getElementById('js-add-issues-btn');
if (issueBoardsModal) {
@@ -216,6 +236,7 @@ export default () => {
return {
modal: ModalStore.store,
store: boardsStore.state,
+ ...getBoardsModalData($boardApp),
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
@@ -278,4 +299,7 @@ export default () => {
`,
});
}
+
+ toggleFocusMode(ModalStore, boardsStore, $boardApp);
+ mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/mixins/modal_footer.js b/app/assets/javascripts/boards/mixins/modal_footer.js
new file mode 100644
index 00000000000..ff8b4c56321
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/modal_footer.js
@@ -0,0 +1 @@
+export default {};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 636ca99952c..68ea28e68d9 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) {
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
const defaultSortOptions = Object.assign({}, sortableConfig, {
- filter: '.board-delete, .btn',
+ filter: '.no-drag',
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20,
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index f858b162c6b..9069b35db9a 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -5,7 +5,7 @@
import Vue from 'vue';
import './label';
-import { isEE, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssueProject from './project';
import boardsStore from '../stores/boards_store';
@@ -91,13 +91,13 @@ class ListIssue {
addMilestone(milestone) {
const miletoneId = this.milestone ? this.milestone.id : null;
- if (isEE && milestone.id !== miletoneId) {
+ if (IS_EE && milestone.id !== miletoneId) {
this.milestone = new ListMilestone(milestone);
}
}
removeMilestone(removeMilestone) {
- if (isEE && removeMilestone && removeMilestone.id === this.milestone.id) {
+ if (IS_EE && removeMilestone && removeMilestone.id === this.milestone.id) {
this.milestone = {};
}
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index a9d88f19146..7e0ccb9bd2a 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -4,7 +4,7 @@
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
-import { isEE, urlParamsToObject } from '~/lib/utils/common_utils';
+import { urlParamsToObject } from '~/lib/utils/common_utils';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
@@ -26,6 +26,12 @@ const TYPES = {
isExpandable: false,
isBlank: true,
},
+ default: {
+ // includes label, assignee, and milestone lists
+ isPreset: false,
+ isExpandable: true,
+ isBlank: false,
+ },
};
class List {
@@ -52,7 +58,7 @@ class List {
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
- } else if (isEE && obj.milestone) {
+ } else if (IS_EE && obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
this.title = this.milestone.title;
}
@@ -79,7 +85,7 @@ class List {
entityType = 'label_id';
} else if (this.assignee) {
entityType = 'assignee_id';
- } else if (isEE && this.milestone) {
+ } else if (IS_EE && this.milestone) {
entityType = 'milestone_id';
}
@@ -199,7 +205,7 @@ class List {
issue.addAssignee(this.assignee);
}
- if (isEE && this.milestone) {
+ if (IS_EE && this.milestone) {
if (listFrom && listFrom.type === 'milestone') {
issue.removeMilestone(listFrom.milestone);
}
@@ -249,7 +255,7 @@ class List {
}
getTypeInfo(type) {
- return TYPES[type] || {};
+ return TYPES[type] || TYPES.default;
}
onNewIssueResponse(issue, data) {
diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js
index 6f81d6bc6f8..7201b6e91f5 100644
--- a/app/assets/javascripts/boards/models/milestone.js
+++ b/app/assets/javascripts/boards/models/milestone.js
@@ -1,11 +1,9 @@
-import { isEE } from '~/lib/utils/common_utils';
-
export default class ListMilestone {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
- if (isEE) {
+ if (IS_EE) {
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
new file mode 100644
index 00000000000..8d22f009784
--- /dev/null
+++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import BoardsSelector from '~/boards/components/boards_selector.vue';
+
+export default () => {
+ const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
+ return new Vue({
+ el: boardsSwitcherElement,
+ components: {
+ BoardsSelector,
+ },
+ data() {
+ const { dataset } = boardsSwitcherElement;
+
+ const boardsSelectorProps = {
+ ...dataset,
+ currentBoard: JSON.parse(dataset.currentBoard),
+ hasMissingBoards: parseBoolean(dataset.hasMissingBoards),
+ canAdminBoard: parseBoolean(dataset.canAdminBoard),
+ multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable),
+ projectId: Number(dataset.projectId),
+ groupId: Number(dataset.groupId),
+ scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
+ weights: JSON.parse(dataset.weights),
+ };
+
+ return { boardsSelectorProps };
+ },
+ render(createElement) {
+ return createElement(BoardsSelector, {
+ props: this.boardsSelectorProps,
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 7d463f17ab1..56a6cab6c73 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -1,106 +1,87 @@
-import axios from '../../lib/utils/axios_utils';
-import { mergeUrlParams } from '../../lib/utils/url_utility';
+/* eslint-disable class-methods-use-this */
+/**
+ * This file is intended to be deleted.
+ * The existing functions will removed one by one in favor of using the board store directly.
+ * see https://gitlab.com/gitlab-org/gitlab-ce/issues/61621
+ */
-export default class BoardService {
- constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
- this.boardsEndpoint = boardsEndpoint;
- this.boardId = boardId;
- this.listsEndpoint = listsEndpoint;
- this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
- this.bulkUpdatePath = bulkUpdatePath;
- this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`;
- }
+import boardsStore from '~/boards/stores/boards_store';
+export default class BoardService {
generateBoardsPath(id) {
- return `${this.boardsEndpoint}${id ? `/${id}` : ''}.json`;
+ return boardsStore.generateBoardsPath(id);
}
generateIssuesPath(id) {
- return `${this.listsEndpoint}${id ? `/${id}` : ''}/issues`;
+ return boardsStore.generateIssuesPath(id);
}
static generateIssuePath(boardId, id) {
- return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${
- id ? `/${id}` : ''
- }`;
+ return boardsStore.generateIssuePath(boardId, id);
}
all() {
- return axios.get(this.listsEndpoint);
+ return boardsStore.all();
}
generateDefaultLists() {
- return axios.post(this.listsEndpointGenerate, {});
+ return boardsStore.generateDefaultLists();
}
createList(entityId, entityType) {
- const list = {
- [entityType]: entityId,
- };
-
- return axios.post(this.listsEndpoint, {
- list,
- });
+ return boardsStore.createList(entityId, entityType);
}
updateList(id, position) {
- return axios.put(`${this.listsEndpoint}/${id}`, {
- list: {
- position,
- },
- });
+ return boardsStore.updateList(id, position);
}
destroyList(id) {
- return axios.delete(`${this.listsEndpoint}/${id}`);
+ return boardsStore.destroyList(id);
}
getIssuesForList(id, filter = {}) {
- const data = { id };
- Object.keys(filter).forEach(key => {
- data[key] = filter[key];
- });
-
- return axios.get(mergeUrlParams(data, this.generateIssuesPath(id)));
+ return boardsStore.getIssuesForList(id, filter);
}
moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
- return axios.put(BoardService.generateIssuePath(this.boardId, id), {
- from_list_id: fromListId,
- to_list_id: toListId,
- move_before_id: moveBeforeId,
- move_after_id: moveAfterId,
- });
+ return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
}
newIssue(id, issue) {
- return axios.post(this.generateIssuesPath(id), {
- issue,
- });
+ return boardsStore.newIssue(id, issue);
}
getBacklog(data) {
- return axios.get(
- mergeUrlParams(data, `${gon.relative_url_root}/-/boards/${this.boardId}/issues.json`),
- );
+ return boardsStore.getBacklog(data);
}
bulkUpdate(issueIds, extraData = {}) {
- const data = {
- update: Object.assign(extraData, {
- issuable_ids: issueIds.join(','),
- }),
- };
-
- return axios.post(this.bulkUpdatePath, data);
+ return boardsStore.bulkUpdate(issueIds, extraData);
}
static getIssueInfo(endpoint) {
- return axios.get(endpoint);
+ return boardsStore.getIssueInfo(endpoint);
}
static toggleIssueSubscription(endpoint) {
- return axios.post(endpoint);
+ return boardsStore.toggleIssueSubscription(endpoint);
+ }
+
+ allBoards() {
+ return boardsStore.allBoards();
+ }
+
+ recentBoards() {
+ return boardsStore.recentBoards();
+ }
+
+ createBoard(board) {
+ return boardsStore.createBoard(board);
+ }
+
+ deleteBoard({ id }) {
+ return boardsStore.deleteBoard({ id });
}
}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 4ba4cde6bae..f57c684691c 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -8,6 +8,8 @@ import Cookies from 'js-cookie';
import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../eventhub';
const boardsStore = {
@@ -28,6 +30,7 @@ const boardsStore = {
},
currentPage: '',
reload: false,
+ endpoints: {},
},
detail: {
issue: {},
@@ -36,6 +39,19 @@ const boardsStore = {
issue: {},
list: {},
},
+
+ setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
+ const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
+ this.state.endpoints = {
+ boardsEndpoint,
+ boardId,
+ listsEndpoint,
+ listsEndpointGenerate,
+ bulkUpdatePath,
+ recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
+ };
+ },
+
create() {
this.state.lists = [];
this.filter.path = getUrlParamsArray().join('&');
@@ -229,6 +245,139 @@ const boardsStore = {
setTimeTrackingLimitToHours(limitToHours) {
this.timeTracking.limitToHours = parseBoolean(limitToHours);
},
+
+ generateBoardsPath(id) {
+ return `${this.state.endpoints.boardsEndpoint}${id ? `/${id}` : ''}.json`;
+ },
+
+ generateIssuesPath(id) {
+ return `${this.state.endpoints.listsEndpoint}${id ? `/${id}` : ''}/issues`;
+ },
+
+ generateIssuePath(boardId, id) {
+ return `${gon.relative_url_root}/-/boards/${boardId ? `${boardId}` : ''}/issues${
+ id ? `/${id}` : ''
+ }`;
+ },
+
+ all() {
+ return axios.get(this.state.endpoints.listsEndpoint);
+ },
+
+ generateDefaultLists() {
+ return axios.post(this.state.endpoints.listsEndpointGenerate, {});
+ },
+
+ createList(entityId, entityType) {
+ const list = {
+ [entityType]: entityId,
+ };
+
+ return axios.post(this.state.endpoints.listsEndpoint, {
+ list,
+ });
+ },
+
+ updateList(id, position) {
+ return axios.put(`${this.state.endpoints.listsEndpoint}/${id}`, {
+ list: {
+ position,
+ },
+ });
+ },
+
+ destroyList(id) {
+ return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
+ },
+
+ getIssuesForList(id, filter = {}) {
+ const data = { id };
+ Object.keys(filter).forEach(key => {
+ data[key] = filter[key];
+ });
+
+ return axios.get(mergeUrlParams(data, this.generateIssuesPath(id)));
+ },
+
+ moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
+ return axios.put(this.generateIssuePath(this.state.endpoints.boardId, id), {
+ from_list_id: fromListId,
+ to_list_id: toListId,
+ move_before_id: moveBeforeId,
+ move_after_id: moveAfterId,
+ });
+ },
+
+ newIssue(id, issue) {
+ return axios.post(this.generateIssuesPath(id), {
+ issue,
+ });
+ },
+
+ getBacklog(data) {
+ return axios.get(
+ mergeUrlParams(
+ data,
+ `${gon.relative_url_root}/-/boards/${this.state.endpoints.boardId}/issues.json`,
+ ),
+ );
+ },
+
+ bulkUpdate(issueIds, extraData = {}) {
+ const data = {
+ update: Object.assign(extraData, {
+ issuable_ids: issueIds.join(','),
+ }),
+ };
+
+ return axios.post(this.state.endpoints.bulkUpdatePath, data);
+ },
+
+ getIssueInfo(endpoint) {
+ return axios.get(endpoint);
+ },
+
+ toggleIssueSubscription(endpoint) {
+ return axios.post(endpoint);
+ },
+
+ allBoards() {
+ return axios.get(this.generateBoardsPath());
+ },
+
+ recentBoards() {
+ return axios.get(this.state.endpoints.recentBoardsEndpoint);
+ },
+
+ createBoard(board) {
+ const boardPayload = { ...board };
+ boardPayload.label_ids = (board.labels || []).map(b => b.id);
+
+ if (boardPayload.label_ids.length === 0) {
+ boardPayload.label_ids = [''];
+ }
+
+ if (boardPayload.assignee) {
+ boardPayload.assignee_id = boardPayload.assignee.id;
+ }
+
+ if (boardPayload.milestone) {
+ boardPayload.milestone_id = boardPayload.milestone.id;
+ }
+
+ if (boardPayload.id) {
+ return axios.put(this.generateBoardsPath(boardPayload.id), { board: boardPayload });
+ }
+ return axios.post(this.generateBoardsPath(), { board: boardPayload });
+ },
+
+ deleteBoard({ id }) {
+ return axios.delete(this.generateBoardsPath(id));
+ },
+
+ setCurrentBoard(board) {
+ this.state.currentBoard = board;
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index 670e8e9eb60..7dbaf984acf 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,23 +1,51 @@
import Vue from 'vue';
+import { __ } from '../locale';
+import createFlash from '../flash';
+import axios from '../lib/utils/axios_utils';
import DivergenceGraph from './components/divergence_graph.vue';
-export default () => {
- document.querySelectorAll('.js-branch-divergence-graph').forEach(el => {
- const { distance, aheadCount, behindCount, defaultBranch, maxCommits } = el.dataset;
-
- return new Vue({
- el,
- render(h) {
- return h(DivergenceGraph, {
- props: {
- defaultBranch,
- distance: distance ? parseInt(distance, 10) : null,
- aheadCount: parseInt(aheadCount, 10),
- behindCount: parseInt(behindCount, 10),
- maxCommits: parseInt(maxCommits, 10),
- },
- });
- },
- });
+export function createGraphVueApp(el, data, maxCommits) {
+ return new Vue({
+ el,
+ render(h) {
+ return h(DivergenceGraph, {
+ props: {
+ defaultBranch: 'master',
+ distance: data.distance ? parseInt(data.distance, 10) : null,
+ aheadCount: parseInt(data.ahead, 10),
+ behindCount: parseInt(data.behind, 10),
+ maxCommits,
+ },
+ });
+ },
});
+}
+
+export default endpoint => {
+ const names = [...document.querySelectorAll('.js-branch-item')].map(
+ ({ dataset }) => dataset.name,
+ );
+ return axios
+ .get(endpoint, {
+ params: { names },
+ })
+ .then(({ data }) => {
+ const maxCommits = Object.entries(data).reduce((acc, [, val]) => {
+ const max = Math.max(...Object.values(val));
+ return max > acc ? max : acc;
+ }, 100);
+
+ Object.entries(data).forEach(([branchName, val]) => {
+ const el = document.querySelector(
+ `[data-name="${branchName}"] .js-branch-divergence-graph`,
+ );
+
+ if (!el) return;
+
+ createGraphVueApp(el, val, maxCommits);
+ });
+ })
+ .catch(() =>
+ createFlash(__('Error fetching diverging counts for branches. Please try again.')),
+ );
};
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index aacfa0d87e6..5f5c8044b49 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -48,6 +48,9 @@ export default class Clusters {
} = document.querySelector('.js-edit-cluster-form').dataset;
this.clusterId = clusterId;
+ this.clusterNewlyCreatedKey = `cluster_${this.clusterId}_newly_created`;
+ this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`;
+
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setManagePrometheusPath(managePrometheusPath);
@@ -81,18 +84,19 @@ export default class Clusters {
this.showTokenButton = document.querySelector('.js-show-cluster-token');
this.tokenField = document.querySelector('.js-cluster-token');
this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text');
- this.ingressDomainSnippet = this.ingressDomainHelpText.querySelector(
- '.js-ingress-domain-snippet',
- );
+ this.ingressDomainSnippet =
+ this.ingressDomainHelpText &&
+ this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet');
Clusters.initDismissableCallout();
initSettingsPanels();
- setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
+ const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area');
+ if (toggleButtonsContainer) {
+ setupToggleButtons(toggleButtonsContainer);
+ }
this.initApplications(clusterType);
- if (this.store.state.status !== 'created') {
- this.updateContainer(null, this.store.state.status, this.store.state.statusReason);
- }
+ this.updateContainer(null, this.store.state.status, this.store.state.statusReason);
this.addListeners();
if (statusPath) {
@@ -247,35 +251,56 @@ export default class Clusters {
setBannerDismissedState(status, isDismissed) {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- window.localStorage.setItem(
- `cluster_${this.clusterId}_banner_dismissed`,
- `${status}_${isDismissed}`,
- );
+ window.localStorage.setItem(this.clusterBannerDismissedKey, `${status}_${isDismissed}`);
}
}
isBannerDismissed(status) {
let bannerState;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`);
+ bannerState = window.localStorage.getItem(this.clusterBannerDismissedKey);
}
return bannerState === `${status}_true`;
}
- updateContainer(prevStatus, status, error) {
- this.hideAll();
+ setClusterNewlyCreated(state) {
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ window.localStorage.setItem(this.clusterNewlyCreatedKey, Boolean(state));
+ }
+ }
+
+ isClusterNewlyCreated() {
+ // once this is true, it will always be true for a given page load
+ if (!this.isNewlyCreated) {
+ let newlyCreated;
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ newlyCreated = window.localStorage.getItem(this.clusterNewlyCreatedKey);
+ }
+
+ this.isNewlyCreated = newlyCreated === 'true';
+ }
+ return this.isNewlyCreated;
+ }
- if (this.isBannerDismissed(status)) {
+ updateContainer(prevStatus, status, error) {
+ if (status !== 'created' && this.isBannerDismissed(status)) {
return;
}
this.setBannerDismissedState(status, false);
- // We poll all the time but only want the `created` banner to show when newly created
- if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) {
+ if (prevStatus !== status) {
+ this.hideAll();
+
switch (status) {
case 'created':
- this.successContainer.classList.remove('hidden');
+ if (this.isClusterNewlyCreated()) {
+ this.setClusterNewlyCreated(false);
+ this.successContainer.classList.remove('hidden');
+ } else if (prevStatus) {
+ this.setClusterNewlyCreated(true);
+ window.location.reload();
+ }
break;
case 'errored':
this.errorContainer.classList.remove('hidden');
@@ -292,7 +317,6 @@ export default class Clusters {
this.creatingContainer.classList.remove('hidden');
break;
default:
- this.hideAll();
}
}
}
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 4771090aa7e..64364092016 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,5 +1,6 @@
<script>
/* eslint-disable vue/require-default-prop */
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlLink, GlModalDirective } from '@gitlab/ui';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import { s__, __, sprintf } from '~/locale';
@@ -207,7 +208,7 @@ export default {
return __('Updating');
}
- return __('Updated');
+ return this.updateSuccessful ? __('Updated to') : __('Updated');
},
updateFailureDescription() {
return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
@@ -331,8 +332,6 @@ export default {
class="form-text text-muted label p-0 js-cluster-application-update-details"
>
{{ versionLabel }}
- <span v-if="updateSuccessful">to</span>
-
<gl-link
v-if="updateSuccessful"
:href="chartRepo"
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 480228619a5..e26ef135bc5 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -2,7 +2,7 @@
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import { GlLoadingIcon } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import { APPLICATION_STATUS } from '~/clusters/constants';
@@ -32,7 +32,7 @@ export default {
return [UPDATING].includes(this.knative.status);
},
saveButtonLabel() {
- return this.saving ? this.__('Saving') : this.__('Save changes');
+ return this.saving ? __('Saving') : __('Save changes');
},
knativeInstalled() {
return this.knative.installed;
@@ -122,9 +122,9 @@ export default {
`ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
</p>
<p
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
index ef4bcbe14dd..8465312d84d 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
@@ -1,6 +1,7 @@
<script>
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
+import { __ } from '~/locale';
const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
@@ -22,7 +23,7 @@ export default {
return this.status === UNINSTALLING;
},
label() {
- return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
+ return this.loading ? __('Uninstalling') : __('Uninstall');
},
},
};
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 65827f1cb6a..4f60e543666 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -2,19 +2,26 @@
import { GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
-import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
+import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
+ [HELM]: s__(
+ 'ClusterIntegration|The associated Tiller pod will be deleted and cannot be restored.',
+ ),
[INGRESS]: s__(
'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
),
[CERT_MANAGER]: s__(
- 'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.',
+ 'ClusterIntegration|The associated private key will be deleted and cannot be restored.',
),
[PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
[RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
- [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'),
- [JUPYTER]: '',
+ [KNATIVE]: s__(
+ 'ClusterIntegration|The associated IP and all deployed services will be deleted and cannot be restored. Uninstalling Knative will also remove Istio from your cluster. This will not effect any other applications.',
+ ),
+ [JUPYTER]: s__(
+ 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.',
+ ),
};
export default {
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index f64f0ca616f..ada5a49e246 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -171,6 +171,7 @@ export default class ClusterStore {
this.state.applications.cert_manager.email || serverAppEntry.email;
} else if (appId === JUPYTER) {
this.state.applications.jupyter.hostname =
+ this.state.applications.jupyter.hostname ||
serverAppEntry.hostname ||
(this.state.applications.ingress.externalIp
? `jupyter.${this.state.applications.ingress.externalIp}.nip.io`
diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js
index 54e2589c707..7dd75d03ab9 100644
--- a/app/assets/javascripts/commits.js
+++ b/app/assets/javascripts/commits.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { pluralize } from './lib/utils/text_utility';
+import { n__ } from '~/locale';
import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
import axios from './lib/utils/axios_utils';
@@ -90,9 +90,10 @@ export default class CommitsList {
.first()
.find('li.commit').length,
);
+
$commitsHeadersLast
.find('span.commits-count')
- .text(`${commitsCount} ${pluralize('commit', commitsCount)}`);
+ .text(n__('%d commit', '%d commits', commitsCount));
}
localTimeAgo($processedData.find('.js-timeago'));
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index 0d2fe2925d8..ad0f6cc1496 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -4,3 +4,6 @@ import './jquery';
import './bootstrap';
import './vue';
import '../lib/utils/axios_utils';
+import { openUserCountsBroadcast } from './nav/user_merge_requests';
+
+openUserCountsBroadcast();
diff --git a/app/assets/javascripts/commons/nav/user_merge_requests.js b/app/assets/javascripts/commons/nav/user_merge_requests.js
new file mode 100644
index 00000000000..8e694cca6a1
--- /dev/null
+++ b/app/assets/javascripts/commons/nav/user_merge_requests.js
@@ -0,0 +1,67 @@
+import Api from '~/api';
+
+let channel;
+
+function broadcastCount(newCount) {
+ if (!channel) {
+ return;
+ }
+
+ channel.postMessage(newCount);
+}
+
+function updateUserMergeRequestCounts(newCount) {
+ const mergeRequestsCountEl = document.querySelector('.merge-requests-count');
+ mergeRequestsCountEl.textContent = newCount.toLocaleString();
+ mergeRequestsCountEl.classList.toggle('hidden', Number(newCount) === 0);
+}
+
+/**
+ * Refresh user counts (and broadcast if open)
+ */
+export function refreshUserMergeRequestCounts() {
+ return Api.userCounts()
+ .then(({ data }) => {
+ const count = data.merge_requests;
+
+ updateUserMergeRequestCounts(count);
+ broadcastCount(count);
+ })
+ .catch(ex => {
+ console.error(ex); // eslint-disable-line no-console
+ });
+}
+
+/**
+ * Close the broadcast channel for user counts
+ */
+export function closeUserCountsBroadcast() {
+ if (!channel) {
+ return;
+ }
+
+ channel.close();
+ channel = null;
+}
+
+/**
+ * Open the broadcast channel for user counts, adds user id so we only update
+ *
+ * **Please note:**
+ * Not supported in all browsers, but not polyfilling for now
+ * to keep bundle size small and
+ * no special functionality lost except cross tab notifications
+ */
+export function openUserCountsBroadcast() {
+ closeUserCountsBroadcast();
+
+ if (window.BroadcastChannel) {
+ const currentUserId = typeof gon !== 'undefined' && gon && gon.current_user_id;
+ if (currentUserId) {
+ channel = new BroadcastChannel(`mr_count_channel_${currentUserId}`);
+ channel.onmessage = ev => {
+ updateUserMergeRequestCounts(ev.data);
+ };
+ }
+ }
+}
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index a4394ab7e92..7a6ad3dc771 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -13,6 +13,7 @@ import 'core-js/es/string/code-point-at';
import 'core-js/es/string/from-code-point';
import 'core-js/es/string/includes';
import 'core-js/es/string/starts-with';
+import 'core-js/es/string/ends-with';
import 'core-js/es/symbol';
import 'core-js/es/map';
import 'core-js/es/weak-map';
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
new file mode 100644
index 00000000000..444640980af
--- /dev/null
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ Icon,
+ },
+ props: {
+ projects: {
+ type: Array,
+ required: true,
+ },
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ dropdownText() {
+ if (Object.keys(this.selectedProject).length) {
+ return this.selectedProject.name;
+ }
+
+ return __('Select private project');
+ },
+ },
+ methods: {
+ selectProject(project) {
+ this.$emit('click', project);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100">
+ <template slot="button-content">
+ <span class="str-truncated-100 mr-2">
+ <icon name="lock" />
+ {{ dropdownText }}
+ </span>
+ <icon name="chevron-down" class="ml-auto" />
+ </template>
+ <gl-dropdown-item v-for="project in projects" :key="project.id" @click="selectProject(project)">
+ <icon
+ name="mobile-issue-close"
+ :class="{ icon: project.id !== selectedProject.id }"
+ class="js-active-project-check"
+ />
+ <span class="ml-1">{{ project.name }}</span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
new file mode 100644
index 00000000000..197a0706062
--- /dev/null
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -0,0 +1,140 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import { __, sprintf } from '../../locale';
+import createFlash from '../../flash';
+import Api from '../../api';
+import state from '../state';
+import Dropdown from './dropdown.vue';
+
+export default {
+ components: {
+ GlLink,
+ Dropdown,
+ },
+ props: {
+ namespacePath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ newForkPath: {
+ type: String,
+ required: true,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projects: [],
+ };
+ },
+ computed: {
+ selectedProject() {
+ return state.selectedProject;
+ },
+ noForkText() {
+ return sprintf(
+ __(
+ "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.",
+ ),
+ { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' },
+ false,
+ );
+ },
+ },
+ mounted() {
+ this.fetchProjects();
+ this.createBtn = document.querySelector('.js-create-target');
+ this.warningText = document.querySelector('.js-exposed-info-warning');
+ },
+ methods: {
+ selectProject(project) {
+ if (project) {
+ Object.assign(state, {
+ selectedProject: project,
+ });
+
+ if (project.namespaceFullPath !== this.namespacePath) {
+ this.showWarning();
+ }
+ } else if (this.createBtn) {
+ this.createBtn.setAttribute('disabled', 'disabled');
+ }
+ },
+ normalizeProjectData(data) {
+ return data.map(p => ({
+ id: p.id,
+ name: p.name_with_namespace,
+ pathWithNamespace: p.path_with_namespace,
+ namespaceFullpath: p.namespace.full_path,
+ }));
+ },
+ fetchProjects() {
+ Api.projectForks(this.projectPath, {
+ with_merge_requests_enabled: true,
+ min_access_level: 30,
+ visibility: 'private',
+ })
+ .then(({ data }) => {
+ this.projects = this.normalizeProjectData(data);
+ this.selectProject(this.projects[0]);
+ })
+ .catch(e => {
+ createFlash(__('Error fetching forked projects. Please try again.'));
+ throw e;
+ });
+ },
+ showWarning() {
+ if (this.warningText) {
+ this.warningText.classList.remove('hidden');
+ }
+
+ if (this.createBtn) {
+ this.createBtn.classList.add('btn-warning');
+ this.createBtn.classList.remove('btn-success');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="confidential-merge-request-fork-group form-group">
+ <label>{{ __('Project') }}</label>
+ <div>
+ <dropdown
+ v-if="projects.length"
+ :projects="projects"
+ :selected-project="selectedProject"
+ @click="selectProject"
+ />
+ <p class="text-muted mt-1 mb-0">
+ <template v-if="projects.length">
+ {{
+ __(
+ "To protect this issue's confidentiality, a private fork of this project was selected.",
+ )
+ }}
+ </template>
+ <template v-else>
+ {{ __('No forks available to you.') }}<br />
+ <span v-html="noForkText"></span>
+ </template>
+ <gl-link
+ :href="helpPagePath"
+ class="w-auto p-0 d-inline-block text-primary bg-transparent"
+ target="_blank"
+ >
+ <span class="sr-only">{{ __('Read more') }}</span>
+ <i class="fa fa-question-circle" aria-hidden="true"></i>
+ </gl-link>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/confidential_merge_request/index.js b/app/assets/javascripts/confidential_merge_request/index.js
new file mode 100644
index 00000000000..9672821d30e
--- /dev/null
+++ b/app/assets/javascripts/confidential_merge_request/index.js
@@ -0,0 +1,30 @@
+import Vue from 'vue';
+import { parseBoolean } from '../lib/utils/common_utils';
+import ProjectFormGroup from './components/project_form_group.vue';
+import state from './state';
+
+export function isConfidentialIssue() {
+ return parseBoolean(document.querySelector('.js-create-mr').dataset.isConfidential);
+}
+
+export function canCreateConfidentialMergeRequest() {
+ return isConfidentialIssue() && Object.keys(state.selectedProject).length > 0;
+}
+
+export function init() {
+ const el = document.getElementById('js-forked-project');
+
+ return new Vue({
+ el,
+ render(h) {
+ return h(ProjectFormGroup, {
+ props: {
+ namespacePath: el.dataset.namespacePath,
+ projectPath: el.dataset.projectPath,
+ newForkPath: el.dataset.newForkPath,
+ helpPagePath: el.dataset.helpPagePath,
+ },
+ });
+ },
+ });
+}
diff --git a/app/assets/javascripts/confidential_merge_request/state.js b/app/assets/javascripts/confidential_merge_request/state.js
new file mode 100644
index 00000000000..95b0580f4b9
--- /dev/null
+++ b/app/assets/javascripts/confidential_merge_request/state.js
@@ -0,0 +1,5 @@
+import Vue from 'vue';
+
+export default Vue.observable({
+ selectedProject: {},
+});
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js
index 5a3407693e5..5a3407693e5 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_dropdown_mixin.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
index 83811ab489a..83811ab489a 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_machine_type_dropdown.vue
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index a2eb79af4f9..a2eb79af4f9 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
index fd5d5f86401..fd5d5f86401 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_zone_dropdown.vue
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js b/app/assets/javascripts/create_cluster/gke_cluster/constants.js
index 2a1c0819916..2a1c0819916 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/constants.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js
index 729b9404b64..729b9404b64 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js
index f05ad7773a2..f05ad7773a2 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js
index f9e2e2f74fb..f9e2e2f74fb 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/getters.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js b/app/assets/javascripts/create_cluster/gke_cluster/store/index.js
index 5f72060633e..5f72060633e 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/index.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js
index 45a91efc2d9..45a91efc2d9 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/mutation_types.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js b/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js
index 88a2c1b630d..88a2c1b630d 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/mutations.js
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js b/app/assets/javascripts/create_cluster/gke_cluster/store/state.js
index 9f3c473d4bc..9f3c473d4bc 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster/store/state.js
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 8f5cece0788..dce9c1a5410 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -5,6 +5,12 @@ import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
+import {
+ init as initConfidentialMergeRequest,
+ isConfidentialIssue,
+ canCreateConfidentialMergeRequest,
+} from './confidential_merge_request';
+import confidentialMergeRequestState from './confidential_merge_request/state';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
@@ -12,6 +18,17 @@ const InputSetter = Object.assign({}, ISetter);
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
+function createEndpoint(projectPath, endpoint) {
+ if (canCreateConfidentialMergeRequest()) {
+ return endpoint.replace(
+ projectPath,
+ confidentialMergeRequestState.selectedProject.pathWithNamespace,
+ );
+ }
+
+ return endpoint;
+}
+
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
@@ -42,6 +59,8 @@ export default class CreateMergeRequestDropdown {
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
+ this.projectPath = this.wrapperEl.dataset.projectPath;
+ this.projectId = this.wrapperEl.dataset.projectId;
// These regexps are used to replace
// a backend generated new branch name and its source (ref)
@@ -58,6 +77,14 @@ export default class CreateMergeRequestDropdown {
};
this.init();
+
+ if (isConfidentialIssue()) {
+ this.createMergeRequestButton.setAttribute(
+ 'data-dropdown-trigger',
+ '#create-merge-request-dropdown',
+ );
+ initConfidentialMergeRequest();
+ }
}
available() {
@@ -113,7 +140,9 @@ export default class CreateMergeRequestDropdown {
this.isCreatingBranch = true;
return axios
- .post(this.createBranchPath)
+ .post(createEndpoint(this.projectPath, this.createBranchPath), {
+ confidential_issue_project_id: canCreateConfidentialMergeRequest() ? this.projectId : null,
+ })
.then(({ data }) => {
this.branchCreated = true;
window.location.href = data.url;
@@ -125,7 +154,11 @@ export default class CreateMergeRequestDropdown {
this.isCreatingMergeRequest = true;
return axios
- .post(this.createMrPath)
+ .post(this.createMrPath, {
+ target_project_id: canCreateConfidentialMergeRequest()
+ ? confidentialMergeRequestState.selectedProject.id
+ : null,
+ })
.then(({ data }) => {
this.mergeRequestCreated = true;
window.location.href = data.url;
@@ -149,6 +182,8 @@ export default class CreateMergeRequestDropdown {
}
enable() {
+ if (isConfidentialIssue() && !canCreateConfidentialMergeRequest()) return;
+
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
@@ -205,7 +240,7 @@ export default class CreateMergeRequestDropdown {
if (!ref) return false;
return axios
- .get(`${this.refsPath}${encodeURIComponent(ref)}`)
+ .get(`${createEndpoint(this.projectPath, this.refsPath)}${encodeURIComponent(ref)}`)
.then(({ data }) => {
const branches = data[Object.keys(data)[0]];
const tags = data[Object.keys(data)[1]];
@@ -325,6 +360,12 @@ export default class CreateMergeRequestDropdown {
let xhr = null;
event.preventDefault();
+ if (isConfidentialIssue() && !event.target.classList.contains('js-create-target')) {
+ this.droplab.hooks.forEach(hook => hook.list.toggle());
+
+ return;
+ }
+
if (this.isBusy()) {
return;
}
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
new file mode 100644
index 00000000000..d946594a069
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
@@ -0,0 +1,41 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'StageCardListItem',
+ components: {
+ Icon,
+ GlButton,
+ },
+ props: {
+ isActive: {
+ type: Boolean,
+ required: true,
+ },
+ canEdit: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="{ active: isActive }" class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded">
+ <slot></slot>
+ <div v-if="canEdit" class="dropdown">
+ <gl-button
+ :title="__('More actions')"
+ class="more-actions-toggle btn btn-transparent p-0"
+ data-toggle="dropdown"
+ >
+ <icon css-classes="icon" name="ellipsis_v" />
+ </gl-button>
+ <ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
+ <slot name="dropdown-options"></slot>
+ </ul>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
new file mode 100644
index 00000000000..004d335f572
--- /dev/null
+++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
@@ -0,0 +1,88 @@
+<script>
+import StageCardListItem from './stage_card_list_item.vue';
+
+export default {
+ name: 'StageNavItem',
+ components: {
+ StageCardListItem,
+ },
+ props: {
+ isDefaultStage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isActive: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isUserAllowed: {
+ type: Boolean,
+ required: true,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ canEdit: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ hasValue() {
+ return this.value && this.value.length > 0;
+ },
+ editable() {
+ return this.isUserAllowed && this.canEdit;
+ },
+ },
+};
+</script>
+
+<template>
+ <li @click="$emit('select')">
+ <stage-card-list-item :is-active="isActive" :can-edit="editable">
+ <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }">
+ {{ title }}
+ </div>
+ <div class="stage-nav-item-cell stage-median mr-4">
+ <template v-if="isUserAllowed">
+ <span v-if="hasValue">{{ value }}</span>
+ <span v-else class="stage-empty">{{ __('Not enough data') }}</span>
+ </template>
+ <template v-else>
+ <span class="not-available">{{ __('Not available') }}</span>
+ </template>
+ </div>
+ <template v-slot:dropdown-options>
+ <template v-if="isDefaultStage">
+ <li>
+ <button type="button" class="btn-default btn-transparent">
+ {{ __('Hide stage') }}
+ </button>
+ </li>
+ </template>
+ <template v-else>
+ <li>
+ <button type="button" class="btn-default btn-transparent">
+ {{ __('Edit stage') }}
+ </button>
+ </li>
+ <li>
+ <button type="button" class="btn-danger danger">
+ {{ __('Remove stage') }}
+ </button>
+ </li>
+ </template>
+ </template>
+ </stage-card-list-item>
+ </li>
+</template>
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index b56e08175cc..b3ae47af750 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -1,7 +1,10 @@
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import { GlEmptyState } from '@gitlab/ui';
+import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins';
import Flash from '../flash';
+import { __ } from '~/locale';
import Translate from '../vue_shared/translate';
import banner from './components/banner.vue';
import stageCodeComponent from './components/stage_code_component.vue';
@@ -9,20 +12,22 @@ import stageComponent from './components/stage_component.vue';
import stageReviewComponent from './components/stage_review_component.vue';
import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_component.vue';
+import stageNavItem from './components/stage_nav_item.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
-import { __ } from '~/locale';
Vue.use(Translate);
export default () => {
const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed';
+ const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
// eslint-disable-next-line no-new
new Vue({
el: '#cycle-analytics',
name: 'CycleAnalytics',
components: {
+ GlEmptyState,
banner,
'stage-issue-component': stageComponent,
'stage-plan-component': stageComponent,
@@ -31,13 +36,16 @@ export default () => {
'stage-review-component': stageReviewComponent,
'stage-staging-component': stageStagingComponent,
'stage-production-component': stageComponent,
+ GroupsDropdownFilter: () =>
+ import('ee_component/analytics/shared/components/groups_dropdown_filter.vue'),
+ ProjectsDropdownFilter: () =>
+ import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'),
+ DateRangeDropdown: () =>
+ import('ee_component/analytics/shared/components/date_range_dropdown.vue'),
+ 'stage-nav-item': stageNavItem,
},
+ mixins: [filterMixins],
data() {
- const cycleAnalyticsEl = document.querySelector('#cycle-analytics');
- const cycleAnalyticsService = new CycleAnalyticsService({
- requestPath: cycleAnalyticsEl.dataset.requestPath,
- });
-
return {
store: CycleAnalyticsStore,
state: CycleAnalyticsStore.state,
@@ -47,7 +55,7 @@ export default () => {
hasError: false,
startDate: 30,
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
- service: cycleAnalyticsService,
+ service: this.createCycleAnalyticsService(cycleAnalyticsEl.dataset.requestPath),
};
},
computed: {
@@ -56,7 +64,13 @@ export default () => {
},
},
created() {
- this.fetchCycleAnalyticsData();
+ // Conditional check placed here to prevent this method from being called on the
+ // new Cycle Analytics page (i.e. the new page will be initialized blank and only
+ // after a group is selected the cycle analyitcs data will be fetched). Once the
+ // old (current) page has been removed this entire created method as well as the
+ // variable itself can be completely removed.
+ // Follow up issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/64490
+ if (cycleAnalyticsEl.dataset.requestPath) this.fetchCycleAnalyticsData();
},
methods: {
handleError() {
@@ -118,6 +132,7 @@ export default () => {
.fetchStageData({
stage,
startDate: this.startDate,
+ projectIds: this.selectedProjectIds,
})
.then(response => {
this.isEmptyStage = !response.events.length;
@@ -133,6 +148,11 @@ export default () => {
this.isOverviewDialogDismissed = true;
Cookies.set(OVERVIEW_DIALOG_COOKIE, '1', { expires: 365 });
},
+ createCycleAnalyticsService(requestPath) {
+ return new CycleAnalyticsService({
+ requestPath,
+ });
+ },
},
});
};
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
index a0426301a0a..babbfe93082 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js
@@ -8,22 +8,26 @@ export default class CycleAnalyticsService {
}
fetchCycleAnalyticsData(options = { startDate: 30 }) {
+ const { startDate, projectIds } = options;
+
return this.axios
.get('', {
params: {
- 'cycle_analytics[start_date]': options.startDate,
+ 'cycle_analytics[start_date]': startDate,
+ 'cycle_analytics[project_ids]': projectIds,
},
})
.then(x => x.data);
}
fetchStageData(options) {
- const { stage, startDate } = options;
+ const { stage, startDate, projectIds } = options;
return this.axios
.get(`events/${stage.name}.json`, {
params: {
'cycle_analytics[start_date]': startDate,
+ 'cycle_analytics[project_ids]': projectIds,
},
})
.then(x => x.data);
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index f66e07ba31a..7817b41514d 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -32,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({
buttonText: function() {
if (this.isDiscussionResolved) {
if (this.textareaIsEmpty) {
- return __('Unresolve discussion');
+ return __('Unresolve thread');
} else {
- return __('Comment & unresolve discussion');
+ return __('Comment & unresolve thread');
}
} else {
if (this.textareaIsEmpty) {
- return __('Resolve discussion');
+ return __('Resolve thread');
} else {
- return __('Comment & resolve discussion');
+ return __('Comment & resolve thread');
}
}
},
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 81da0754752..19b85710710 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -305,7 +305,7 @@ export default {
<div
v-show="showTreeList"
:style="{ width: `${treeWidth}px` }"
- class="diff-tree-list js-diff-tree-list"
+ class="diff-tree-list js-diff-tree-list mr-3"
>
<panel-resizer
:size.sync="treeWidth"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 58d5b658b17..c82b4a7abc6 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -49,8 +49,8 @@ export default {
return this.author.id ? this.author.id : '';
},
authorUrl() {
- // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings
// name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return this.author.web_url || `mailto:${this.commit.author_email}`;
},
authorAvatar() {
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 363ebad1594..2e57a47f2f7 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
new file mode 100644
index 00000000000..531ebaddacd
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -0,0 +1,54 @@
+<script>
+import { mapGetters } from 'vuex';
+import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ name: 'DiffDiscussionReply',
+ components: {
+ NoteSignedOutWidget,
+ ReplyPlaceholder,
+ UserAvatarLink,
+ },
+ props: {
+ hasForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ renderReplyPlaceholder: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters({
+ currentUser: 'getUserData',
+ userCanReply: 'userCanReply',
+ }),
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-reply-holder d-flex clearfix">
+ <template v-if="userCanReply">
+ <slot v-if="hasForm" name="form"></slot>
+ <template v-else-if="renderReplyPlaceholder">
+ <user-avatar-link
+ :link-href="currentUser.path"
+ :img-src="currentUser.avatar_url"
+ :img-alt="currentUser.name"
+ :img-size="40"
+ class="d-none d-sm-block"
+ />
+ <reply-placeholder
+ :button-text="__('Start a new discussion...')"
+ @onClick="$emit('showNewDiscussionForm')"
+ />
+ </template>
+ </template>
+ <note-signed-out-widget v-else />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 4c73eea4049..b0460bacff2 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -80,7 +80,6 @@ export default {
v-show="isExpanded(discussion)"
:discussion="discussion"
:render-diff-file="false"
- :always-expanded="true"
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
new file mode 100644
index 00000000000..839ab542377
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue
@@ -0,0 +1,244 @@
+<script>
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { mapState, mapActions } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import { UNFOLD_COUNT } from '../constants';
+import * as utils from '../store/utils';
+import tooltip from '../../vue_shared/directives/tooltip';
+
+const EXPAND_ALL = 0;
+const EXPAND_UP = 1;
+const EXPAND_DOWN = 2;
+
+export default {
+ directives: {
+ tooltip,
+ },
+ components: {
+ Icon,
+ },
+ props: {
+ fileHash: {
+ type: String,
+ required: true,
+ },
+ contextLinesPath: {
+ type: String,
+ required: true,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ isTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBottom: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ colspan: {
+ type: Number,
+ required: false,
+ default: 3,
+ },
+ },
+ computed: {
+ ...mapState({
+ diffViewType: state => state.diffs.diffViewType,
+ diffFiles: state => state.diffs.diffFiles,
+ }),
+ canExpandUp() {
+ return !this.isBottom;
+ },
+ canExpandDown() {
+ return this.isBottom || !this.isTop;
+ },
+ },
+ created() {
+ this.EXPAND_DOWN = EXPAND_DOWN;
+ this.EXPAND_UP = EXPAND_UP;
+ },
+ methods: {
+ ...mapActions('diffs', ['loadMoreLines']),
+ getPrevLineNumber(oldLineNumber, newLineNumber) {
+ const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash);
+ const indexForInline = utils.findIndexInInlineLines(diffFile.highlighted_diff_lines, {
+ oldLineNumber,
+ newLineNumber,
+ });
+ const prevLine = diffFile.highlighted_diff_lines[indexForInline - 2];
+ return (prevLine && prevLine.new_line) || 0;
+ },
+ callLoadMoreLines(
+ endpoint,
+ params,
+ lineNumbers,
+ fileHash,
+ isExpandDown = false,
+ nextLineNumbers = {},
+ ) {
+ this.loadMoreLines({ endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers })
+ .then(() => {
+ this.isRequesting = false;
+ })
+ .catch(() => {
+ createFlash(s__('Diffs|Something went wrong while fetching diff lines.'));
+ this.isRequesting = false;
+ });
+ },
+ handleExpandLines(type = EXPAND_ALL) {
+ if (this.isRequesting) {
+ return;
+ }
+
+ this.isRequesting = true;
+ const endpoint = this.contextLinesPath;
+ const { fileHash } = this;
+ const view = this.diffViewType;
+ const oldLineNumber = this.line.meta_data.old_pos || 0;
+ const newLineNumber = this.line.meta_data.new_pos || 0;
+ const offset = newLineNumber - oldLineNumber;
+
+ const expandOptions = { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset };
+
+ if (type === EXPAND_UP) {
+ this.handleExpandUpLines(expandOptions);
+ } else if (type === EXPAND_DOWN) {
+ this.handleExpandDownLines(expandOptions);
+ } else {
+ this.handleExpandAllLines(expandOptions);
+ }
+ },
+ handleExpandUpLines(expandOptions = EXPAND_ALL) {
+ const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions;
+
+ const bottom = this.isBottom;
+ const lineNumber = newLineNumber - 1;
+ const to = lineNumber;
+ let since = lineNumber - UNFOLD_COUNT;
+ let unfold = true;
+
+ const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber);
+ if (since <= prevLineNumber + 1) {
+ since = prevLineNumber + 1;
+ unfold = false;
+ }
+
+ const params = { since, to, bottom, offset, unfold, view };
+ const lineNumbers = { oldLineNumber, newLineNumber };
+ this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash);
+ },
+ handleExpandDownLines(expandOptions) {
+ const {
+ endpoint,
+ fileHash,
+ view,
+ oldLineNumber: metaOldPos,
+ newLineNumber: metaNewPos,
+ offset,
+ } = expandOptions;
+
+ const bottom = true;
+ const nextLineNumbers = {
+ old_line: metaOldPos,
+ new_line: metaNewPos,
+ };
+
+ let unfold = true;
+ let isExpandDown = false;
+ let oldLineNumber = metaOldPos;
+ let newLineNumber = metaNewPos;
+ let lineNumber = metaNewPos + 1;
+ let since = lineNumber;
+ let to = lineNumber + UNFOLD_COUNT;
+
+ if (!this.isBottom) {
+ const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber);
+
+ isExpandDown = true;
+ oldLineNumber = prevLineNumber - offset;
+ newLineNumber = prevLineNumber;
+ lineNumber = prevLineNumber + 1;
+ since = lineNumber;
+ to = lineNumber + UNFOLD_COUNT;
+
+ if (to >= metaNewPos) {
+ to = metaNewPos - 1;
+ unfold = false;
+ }
+ }
+
+ const params = { since, to, bottom, offset, unfold, view };
+ const lineNumbers = { oldLineNumber, newLineNumber };
+ this.callLoadMoreLines(
+ endpoint,
+ params,
+ lineNumbers,
+ fileHash,
+ isExpandDown,
+ nextLineNumbers,
+ );
+ },
+ handleExpandAllLines(expandOptions) {
+ const { endpoint, fileHash, view, oldLineNumber, newLineNumber, offset } = expandOptions;
+ const bottom = this.isBottom;
+ const unfold = false;
+ let since;
+ let to;
+
+ if (this.isTop) {
+ since = 1;
+ to = newLineNumber - 1;
+ } else if (bottom) {
+ since = newLineNumber + 1;
+ to = -1;
+ } else {
+ const prevLineNumber = this.getPrevLineNumber(oldLineNumber, newLineNumber);
+ since = prevLineNumber + 1;
+ to = newLineNumber - 1;
+ }
+
+ const params = { since, to, bottom, offset, unfold, view };
+ const lineNumbers = { oldLineNumber, newLineNumber };
+ this.callLoadMoreLines(endpoint, params, lineNumbers, fileHash);
+ },
+ },
+};
+</script>
+
+<template>
+ <td :colspan="colspan" class="text-center">
+ <div class="content js-line-expansion-content">
+ <a
+ v-if="canExpandUp"
+ v-tooltip
+ class="cursor-pointer js-unfold unfold-icon d-inline-block pt-2 pb-2"
+ data-placement="top"
+ data-container="body"
+ :title="__('Expand up')"
+ @click="handleExpandLines(EXPAND_UP)"
+ >
+ <icon :size="12" name="expand-up" aria-hidden="true" />
+ </a>
+ <a class="mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()">
+ <span>{{ s__('Diffs|Show all lines') }}</span>
+ </a>
+ <a
+ v-if="canExpandDown"
+ v-tooltip
+ class="cursor-pointer js-unfold-down has-tooltip unfold-icon d-inline-block pt-2 pb-2"
+ data-placement="top"
+ data-container="body"
+ :title="__('Expand down')"
+ @click="handleExpandLines(EXPAND_DOWN)"
+ >
+ <icon :size="12" name="expand-down" aria-hidden="true" />
+ </a>
+ </div>
+ </td>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 63350fafefa..2514274224d 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -67,6 +67,18 @@ export default {
errorMessage() {
return this.file.viewer.error_message;
},
+ forkMessage() {
+ return sprintf(
+ __(
+ "You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.",
+ ),
+ {
+ tag_start: '<span class="js-file-fork-suggestion-section-action">',
+ tag_end: '</span>',
+ },
+ false,
+ );
+ },
},
watch: {
isCollapsed: function fileCollapsedWatch(newVal, oldVal) {
@@ -150,12 +162,7 @@ export default {
/>
<div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion">
- <span class="file-fork-suggestion-note">
- {{ sprintf(__("You're not allowed to %{tag_start}edit%{tag_end} files in this project
- directly. Please fork this project, make your changes there, and submit a merge request."),
- { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>' })
- }}
- </span>
+ <span class="file-fork-suggestion-note" v-html="forkMessage"></span>
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index eb9f1465945..69ec6ab8600 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -130,7 +130,7 @@ export default {
return `\`${this.diffFile.file_path}\``;
},
isFileRenamed() {
- return this.diffFile.viewer.name === diffViewerModes.renamed;
+ return this.diffFile.renamed_file;
},
isModeChanged() {
return this.diffFile.viewer.name === diffViewerModes.mode_changed;
@@ -151,7 +151,11 @@ export default {
stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
- ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']),
+ ...mapActions('diffs', [
+ 'toggleFileDiscussions',
+ 'toggleFileDiscussionWrappers',
+ 'toggleFullDiff',
+ ]),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
@@ -165,7 +169,7 @@ export default {
this.$emit('showForkMessage');
},
handleToggleDiscussions() {
- this.toggleFileDiscussions(this.diffFile);
+ this.toggleFileDiscussionWrappers(this.diffFile);
},
handleFileNameClick(e) {
const isLinkToOtherPage =
@@ -259,6 +263,7 @@ export default {
:disabled="!diffHasDiscussions(diffFile)"
:class="{ active: hasExpandedDiscussions }"
class="js-btn-vue-toggle-comments btn"
+ data-qa-selector="toggle_comments_button"
type="button"
@click="handleToggleDiscussions"
>
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index e28909b7be3..7ede7a4f430 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -1,7 +1,7 @@
<script>
-import { mapActions } from 'vuex';
+import { n__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import { pluralize, truncate } from '~/lib/utils/text_utility';
+import { truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { GlTooltipDirective } from '@gitlab/ui';
import { COUNT_OF_AVATARS_IN_GUTTER, LENGTH_OF_AVATAR_TOOLTIP } from '../constants';
@@ -19,11 +19,13 @@ export default {
type: Array,
required: true,
},
+ discussionsExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- discussionsExpanded() {
- return this.discussions.every(discussion => discussion.expanded);
- },
allDiscussions() {
return this.discussions.reduce((acc, note) => acc.concat(note.notes), []);
},
@@ -41,30 +43,18 @@ export default {
return '';
}
- return pluralize(`${this.moreCount} more comment`, this.moreCount);
+ return n__('%d more comment', '%d more comments', this.moreCount);
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
getTooltipText(noteData) {
let { note } = noteData;
-
if (note.length > LENGTH_OF_AVATAR_TOOLTIP) {
note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP);
}
return `${noteData.author.name}: ${note}`;
},
- toggleDiscussions() {
- const forceExpanded = this.discussions.some(discussion => !discussion.expanded);
-
- this.discussions.forEach(discussion => {
- this.toggleDiscussion({
- discussionId: discussion.id,
- forceExpanded,
- });
- });
- },
},
};
</script>
@@ -76,7 +66,7 @@ export default {
type="button"
:aria-label="__('Show comments')"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>
<icon :size="12" name="collapse" />
</button>
@@ -87,7 +77,7 @@ export default {
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
class="diff-comment-avatar js-diff-comment-avatar"
- @click.native="toggleDiscussions"
+ @click.native="$emit('toggleLineDiscussions')"
/>
<span
v-if="moreText"
@@ -97,7 +87,7 @@ export default {
data-container="body"
data-placement="top"
role="button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>+{{ moreCount }}</span
>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index 1281f9b17ef..434d554d148 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -1,11 +1,8 @@
<script>
-import createFlash from '~/flash';
-import { s__ } from '~/locale';
import { mapState, mapGetters, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
-import { LINE_POSITION_RIGHT, UNFOLD_COUNT } from '../constants';
-import * as utils from '../store/utils';
+import { LINE_POSITION_RIGHT } from '../constants';
export default {
components: {
@@ -105,86 +102,46 @@ export default {
},
},
methods: {
- ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
+ ...mapActions('diffs', [
+ 'loadMoreLines',
+ 'showCommentForm',
+ 'setHighlightedRow',
+ 'toggleLineDiscussions',
+ 'toggleLineDiscussionWrappers',
+ ]),
handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
},
- handleLoadMoreLines() {
- if (this.isRequesting) {
- return;
- }
-
- this.isRequesting = true;
- const endpoint = this.contextLinesPath;
- const oldLineNumber = this.line.meta_data.old_pos || 0;
- const newLineNumber = this.line.meta_data.new_pos || 0;
- const offset = newLineNumber - oldLineNumber;
- const bottom = this.isBottom;
- const { fileHash } = this;
- const view = this.diffViewType;
- let unfold = true;
- let lineNumber = newLineNumber - 1;
- let since = lineNumber - UNFOLD_COUNT;
- let to = lineNumber;
-
- if (bottom) {
- lineNumber = newLineNumber + 1;
- since = lineNumber;
- to = lineNumber + UNFOLD_COUNT;
- } else {
- const diffFile = utils.findDiffFile(this.diffFiles, this.fileHash);
- const indexForInline = utils.findIndexInInlineLines(diffFile.highlighted_diff_lines, {
- oldLineNumber,
- newLineNumber,
- });
- const prevLine = diffFile.highlighted_diff_lines[indexForInline - 2];
- const prevLineNumber = (prevLine && prevLine.new_line) || 0;
-
- if (since <= prevLineNumber + 1) {
- since = prevLineNumber + 1;
- unfold = false;
- }
- }
-
- const params = { since, to, bottom, offset, unfold, view };
- const lineNumbers = { oldLineNumber, newLineNumber };
- this.loadMoreLines({ endpoint, params, lineNumbers, fileHash })
- .then(() => {
- this.isRequesting = false;
- })
- .catch(() => {
- createFlash(s__('Diffs|Something went wrong while fetching diff lines.'));
- this.isRequesting = false;
- });
- },
},
};
</script>
<template>
<div>
- <span v-if="isMatchLine" class="context-cell" role="button" @click="handleLoadMoreLines"
- >...</span
+ <button
+ v-if="shouldRenderCommentButton"
+ v-show="shouldShowCommentButton"
+ type="button"
+ class="add-diff-note js-add-diff-note-button qa-diff-comment"
+ title="Add a comment to this line"
+ @click="handleCommentButton"
+ >
+ <icon :size="12" name="comment" />
+ </button>
+ <a
+ v-if="lineNumber"
+ :data-linenumber="lineNumber"
+ :href="lineHref"
+ @click="setHighlightedRow(lineCode)"
>
- <template v-else>
- <button
- v-if="shouldRenderCommentButton"
- v-show="shouldShowCommentButton"
- type="button"
- class="add-diff-note js-add-diff-note-button qa-diff-comment"
- title="Add a comment to this line"
- @click="handleCommentButton"
- >
- <icon :size="12" name="comment" />
- </button>
- <a
- v-if="lineNumber"
- :data-linenumber="lineNumber"
- :href="lineHref"
- @click="setHighlightedRow(lineCode)"
- >
- </a>
- <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
- </template>
+ </a>
+ <diff-gutter-avatars
+ v-if="shouldShowAvatarsOnGutter"
+ :discussions="line.discussions"
+ :discussions-expanded="line.discussionsExpanded"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ "
+ />
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
index 119e139de21..035c2b3b11e 100644
--- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue
+++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
export default {
props: {
total: {
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index 1faa0493e79..a06dbd70ac5 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -21,6 +24,11 @@ export default {
required: false,
default: '',
},
+ hasDraft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
className() {
@@ -32,10 +40,12 @@ export default {
if (!this.line.discussions || !this.line.discussions.length) {
return false;
}
-
- return this.line.discussions.every(discussion => discussion.expanded);
+ return this.line.discussionsExpanded;
},
},
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ },
};
</script>
@@ -49,13 +59,23 @@ export default {
:discussions="line.discussions"
:help-page-path="helpPagePath"
/>
- <diff-line-note-form
- v-if="line.hasForm"
- :diff-file-hash="diffFileHash"
- :line="line"
- :note-target-line="line"
- :help-page-path="helpPagePath"
- />
+ <diff-discussion-reply
+ v-if="!hasDraft"
+ :has-form="line.hasForm"
+ :render-reply-placeholder="Boolean(line.discussions.length)"
+ @showNewDiscussionForm="
+ showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })
+ "
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line"
+ :note-target-line="line"
+ :help-page-path="helpPagePath"
+ />
+ </template>
+ </diff-discussion-reply>
</div>
</td>
</tr>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
new file mode 100644
index 00000000000..6e732727f42
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
@@ -0,0 +1,53 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import DiffExpansionCell from './diff_expansion_cell.vue';
+import { MATCH_LINE_TYPE } from '../constants';
+
+export default {
+ components: {
+ Icon,
+ DiffExpansionCell,
+ },
+ props: {
+ fileHash: {
+ type: String,
+ required: true,
+ },
+ contextLinesPath: {
+ type: String,
+ required: true,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ isTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBottom: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isMatchLine() {
+ return this.line.type === MATCH_LINE_TYPE;
+ },
+ },
+};
+</script>
+
+<template>
+ <tr v-if="isMatchLine" class="line_expansion match">
+ <diff-expansion-cell
+ :file-hash="fileHash"
+ :context-lines-path="contextLinesPath"
+ :line="line"
+ :is-top="isTop"
+ :is-bottom="isBottom"
+ />
+ </tr>
+</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 2d5262baeec..55a8df43c62 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -2,6 +2,7 @@
import { mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
+ MATCH_LINE_TYPE,
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
@@ -58,6 +59,9 @@ export default {
inlineRowId() {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
},
+ isMatchLine() {
+ return this.line.type === MATCH_LINE_TYPE;
+ },
},
created() {
this.newLineType = NEW_LINE_TYPE;
@@ -81,6 +85,7 @@ export default {
<template>
<tr
+ v-if="!isMatchLine"
:id="inlineRowId"
:class="classNameMap"
class="line_holder"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 8c76a555b62..aee01409db7 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
+import inlineDiffExpansionRow from './inline_diff_expansion_row.vue';
export default {
components: {
@@ -10,6 +11,7 @@ export default {
inlineDiffTableRow,
InlineDraftCommentRow: () =>
import('ee_component/batch_comments/components/inline_draft_comment_row.vue'),
+ inlineDiffExpansionRow,
},
mixins: [draftCommentsMixin],
props: {
@@ -43,10 +45,24 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file js-diff-inline-view"
>
+ <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
+ <tr>
+ <td style="width: 50px;"></td>
+ <td style="width: 50px;"></td>
+ <td></td>
+ </tr>
<tbody>
<template v-for="(line, index) in diffLines">
+ <inline-diff-expansion-row
+ :key="`expand-${index}`"
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
<inline-diff-table-row
- :key="line.line_code"
+ :key="`${line.line_code || index}`"
:file-hash="diffFile.file_hash"
:context-lines-path="diffFile.context_lines_path"
:line="line"
@@ -57,6 +73,7 @@ export default {
:diff-file-hash="diffFile.file_hash"
:line="line"
:help-page-path="helpPagePath"
+ :has-draft="shouldRenderDraftRow(diffFile.file_hash, line) || false"
/>
<inline-draft-comment-row
v-if="shouldRenderDraftRow(diffFile.file_hash, line)"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index d2e54edca85..65b41b0e456 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -25,28 +28,44 @@ export default {
required: false,
default: '',
},
+ hasDraftLeft: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ hasDraftRight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
hasExpandedDiscussionOnLeft() {
return this.line.left && this.line.left.discussions.length
- ? this.line.left.discussions.every(discussion => discussion.expanded)
+ ? this.line.left.discussionsExpanded
: false;
},
hasExpandedDiscussionOnRight() {
return this.line.right && this.line.right.discussions.length
- ? this.line.right.discussions.every(discussion => discussion.expanded)
+ ? this.line.right.discussionsExpanded
: false;
},
hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsOnLeft() {
- return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft;
+ return (
+ this.line.left &&
+ this.line.left.discussions &&
+ this.line.left.discussions.length &&
+ this.hasExpandedDiscussionOnLeft
+ );
},
shouldRenderDiscussionsOnRight() {
return (
this.line.right &&
this.line.right.discussions &&
+ this.line.right.discussions.length &&
this.hasExpandedDiscussionOnRight &&
this.line.right.type
);
@@ -81,6 +100,22 @@ export default {
return hasCommentFormOnLeft || hasCommentFormOnRight;
},
+ shouldRenderReplyPlaceholderOnLeft() {
+ return Boolean(
+ this.line.left && this.line.left.discussions && this.line.left.discussions.length,
+ );
+ },
+ shouldRenderReplyPlaceholderOnRight() {
+ return Boolean(
+ this.line.right && this.line.right.discussions && this.line.right.discussions.length,
+ );
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ showNewDiscussionForm() {
+ this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
+ },
},
};
</script>
@@ -90,37 +125,51 @@ export default {
<td class="notes-content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
- v-if="line.left.discussions.length"
:discussions="line.left.discussions"
:line="line.left"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showLeftSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.left"
- :note-target-line="line.left"
- :help-page-path="helpPagePath"
- line-position="left"
- />
+ <diff-discussion-reply
+ v-if="!hasDraftLeft"
+ :has-form="showLeftSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.left"
+ :note-target-line="line.left"
+ :help-page-path="helpPagePath"
+ line-position="left"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
<td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
- v-if="line.right.discussions.length"
:discussions="line.right.discussions"
:line="line.right"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showRightSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.right"
- :note-target-line="line.right"
- line-position="right"
- />
+ <diff-discussion-reply
+ v-if="!hasDraftRight"
+ :has-form="showRightSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.right"
+ :note-target-line="line.right"
+ line-position="right"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue
new file mode 100644
index 00000000000..c1b30eab199
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/parallel_diff_expansion_row.vue
@@ -0,0 +1,56 @@
+<script>
+import { MATCH_LINE_TYPE } from '../constants';
+import DiffExpansionCell from './diff_expansion_cell.vue';
+
+export default {
+ components: {
+ DiffExpansionCell,
+ },
+ props: {
+ fileHash: {
+ type: String,
+ required: true,
+ },
+ contextLinesPath: {
+ type: String,
+ required: true,
+ },
+ line: {
+ type: Object,
+ required: true,
+ },
+ isTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isBottom: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isMatchLineLeft() {
+ return this.line.left && this.line.left.type === MATCH_LINE_TYPE;
+ },
+ isMatchLineRight() {
+ return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
+ },
+ },
+};
+</script>
+<template>
+ <tr class="line_expansion match">
+ <template v-if="isMatchLineLeft || isMatchLineRight">
+ <diff-expansion-cell
+ :file-hash="fileHash"
+ :context-lines-path="contextLinesPath"
+ :line="line.left"
+ :is-top="isTop"
+ :is-bottom="isBottom"
+ :colspan="4"
+ />
+ </template>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index c60246bf8ef..4c95d618b0f 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -3,6 +3,7 @@ import { mapActions, mapState } from 'vuex';
import $ from 'jquery';
import DiffTableCell from './diff_table_cell.vue';
import {
+ MATCH_LINE_TYPE,
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
@@ -75,6 +76,12 @@ export default {
},
];
},
+ isMatchLineLeft() {
+ return this.line.left && this.line.left.type === MATCH_LINE_TYPE;
+ },
+ isMatchLineRight() {
+ return this.line.right && this.line.right.type === MATCH_LINE_TYPE;
+ },
},
created() {
this.newLineType = NEW_LINE_TYPE;
@@ -122,7 +129,7 @@ export default {
@mouseover="handleMouseMove"
@mouseout="handleMouseMove"
>
- <template v-if="line.left">
+ <template v-if="line.left && !isMatchLineLeft">
<diff-table-cell
:file-hash="fileHash"
:context-lines-path="contextLinesPath"
@@ -148,7 +155,7 @@ export default {
<td class="diff-line-num old_line empty-cell"></td>
<td class="line_content parallel left-side empty-cell"></td>
</template>
- <template v-if="line.right">
+ <template v-if="line.right && !isMatchLineRight">
<diff-table-cell
:file-hash="fileHash"
:context-lines-path="contextLinesPath"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index 41a80d99850..d400eb2c586 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -3,9 +3,11 @@ import { mapGetters } from 'vuex';
import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
+import parallelDiffExpansionRow from './parallel_diff_expansion_row.vue';
export default {
components: {
+ parallelDiffExpansionRow,
parallelDiffTableRow,
parallelDiffCommentRow,
ParallelDraftCommentRow: () =>
@@ -43,8 +45,23 @@ export default {
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
+ <!-- Need to insert an empty row to solve "table-layout:fixed" equal width when expansion row is the first line -->
+ <tr>
+ <td style="width: 50px;"></td>
+ <td></td>
+ <td style="width: 50px;"></td>
+ <td></td>
+ </tr>
<tbody>
<template v-for="(line, index) in diffLines">
+ <parallel-diff-expansion-row
+ :key="`expand-${index}`"
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line"
+ :is-top="index === 0"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
<parallel-diff-table-row
:key="line.line_code"
:file-hash="diffFile.file_hash"
@@ -58,6 +75,8 @@ export default {
:diff-file-hash="diffFile.file_hash"
:line-index="index"
:help-page-path="helpPagePath"
+ :has-draft-left="hasParallelDraftLeft(diffFile.file_hash, line) || false"
+ :has-draft-right="hasParallelDraftRight(diffFile.file_hash, line) || false"
/>
<parallel-draft-comment-row
v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
index dfb71bf38ce..b6c9b132aeb 100644
--- a/app/assets/javascripts/diffs/mixins/draft_comments.js
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -6,5 +6,7 @@ export default {
imageDiscussions() {
return this.diffFile.discussions;
},
+ hasParallelDraftLeft: () => () => false,
+ hasParallelDraftRight: () => () => false,
},
};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 88d7b4bba63..6695d9fe96c 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -12,6 +12,7 @@ import {
getNoteFormData,
convertExpandLines,
idleCallback,
+ allDiscussionWrappersExpanded,
} from './utils';
import * as types from './mutation_types';
import {
@@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = (
discussions = rootState.notes.discussions,
) => {
const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
+ const hash = getLocationHash();
discussions
.filter(discussion => discussion.diff_discussion)
@@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = (
commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, {
discussion,
diffPositionByLineCode,
+ hash,
});
});
@@ -99,10 +102,14 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const toggleLineDiscussions = ({ commit }, options) => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, options);
+};
+
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
- if (discussion) {
+ if (discussion && discussion.diff_file) {
const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
if (file) {
@@ -176,7 +183,7 @@ export const cancelCommentForm = ({ commit }, { lineCode, fileHash }) => {
};
export const loadMoreLines = ({ commit }, options) => {
- const { endpoint, params, lineNumbers, fileHash } = options;
+ const { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers } = options;
params.from_merge_request = true;
@@ -188,6 +195,8 @@ export const loadMoreLines = ({ commit }, options) => {
contextLines,
params,
fileHash,
+ isExpandDown,
+ nextLineNumbers,
});
});
};
@@ -257,6 +266,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
});
};
+export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
+ const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
+ let linesWithDiscussions;
+ if (diff.highlighted_diff_lines) {
+ linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length);
+ }
+ if (diff.parallel_diff_lines) {
+ linesWithDiscussions = diff.parallel_diff_lines.filter(
+ line =>
+ (line.left && line.left.discussions.length) ||
+ (line.right && line.right.discussions.length),
+ );
+ }
+
+ if (linesWithDiscussions.length) {
+ linesWithDiscussions.forEach(line => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, {
+ fileHash: diff.file_hash,
+ lineCode: line.line_code,
+ expanded: !discussionWrappersExpanded,
+ });
+ });
+ }
+};
+
export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
@@ -267,7 +301,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
return dispatch('saveNote', postData, { root: true })
.then(result => dispatch('updateDiscussion', result.discussion, { root: true }))
.then(discussion => dispatch('assignDiscussionsToDiff', [discussion]))
- .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true }))
+ .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
.catch(() => createFlash(s__('MergeRequests|Saving the comment failed')));
};
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 8d6111da500..9db56331faa 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
+
+export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 00181a63c43..a6915a46c00 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -6,6 +6,7 @@ import {
addContextLines,
prepareDiffData,
isDiscussionApplicableToLine,
+ updateLineInFile,
} from './utils';
import * as types from './mutation_types';
@@ -70,18 +71,30 @@ export default {
},
[types.ADD_CONTEXT_LINES](state, options) {
- const { lineNumbers, contextLines, fileHash } = options;
+ const { lineNumbers, contextLines, fileHash, isExpandDown, nextLineNumbers } = options;
const { bottom } = options.params;
const diffFile = findDiffFile(state.diffFiles, fileHash);
removeMatchLine(diffFile, lineNumbers, bottom);
- const lines = addLineReferences(contextLines, lineNumbers, bottom).map(line => ({
- ...line,
- line_code: line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`,
- discussions: line.discussions || [],
- hasForm: false,
- }));
+ const lines = addLineReferences(
+ contextLines,
+ lineNumbers,
+ bottom,
+ isExpandDown,
+ nextLineNumbers,
+ ).map(line => {
+ const lineCode =
+ line.type === 'match'
+ ? `${fileHash}_${line.meta_data.old_pos}_${line.meta_data.new_pos}_match`
+ : line.line_code || `${fileHash}_${line.old_line}_${line.new_line}`;
+ return {
+ ...line,
+ line_code: lineCode,
+ discussions: line.discussions || [],
+ hasForm: false,
+ };
+ });
addContextLines({
inlineLines: diffFile.highlighted_diff_lines,
@@ -89,6 +102,7 @@ export default {
contextLines: lines,
bottom,
lineNumbers,
+ isExpandDown,
});
},
@@ -109,7 +123,7 @@ export default {
}));
},
- [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) {
+ [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
const discussionLineCode = discussion.line_code;
@@ -130,13 +144,27 @@ export default {
: [],
});
+ const setDiscussionsExpanded = line => {
+ const isLineNoteTargeted = line.discussions.some(
+ disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
+ );
+
+ return {
+ ...line,
+ discussionsExpanded:
+ line.discussions && line.discussions.length
+ ? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted
+ : false,
+ };
+ };
+
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
- lineCheck(line) ? mapDiscussions(line) : line,
+ setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
);
}
@@ -148,8 +176,10 @@ export default {
if (left || right) {
return {
...line,
- left: line.left ? mapDiscussions(line.left) : null,
- right: line.right ? mapDiscussions(line.right, () => !left) : null,
+ left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
+ right: line.right
+ ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
+ : null,
};
}
@@ -173,32 +203,11 @@ export default {
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
- if (selectedFile.parallel_diff_lines) {
- const targetLine = selectedFile.parallel_diff_lines.find(
- line =>
- (line.left && line.left.line_code === lineCode) ||
- (line.right && line.right.line_code === lineCode),
- );
- if (targetLine) {
- const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
-
- Object.assign(targetLine[side], {
- discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
-
- if (selectedFile.highlighted_diff_lines) {
- const targetInlineLine = selectedFile.highlighted_diff_lines.find(
- line => line.line_code === lineCode,
- );
-
- if (targetInlineLine) {
- Object.assign(targetInlineLine, {
- discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, {
+ discussions: line.discussions.filter(discussion => discussion.notes.length),
+ }),
+ );
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
@@ -207,6 +216,15 @@ export default {
}
}
},
+
+ [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
+ const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
+
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, { discussionsExpanded: expanded }),
+ );
+ },
+
[types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 71956255eef..d46bdea9b50 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -121,7 +121,7 @@ export function removeMatchLine(diffFile, lineNumbers, bottom) {
diffFile.parallel_diff_lines.splice(indexForParallel + factor, 1);
}
-export function addLineReferences(lines, lineNumbers, bottom) {
+export function addLineReferences(lines, lineNumbers, bottom, isExpandDown, nextLineNumbers) {
const { oldLineNumber, newLineNumber } = lineNumbers;
const lineCount = lines.length;
let matchLineIndex = -1;
@@ -135,15 +135,20 @@ export function addLineReferences(lines, lineNumbers, bottom) {
new_line: bottom ? newLineNumber + index + 1 : newLineNumber + index - lineCount,
});
}
-
return l;
});
if (matchLineIndex > -1) {
const line = linesWithNumbers[matchLineIndex];
- const targetLine = bottom
- ? linesWithNumbers[matchLineIndex - 1]
- : linesWithNumbers[matchLineIndex + 1];
+ let targetLine;
+
+ if (isExpandDown) {
+ targetLine = nextLineNumbers;
+ } else if (bottom) {
+ targetLine = linesWithNumbers[matchLineIndex - 1];
+ } else {
+ targetLine = linesWithNumbers[matchLineIndex + 1];
+ }
Object.assign(line, {
meta_data: {
@@ -152,26 +157,27 @@ export function addLineReferences(lines, lineNumbers, bottom) {
},
});
}
-
return linesWithNumbers;
}
export function addContextLines(options) {
- const { inlineLines, parallelLines, contextLines, lineNumbers } = options;
+ const { inlineLines, parallelLines, contextLines, lineNumbers, isExpandDown } = options;
const normalizedParallelLines = contextLines.map(line => ({
left: line,
right: line,
line_code: line.line_code,
}));
+ const factor = isExpandDown ? 1 : 0;
- if (options.bottom) {
+ if (!isExpandDown && options.bottom) {
inlineLines.push(...contextLines);
parallelLines.push(...normalizedParallelLines);
} else {
const inlineIndex = findIndexInInlineLines(inlineLines, lineNumbers);
const parallelIndex = findIndexInParallelLines(parallelLines, lineNumbers);
- inlineLines.splice(inlineIndex, 0, ...contextLines);
- parallelLines.splice(parallelIndex, 0, ...normalizedParallelLines);
+
+ inlineLines.splice(inlineIndex + factor, 0, ...contextLines);
+ parallelLines.splice(parallelIndex + factor, 0, ...normalizedParallelLines);
}
}
@@ -454,3 +460,48 @@ export const convertExpandLines = ({
};
export const idleCallback = cb => requestIdleCallback(cb);
+
+export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
+ if (selectedFile.parallel_diff_lines) {
+ const targetLine = selectedFile.parallel_diff_lines.find(
+ line =>
+ (line.left && line.left.line_code === lineCode) ||
+ (line.right && line.right.line_code === lineCode),
+ );
+ if (targetLine) {
+ const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
+
+ updateFn(targetLine[side]);
+ }
+ }
+ if (selectedFile.highlighted_diff_lines) {
+ const targetInlineLine = selectedFile.highlighted_diff_lines.find(
+ line => line.line_code === lineCode,
+ );
+
+ if (targetInlineLine) {
+ updateFn(targetInlineLine);
+ }
+ }
+};
+
+export const allDiscussionWrappersExpanded = diff => {
+ const discussionsExpandedArray = [];
+ if (diff.parallel_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.left && line.left.discussions.length) {
+ discussionsExpandedArray.push(line.left.discussionsExpanded);
+ }
+ if (line.right && line.right.discussions.length) {
+ discussionsExpandedArray.push(line.right.discussionsExpanded);
+ }
+ });
+ } else if (diff.highlighted_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.discussions.length) {
+ discussionsExpandedArray.push(line.discussionsExpanded);
+ }
+ });
+ }
+ return discussionsExpandedArray.every(el => el);
+};
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 208bd19f6b0..21244c14977 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
@@ -28,7 +28,7 @@ export default {
},
computed: {
title() {
- return 'Deploy to...';
+ return __('Deploy to...');
},
},
methods: {
@@ -80,7 +80,8 @@ export default {
data-toggle="dropdown"
>
<span>
- <icon name="play" /> <icon name="chevron-down" />
+ <icon name="play" />
+ <icon name="chevron-down" />
<gl-loading-icon v-if="isLoading" />
</span>
</button>
@@ -94,9 +95,10 @@ export default {
class="js-manual-action-link no-btn btn d-flex align-items-center"
@click="onClickAction(action)"
>
- <span class="flex-fill"> {{ action.name }} </span>
+ <span class="flex-fill">{{ action.name }}</span>
<span v-if="action.scheduledAt" class="text-secondary">
- <icon name="clock" /> {{ remainingTime(action) }}
+ <icon name="clock" />
+ {{ remainingTime(action) }}
</span>
</button>
</li>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index f0e80cba753..95e1e8af9b3 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,4 +1,6 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
+import { __, sprintf } from '~/locale';
import Timeago from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
@@ -14,7 +16,6 @@ import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { CLUSTER_TYPE } from '~/clusters/constants';
/**
* Environment Item Component
@@ -80,15 +81,6 @@ export default {
},
/**
- * Hide group cluster features which are not currently implemented.
- *
- * @returns {Boolean}
- */
- disableGroupClusterFeatures() {
- return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP;
- },
-
- /**
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
@@ -172,7 +164,9 @@ export default {
this.model.last_deployment.user &&
this.model.last_deployment.user.username
) {
- return `${this.model.last_deployment.user.username}'s avatar'`;
+ return sprintf(__("%{username}'s avatar"), {
+ username: this.model.last_deployment.user.username,
+ });
}
return '';
},
@@ -293,6 +287,9 @@ export default {
* @returns {Boolean|Undefined}
*/
isLastDeployment() {
+ // name: 'last?' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // Vue i18n ESLint rules issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/63560
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return this.model && this.model.last_deployment && this.model.last_deployment['last?'];
},
@@ -575,7 +572,6 @@ export default {
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
- :disabled="disableGroupClusterFeatures"
/>
<rollback-component
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index ae4f07a71cd..886490847ea 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
@@ -21,7 +22,7 @@ export default {
},
computed: {
title() {
- return 'Monitoring';
+ return __('Monitoring');
},
},
};
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 13195d32cc4..37f94f9f5ab 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -5,6 +5,7 @@
*/
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -27,7 +28,7 @@ export default {
},
computed: {
title() {
- return 'Terminal';
+ return __('Terminal');
},
},
};
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index c78d86e9b97..2cc3412e075 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective } from '@gitlab/ui';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { s__, sprintf } from '~/locale';
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index 060d8e25227..a734e8527dd 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -36,12 +36,14 @@ export default {
<label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label>
<div class="row">
<div class="col-8 col-md-9 gl-pr-0">
+ <!-- eslint-disable @gitlab/vue-i18n/no-bare-attribute-strings -->
<gl-form-input
id="error-tracking-api-host"
:value="apiHost"
placeholder="https://mysentryserver.com"
@input="$emit('update-api-host', $event)"
/>
+ <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings -->
</div>
</div>
<p class="form-text text-muted">
@@ -49,9 +51,9 @@ export default {
</p>
</div>
<div class="form-group" :class="{ 'gl-show-field-errors': connectError }">
- <label class="label-bold" for="error-tracking-token">{{
- s__('ErrorTracking|Auth Token')
- }}</label>
+ <label class="label-bold" for="error-tracking-token">
+ {{ s__('ErrorTracking|Auth Token') }}
+ </label>
<div class="row">
<div class="col-8 col-md-9 gl-pr-0">
<gl-form-input
@@ -65,9 +67,8 @@ export default {
<gl-button
class="js-error-tracking-connect prepend-left-5"
@click="$emit('handle-connect')"
+ >{{ __('Connect') }}</gl-button
>
- {{ __('Connect') }}
- </gl-button>
<icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js
new file mode 100644
index 00000000000..6909f82c66f
--- /dev/null
+++ b/app/assets/javascripts/event_tracking/issue_sidebar.js
@@ -0,0 +1,2 @@
+export const initSidebarTracking = () => {};
+export const trackEvent = () => {};
diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js
index 2d1ec238274..1f70290c397 100644
--- a/app/assets/javascripts/event_tracking/notes.js
+++ b/app/assets/javascripts/event_tracking/notes.js
@@ -1 +1,2 @@
+// Noop function which has a EE counter-part
export default () => {};
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index 64b09c8b62c..77080691dcb 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -17,11 +17,13 @@ export default class FilterableList {
}
getFilterEndpoint() {
- return `${this.filterForm.getAttribute('action')}?${$(this.filterForm).serialize()}`;
+ return this.getPagePath();
}
getPagePath() {
- return this.getFilterEndpoint();
+ const action = this.filterForm.getAttribute('action');
+ const params = $(this.filterForm).serialize();
+ return `${action}${action.indexOf('?') > 0 ? '&' : '?'}${params}`;
}
initSearch() {
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 19bc3313373..4757c4b1e43 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -59,7 +59,7 @@ export default {
<template>
<div>
<div v-if="!isLocalStorageAvailable" class="dropdown-info-note">
- This feature requires local storage to be enabled
+ {{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
<li v-for="(item, index) in processedItems" :key="`processed-items-${index}`">
@@ -90,10 +90,10 @@ export default {
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
>
- Clear recent searches
+ {{ __('Clear recent searches') }}
</button>
</li>
</ul>
- <div v-else class="dropdown-info-note">You don't have any recent searches</div>
+ <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div>
</div>
</template>
diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
new file mode 100644
index 00000000000..7403fd6d3c2
--- /dev/null
+++ b/app/assets/javascripts/graphql_shared/fragments/pageInfo.fragment.graphql
@@ -0,0 +1,4 @@
+fragment PageInfo on PageInfo {
+ hasNextPage
+ endCursor
+}
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 903c838e266..460174caf4d 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { slugifyWithHyphens } from './lib/utils/text_utility';
+import { slugify } from './lib/utils/text_utility';
export default class Group {
constructor() {
@@ -14,7 +14,7 @@ export default class Group {
}
update() {
- const slug = slugifyWithHyphens(this.groupName.val());
+ const slug = slugify(this.groupName.val());
this.groupPath.val(slug);
}
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 9909f437fc8..830385941d8 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -129,7 +129,7 @@ export default {
<item-stats-value
:icon-name="visibilityIcon"
:title="visibilityTooltip"
- css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4"
+ css-class="item-visibility d-inline-flex align-items-center prepend-top-8 append-right-4 text-secondary"
/>
<span v-if="group.permission" class="user-access-role prepend-top-8">
{{ group.permission }}
diff --git a/app/assets/javascripts/ide/components/branches/item.vue b/app/assets/javascripts/ide/components/branches/item.vue
index 4dff3f7e755..5c048749060 100644
--- a/app/assets/javascripts/ide/components/branches/item.vue
+++ b/app/assets/javascripts/ide/components/branches/item.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import Icon from '~/vue_shared/components/icon.vue';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import router from '../../ide_router';
diff --git a/app/assets/javascripts/ide/components/branches/search_list.vue b/app/assets/javascripts/ide/components/branches/search_list.vue
index 3cfdc1a367a..db8365a08e0 100644
--- a/app/assets/javascripts/ide/components/branches/search_list.vue
+++ b/app/assets/javascripts/ide/components/branches/search_list.vue
@@ -58,26 +58,24 @@ export default {
<template>
<div>
- <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
- <div class="position-relative">
- <input
- ref="searchInput"
- v-model="search"
- :placeholder="__('Search branches')"
- type="search"
- class="form-control dropdown-input-field"
- @input="searchBranches"
- />
- <icon :size="18" name="search" class="input-icon" />
- </div>
- </div>
+ <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block position-relative" @click.stop>
+ <input
+ ref="searchInput"
+ v-model="search"
+ :placeholder="__('Search branches')"
+ type="search"
+ class="form-control dropdown-input-field"
+ @input="searchBranches"
+ />
+ <icon :size="18" name="search" class="ml-3 input-icon" />
+ </label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
v-if="isLoading"
:size="2"
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
/>
- <ul v-else class="mb-3 w-100">
+ <ul v-else class="mb-0 w-100">
<template v-if="hasBranches">
<li v-for="item in branches" :key="item.name">
<item :item="item" :project-id="currentProjectId" :is-active="isActiveBranch(item)" />
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 685d8a6b245..8b356ee6e97 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -41,10 +41,16 @@ export default {
methods: {
...mapCommitActions(['updateCommitAction']),
updateSelectedCommitAction() {
- if (this.currentBranch && !this.currentBranch.can_push) {
- this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH);
- } else if (this.containsStagedChanges) {
+ if (!this.currentBranch) {
+ return;
+ }
+
+ const { can_push: canPush = false, default: isDefault = false } = this.currentBranch;
+
+ if (canPush && !isDefault) {
this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH);
+ } else {
+ this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH);
}
},
},
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 4be4b02ac1e..302adccd759 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -45,6 +45,8 @@ export default {
},
computed: {
iconName() {
+ // name: '-solid' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
const suffix = this.stagedList ? '-solid' : '';
return `${getCommitIconMap(this.file).icon}${suffix}`;
@@ -107,7 +109,8 @@ export default {
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
- <file-icon :file-name="file.name" class="append-right-8" />{{ file.name }}
+ <file-icon :file-name="file.name" class="append-right-8" />
+ {{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
index b2e7b15089c..daa44a42765 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue
@@ -1,43 +1,36 @@
<script>
-import { mapGetters, createNamespacedHelpers } from 'vuex';
+import { createNamespacedHelpers } from 'vuex';
const {
mapState: mapCommitState,
- mapGetters: mapCommitGetters,
mapActions: mapCommitActions,
+ mapGetters: mapCommitGetters,
} = createNamespacedHelpers('commit');
export default {
computed: {
...mapCommitState(['shouldCreateMR']),
- ...mapCommitGetters(['isCommittingToCurrentBranch', 'isCommittingToDefaultBranch']),
- ...mapGetters(['hasMergeRequest', 'isOnDefaultBranch']),
- currentBranchHasMr() {
- return this.hasMergeRequest && this.isCommittingToCurrentBranch;
- },
- showNewMrOption() {
- return (
- this.isCommittingToDefaultBranch || !this.currentBranchHasMr || this.isCommittingToNewBranch
- );
- },
- },
- mounted() {
- this.setShouldCreateMR();
+ ...mapCommitGetters(['shouldHideNewMrOption']),
},
methods: {
- ...mapCommitActions(['toggleShouldCreateMR', 'setShouldCreateMR']),
+ ...mapCommitActions(['toggleShouldCreateMR']),
},
};
</script>
<template>
- <div v-if="showNewMrOption">
+ <fieldset v-if="!shouldHideNewMrOption">
<hr class="my-2" />
- <label class="mb-0">
- <input :checked="shouldCreateMR" type="checkbox" @change="toggleShouldCreateMR" />
+ <label class="mb-0 js-ide-commit-new-mr">
+ <input
+ :checked="shouldCreateMR"
+ type="checkbox"
+ data-qa-selector="start_new_mr_checkbox"
+ @change="toggleShouldCreateMR"
+ />
<span class="prepend-left-10">
{{ __('Start a new merge request') }}
</span>
</label>
- </div>
+ </fieldset>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
index b1d5de8682d..137f8bb18c7 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/success_message.vue
@@ -10,7 +10,9 @@ export default {
<template>
<div class="multi-file-commit-panel-success-message" aria-live="assertive">
- <div class="svg-content svg-80"><img :src="committedStateSvgPath" alt="" /></div>
+ <div class="svg-content svg-80">
+ <img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" />
+ </div>
<div class="append-right-default prepend-left-default">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue
index 954f84cea17..d1857f0176a 100644
--- a/app/assets/javascripts/ide/components/external_link.vue
+++ b/app/assets/javascripts/ide/components/external_link.vue
@@ -27,7 +27,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <span class="vertical-align-middle">Open in file view</span>
+ <span class="vertical-align-middle">{{ __('Open in file view') }}</span>
<icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" />
</a>
</div>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index 80a6ab9598a..7254c50a568 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -87,7 +87,6 @@ export default {
:file="file"
:show-tooltip="true"
:show-staged-icon="true"
- :force-modified-icon="true"
/>
<new-dropdown
:type="file.type"
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index 206b8341aad..326589fa50f 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapActions, mapState, mapGetters } from 'vuex';
import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue';
import icon from '~/vue_shared/components/icon.vue';
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index 2d55ffb3c65..5daf2d1422c 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -76,19 +76,17 @@ export default {
<template>
<div>
- <div class="dropdown-input mt-3 pb-3 mb-0 border-bottom">
- <div class="position-relative">
- <tokened-input
- v-model="search"
- :tokens="searchTokens"
- :placeholder="__('Search merge requests')"
- @focus="onSearchFocus"
- @input="searchMergeRequests"
- @removeToken="setSearchType(null)"
- />
- <icon :size="18" name="search" class="input-icon" />
- </div>
- </div>
+ <label class="dropdown-input pt-3 pb-3 mb-0 border-bottom block" @click.stop>
+ <tokened-input
+ v-model="search"
+ :tokens="searchTokens"
+ :placeholder="__('Search merge requests')"
+ @focus="onSearchFocus"
+ @input="searchMergeRequests"
+ @removeToken="setSearchType(null)"
+ />
+ <icon :size="18" name="search" class="ml-3 input-icon" />
+ </label>
<div class="dropdown-content ide-merge-requests-dropdown-content d-flex">
<gl-loading-icon
v-if="isLoading"
@@ -96,7 +94,7 @@ export default {
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
/>
<template v-else>
- <ul class="mb-3 w-100">
+ <ul class="mb-0 w-100">
<template v-if="showSearchTypes">
<li v-for="searchType in $options.searchTypes" :key="searchType.type">
<button
@@ -107,7 +105,7 @@ export default {
<span class="d-flex append-right-default ide-search-list-current-icon">
<icon :size="18" name="search" />
</span>
- <span> {{ searchType.label }} </span>
+ <span>{{ searchType.label }}</span>
</button>
</li>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b0c4969c5e4..802b7f1fa6f 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -4,10 +4,16 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
-import { activityBarViews, viewerTypes } from '../constants';
+import {
+ activityBarViews,
+ viewerTypes,
+ FILE_VIEW_MODE_EDITOR,
+ FILE_VIEW_MODE_PREVIEW,
+} from '../constants';
import Editor from '../lib/editor';
import ExternalLink from './external_link.vue';
import FileTemplatesBar from './file_templates/bar.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -40,27 +46,36 @@ export default {
},
showContentViewer() {
return (
- (this.shouldHideEditor || this.file.viewMode === 'preview') &&
+ (this.shouldHideEditor || this.isPreviewViewMode) &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
+ isEditorViewMode() {
+ return this.file.viewMode === FILE_VIEW_MODE_EDITOR;
+ },
+ isPreviewViewMode() {
+ return this.file.viewMode === FILE_VIEW_MODE_PREVIEW;
+ },
editTabCSS() {
return {
- active: this.file.viewMode === 'editor',
+ active: this.isEditorViewMode,
};
},
previewTabCSS() {
return {
- active: this.file.viewMode === 'preview',
+ active: this.isPreviewViewMode,
};
},
fileType() {
const info = viewerInformationForPath(this.file.path);
return (info && info.id) || '';
},
+ showEditor() {
+ return !this.shouldHideEditor && this.isEditorViewMode;
+ },
},
watch: {
file(newVal, oldVal) {
@@ -75,7 +90,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({
file: this.file,
- viewMode: 'editor',
+ viewMode: FILE_VIEW_MODE_EDITOR,
});
}
}
@@ -84,12 +99,12 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({
file: this.file,
- viewMode: 'editor',
+ viewMode: FILE_VIEW_MODE_EDITOR,
});
}
},
rightPanelCollapsed() {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
},
viewer() {
if (!this.file.pending) {
@@ -98,11 +113,17 @@ export default {
},
panelResizing() {
if (!this.panelResizing) {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
}
},
rightPaneIsOpen() {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
+ },
+ showEditor(val) {
+ if (val) {
+ // We need to wait for the editor to actually be rendered.
+ this.$nextTick(() => this.refreshEditorDimensions());
+ }
},
},
beforeDestroy() {
@@ -128,7 +149,9 @@ export default {
'triggerFilesChange',
]),
initEditor() {
- if (this.shouldHideEditor) return;
+ if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
+ return;
+ }
this.editor.clearEditor();
@@ -145,7 +168,14 @@ export default {
this.createEditorInstance();
})
.catch(err => {
- flash('Error setting up editor. Please try again.', 'alert', document, null, false, true);
+ flash(
+ __('Error setting up editor. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
throw err;
});
},
@@ -212,8 +242,15 @@ export default {
eol: this.model.eol,
});
},
+ refreshEditorDimensions() {
+ if (this.showEditor) {
+ this.editor.updateDimensions();
+ }
+ },
},
viewerTypes,
+ FILE_VIEW_MODE_EDITOR,
+ FILE_VIEW_MODE_PREVIEW,
};
</script>
@@ -225,31 +262,26 @@ export default {
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: 'editor' })"
+ @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>
- <template v-if="viewer === $options.viewerTypes.edit">
- {{ __('Edit') }}
- </template>
- <template v-else>
- {{ __('Review') }}
- </template>
+ <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template>
+ <template v-else>{{ __('Review') }}</template>
</a>
</li>
<li v-if="file.previewMode" :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: 'preview' })"
+ @click.prevent="setFileViewMode({ file, viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
+ >{{ file.previewMode.previewTitle }}</a
>
- {{ file.previewMode.previewTitle }}
- </a>
</li>
</ul>
<external-link :file="file" />
</div>
<file-templates-bar v-if="showFileTemplatesBar(file.name)" />
<div
- v-show="!shouldHideEditor && file.viewMode === 'editor'"
+ v-show="showEditor"
ref="editor"
:class="{
'is-readonly': isCommitModeActive,
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index a964d90b090..84a962bfc7d 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import '~/lib/utils/datetime_utility';
@@ -18,7 +19,9 @@ export default {
},
computed: {
lockTooltip() {
- return `Locked by ${this.file.file_lock.user.name}`;
+ return sprintf(__(`Locked by %{fileLockUserName}`), {
+ fileLockUserName: this.file.file_lock.user.name,
+ });
},
},
};
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index f6aa2295844..7615cfc966e 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import { mapActions } from 'vuex';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -27,9 +28,9 @@ export default {
computed: {
closeLabel() {
if (this.fileHasChanged) {
- return `${this.tab.name} changed`;
+ return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name });
}
- return `Close ${this.tab.name}`;
+ return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name }));
},
showChangedIcon() {
if (this.tab.pending) return true;
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index e30670e119f..673ac1bfa9a 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -4,6 +4,10 @@ export const MAX_WINDOW_HEIGHT_COMPACT = 750;
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
+// File view modes
+export const FILE_VIEW_MODE_EDITOR = 'editor';
+export const FILE_VIEW_MODE_PREVIEW = 'preview';
+
export const activityBarViews = {
edit: 'ide-tree',
commit: 'commit-section',
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
index b8abaa41f23..51278640b5b 100644
--- a/app/assets/javascripts/ide/lib/files.js
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -77,6 +77,7 @@ export const decorateFiles = ({
const fileFolder = parent && insertParent(parent);
if (name) {
+ const previewMode = viewerInformationForPath(name);
parentPath = fileFolder && fileFolder.path;
file = decorateData({
@@ -92,9 +93,9 @@ export const decorateFiles = ({
changed: tempFile,
content,
base64,
- binary,
+ binary: (previewMode && previewMode.binary) || binary,
rawPath,
- previewMode: viewerInformationForPath(name),
+ previewMode,
parentPath,
});
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 840761f68db..ba33b6826d6 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -56,13 +56,7 @@ export default {
return Api.branchSingle(projectId, currentBranchId);
},
commit(projectId, payload) {
- // Currently the `commit` endpoint does not support `start_sha` so we
- // have to make the request in the FE. This is not ideal and will be
- // resolved soon. https://gitlab.com/gitlab-org/gitlab-ce/issues/59023
- const { branch, start_sha: ref } = payload;
- const branchPromise = ref ? Api.createBranch(projectId, { ref, branch }) : Promise.resolve();
-
- return branchPromise.then(() => Api.commitMultiple(projectId, payload));
+ return Api.commitMultiple(projectId, payload);
},
getFiles(projectUrl, branchId) {
const url = `${projectUrl}/files/${branchId}`;
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 507dc363529..8c0119a1fed 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -62,7 +62,7 @@ export const createTempEntry = (
new Promise(resolve => {
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
- if (state.entries[name]) {
+ if (state.entries[name] && !state.entries[name].deleted) {
flash(
`The name "${name.split('/').pop()}" is already taken in this directory.`,
'alert',
@@ -208,6 +208,7 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
}
commit(types.DELETE_ENTRY, path);
+ dispatch('stageChange', path);
dispatch('triggerFilesChange');
};
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 406903129db..85fd45358be 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -104,5 +104,8 @@ export const packageJson = state => state.entries[packageJsonPath];
export const isOnDefaultBranch = (_state, getters) =>
getters.currentProject && getters.currentProject.default_branch === getters.branchName;
+export const canPushToBranch = (_state, getters) =>
+ getters.currentBranch && getters.currentBranch.can_push;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 01ca6a6b12f..23caf2d48ed 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -18,34 +18,15 @@ export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
-export const updateCommitAction = ({ commit, dispatch }, commitAction) => {
+export const updateCommitAction = ({ commit, getters }, commitAction) => {
commit(types.UPDATE_COMMIT_ACTION, {
commitAction,
});
- dispatch('setShouldCreateMR');
+ commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption);
};
export const toggleShouldCreateMR = ({ commit }) => {
commit(types.TOGGLE_SHOULD_CREATE_MR);
- commit(types.INTERACT_WITH_NEW_MR);
-};
-
-export const setShouldCreateMR = ({
- commit,
- getters,
- rootGetters,
- state: { interactedWithNewMR },
-}) => {
- const committingToExistingMR =
- getters.isCommittingToCurrentBranch &&
- rootGetters.hasMergeRequest &&
- !rootGetters.isOnDefaultBranch;
-
- if ((getters.isCommittingToDefaultBranch && !interactedWithNewMR) || committingToExistingMR) {
- commit(types.TOGGLE_SHOULD_CREATE_MR, false);
- } else if (!interactedWithNewMR) {
- commit(types.TOGGLE_SHOULD_CREATE_MR, true);
- }
};
export const updateBranchName = ({ commit }, branchName) => {
@@ -186,6 +167,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
+ commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true });
+
setTimeout(() => {
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 64779e9e4df..de289e27199 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -20,7 +20,7 @@ export const placeholderBranchName = (state, _, rootState) =>
)}`;
export const branchName = (state, getters, rootState) => {
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
+ if (getters.isCreatingNewBranch) {
if (state.newBranchName === '') {
return getters.placeholderBranchName;
}
@@ -48,11 +48,11 @@ export const preBuiltCommitMessage = (state, _, rootState) => {
export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH;
-export const isCommittingToCurrentBranch = state =>
- state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH;
-
-export const isCommittingToDefaultBranch = (_state, getters, _rootState, rootGetters) =>
- getters.isCommittingToCurrentBranch && rootGetters.isOnDefaultBranch;
+export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters) =>
+ !getters.isCreatingNewBranch &&
+ (rootGetters.hasMergeRequest ||
+ (!rootGetters.hasMergeRequest && rootGetters.isOnDefaultBranch)) &&
+ rootGetters.canPushToBranch;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
index b81918156b0..7ad8f3570b7 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -3,4 +3,3 @@ export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
export const UPDATE_LOADING = 'UPDATE_LOADING';
export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR';
-export const INTERACT_WITH_NEW_MR = 'INTERACT_WITH_NEW_MR';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
index 14957d283bb..73b618e250f 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -24,7 +24,4 @@ export default {
shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR,
});
},
- [types.INTERACT_WITH_NEW_MR](state) {
- Object.assign(state, { interactedWithNewMR: true });
- },
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
index 53647a7e3e3..259577e48e0 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/state.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -3,6 +3,5 @@ export default () => ({
commitAction: '1',
newBranchName: '',
submitCommitLoading: false,
- shouldCreateMR: false,
- interactedWithNewMR: false,
+ shouldCreateMR: true,
});
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 86ab76136df..f021729c451 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -60,6 +60,8 @@ export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
+export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES';
+
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index ec4c2fdcde2..ea125214ebb 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -56,6 +56,11 @@ export default {
stagedFiles: [],
});
},
+ [types.CLEAR_REPLACED_FILES](state) {
+ Object.assign(state, {
+ replacedFiles: [],
+ });
+ },
[types.SET_ENTRIES](state, entries) {
Object.assign(state, {
entries,
@@ -70,6 +75,13 @@ export default {
Object.assign(state.entries, {
[key]: entry,
});
+ } else if (foundEntry.deleted) {
+ Object.assign(state.entries, {
+ [key]: {
+ ...entry,
+ replaces: true,
+ },
+ });
} else {
const tree = entry.tree.filter(
f => foundEntry.tree.find(e => e.path === f.path) === undefined,
@@ -144,6 +156,7 @@ export default {
raw: file.content,
changed: Boolean(changedFile),
staged: false,
+ replaces: false,
prevPath: '',
moved: false,
lastCommitSha: lastCommit.commit.id,
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 6ca246c1d63..1442ea7dbfa 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -1,6 +1,7 @@
import * as types from '../mutation_types';
import { sortTree } from '../utils';
import { diffModes } from '../../constants';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
[types.SET_FILE_ACTIVE](state, { path, active }) {
@@ -35,19 +36,22 @@ export default {
}
},
[types.SET_FILE_DATA](state, { data, file }) {
- Object.assign(state.entries[file.path], {
- id: data.id,
- blamePath: data.blame_path,
- commitsPath: data.commits_path,
- permalink: data.permalink,
- rawPath: data.raw_path,
- binary: data.binary,
- renderError: data.render_error,
- raw: (state.entries[file.path] && state.entries[file.path].raw) || null,
- baseRaw: null,
- html: data.html,
- size: data.size,
- lastCommitSha: data.last_commit_sha,
+ const stateEntry = state.entries[file.path];
+ const stagedFile = state.stagedFiles.find(f => f.path === file.path);
+ const openFile = state.openFiles.find(f => f.path === file.path);
+ const changedFile = state.changedFiles.find(f => f.path === file.path);
+
+ [stateEntry, stagedFile, openFile, changedFile].forEach(f => {
+ if (f) {
+ Object.assign(
+ f,
+ convertObjectPropsToCamelCase(data, { dropKeys: ['path', 'name', 'raw', 'baseRaw'] }),
+ {
+ raw: (stateEntry && stateEntry.raw) || null,
+ baseRaw: null,
+ },
+ );
+ }
});
},
[types.SET_FILE_RAW_DATA](state, { file, raw }) {
@@ -170,12 +174,16 @@ export default {
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: true,
- changed: false,
}),
}),
});
if (stagedFile) {
+ Object.assign(state, {
+ replacedFiles: state.replacedFiles.concat({
+ ...stagedFile,
+ }),
+ });
Object.assign(stagedFile, {
...state.entries[path],
});
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index d400b9831a9..c4da482bf0a 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -6,6 +6,7 @@ export default () => ({
currentMergeRequestId: '',
changedFiles: [],
stagedFiles: [],
+ replacedFiles: [],
endpoints: {},
lastCommitMsg: '',
lastCommitPath: '',
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index fb132c1afc1..52200ce7847 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,4 +1,4 @@
-import { commitActionTypes } from '../constants';
+import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
export const dataStructure = () => ({
id: '',
@@ -18,6 +18,7 @@ export const dataStructure = () => ({
active: false,
changed: false,
staged: false,
+ replaces: false,
lastCommitPath: '',
lastCommitSha: '',
lastCommit: {
@@ -42,7 +43,7 @@ export const dataStructure = () => ({
editorColumn: 1,
fileLanguage: '',
eol: '',
- viewMode: 'editor',
+ viewMode: FILE_VIEW_MODE_EDITOR,
previewMode: null,
size: 0,
parentPath: null,
@@ -119,7 +120,7 @@ export const commitActionForFile = file => {
return commitActionTypes.move;
} else if (file.deleted) {
return commitActionTypes.delete;
- } else if (file.tempFile) {
+ } else if (file.tempFile && !file.replaces) {
return commitActionTypes.create;
}
@@ -128,7 +129,7 @@ export const commitActionForFile = file => {
export const getCommitFiles = stagedFiles =>
stagedFiles.reduce((acc, file) => {
- if (file.moved) return acc;
+ if (file.moved || file.type === 'tree') return acc;
return acc.concat({
...file,
@@ -151,13 +152,14 @@ export const createCommitPayload = ({
previous_path: f.prevPath === '' ? undefined : f.prevPath,
content: f.prevPath ? null : f.content || undefined,
encoding: f.base64 ? 'base64' : 'text',
- last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
+ last_commit_id:
+ newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha,
})),
- start_sha: newBranch ? rootGetters.lastCommit.short_id : undefined,
+ start_sha: newBranch ? rootGetters.lastCommit.id : undefined,
});
export const createNewMergeRequestUrl = (projectUrl, source, target) =>
- `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}`;
+ `${projectUrl}/merge_requests/new?merge_request[source_branch]=${source}&merge_request[target_branch]=${target}&nav_source=webide`;
const sortTreesByTypeAndName = (a, b) => {
if (a.type === 'tree' && b.type === 'blob') {
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index bc9d7fcf30d..c855f3973b0 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback, no-unused-vars */
+/* eslint-disable consistent-return, func-names, array-callback-return, prefer-arrow-callback */
import $ from 'jquery';
import _ from 'underscore';
@@ -7,7 +7,7 @@ import Flash from './flash';
import { __ } from './locale';
export default {
- init({ container, form, issues, prefixId } = {}) {
+ init({ form, issues, prefixId } = {}) {
this.prefixId = prefixId || 'issue_';
this.form = form || this.getElement('.bulk-update');
this.$labelDropdown = this.form.find('.js-label-select');
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index 16f88cddce3..f3f8b6ec715 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -2,26 +2,13 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import { s__, __ } from './locale';
-import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
-import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
+import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar';
export default class IssuableIndex {
constructor(pagePrefix) {
- this.initBulkUpdate(pagePrefix);
+ issuableInitBulkUpdateSidebar.init(pagePrefix);
IssuableIndex.resetIncomingEmailToken();
}
- initBulkUpdate(pagePrefix) {
- const userCanBulkUpdate = $('.issues-bulk-update').length > 0;
- const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
-
- if (userCanBulkUpdate && !alreadyInitialized) {
- IssuableBulkUpdateActions.init({
- prefixId: pagePrefix,
- });
-
- this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
- }
- }
static resetIncomingEmailToken() {
const $resetToken = $('.incoming-email-token-reset');
diff --git a/app/assets/javascripts/issuable_init_bulk_update_sidebar.js b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js
new file mode 100644
index 00000000000..da8969c80f3
--- /dev/null
+++ b/app/assets/javascripts/issuable_init_bulk_update_sidebar.js
@@ -0,0 +1,19 @@
+import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
+import issuableBulkUpdateActions from './issuable_bulk_update_actions';
+
+export default {
+ bulkUpdateSidebar: null,
+
+ init(prefixId) {
+ const bulkUpdateEl = document.querySelector('.issues-bulk-update');
+ const alreadyInitialized = Boolean(this.bulkUpdateSidebar);
+
+ if (bulkUpdateEl && !alreadyInitialized) {
+ issuableBulkUpdateActions.init({ prefixId });
+
+ this.bulkUpdateSidebar = new IssuableBulkUpdateSidebar();
+ }
+
+ return this.bulkUpdateSidebar;
+ },
+};
diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue
index 9a16b486bf5..7629e04684c 100644
--- a/app/assets/javascripts/issuable_suggestions/components/item.vue
+++ b/app/assets/javascripts/issuable_suggestions/components/item.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
import { GlLink, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale';
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index de2a9664cde..9ca38d6bbfa 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -55,6 +55,11 @@ export default {
required: false,
default: true,
},
+ zoomMeetingUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
issuableRef: {
type: String,
required: true,
@@ -342,7 +347,7 @@ export default {
:title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton"
/>
- <pinned-links :description-html="state.descriptionHtml" />
+ <pinned-links :zoom-meeting-url="zoomMeetingUrl" />
<description-component
v-if="state.descriptionHtml"
:can-update="canUpdate"
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index b2f9296c68b..eb51a074f84 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { __, sprintf } from '~/locale';
import updateMixin from '../mixins/update';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/issue_show/components/edited.vue b/app/assets/javascripts/issue_show/components/edited.vue
index 14ad8d3b7c9..2c92324d292 100644
--- a/app/assets/javascripts/issue_show/components/edited.vue
+++ b/app/assets/javascripts/issue_show/components/edited.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 6f955928d8e..bc3c81d479e 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import $ from 'jquery';
import IssuableTemplateSelectors from '../../../templates/issuable_template_selectors';
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 528ccb77efc..d48bf1fe7a9 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -70,6 +70,9 @@ export default {
hasIssuableTemplates() {
return this.issuableTemplates.length;
},
+ showLockedWarning() {
+ return this.formState.lockedWarningVisible && !this.formState.updateLoading;
+ },
},
created() {
eventHub.$on('delete.issuable', this.resetAutosave);
@@ -117,7 +120,7 @@ export default {
<template>
<form>
- <locked-warning v-if="formState.lockedWarningVisible" />
+ <locked-warning v-if="showLockedWarning" />
<div class="row">
<div v-if="hasIssuableTemplates" class="col-sm-4 col-lg-3">
<description-template
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
index 2f3e611e089..19c7a11d87b 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -1,18 +1,27 @@
<script>
+import { __, sprintf } from '~/locale';
+
export default {
computed: {
currentPath() {
return window.location.pathname;
},
+ alertMessage() {
+ return sprintf(
+ __(
+ 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.',
+ ),
+ {
+ linkStart: `<a href="${this.currentPath}" target="_blank" rel="nofollow">`,
+ linkEnd: `</a>`,
+ },
+ false,
+ );
+ },
},
};
</script>
<template>
- <div class="alert alert-danger">
- {{ sprintf(__("Someone edited the issue at the same time you did. Please check out
- %{linkStart}%the issue%{linkEnd} and make sure your changes will not unintentionally remove
- theirs."), { linkStart: `<a href="${currentPath}" target="_blank" rel="nofollow">` linkEnd: '</a
- >', }) }}
- </div>
+ <div class="alert alert-danger" v-html="alertMessage"></div>
</template>
diff --git a/app/assets/javascripts/issue_show/components/pinned_links.vue b/app/assets/javascripts/issue_show/components/pinned_links.vue
index 7a54b26bc2b..965e8a3d751 100644
--- a/app/assets/javascripts/issue_show/components/pinned_links.vue
+++ b/app/assets/javascripts/issue_show/components/pinned_links.vue
@@ -8,40 +8,19 @@ export default {
GlLink,
},
props: {
- descriptionHtml: {
+ zoomMeetingUrl: {
type: String,
- required: true,
- },
- },
- computed: {
- linksInDescription() {
- const el = document.createElement('div');
- el.innerHTML = this.descriptionHtml;
- return [...el.querySelectorAll('a')].map(a => a.href);
- },
- // Detect links matching the following formats:
- // Zoom Start links: https://zoom.us/s/<meeting-id>
- // Zoom Join links: https://zoom.us/j/<meeting-id>
- // Personal Zoom links: https://zoom.us/my/<meeting-id>
- // Vanity Zoom links: https://gitlab.zoom.us/j/<meeting-id> (also /s and /my)
- zoomHref() {
- const zoomRegex = /^https:\/\/([\w\d-]+\.)?zoom\.us\/(s|j|my)\/.+/;
- return this.linksInDescription.reduce((acc, currentLink) => {
- let lastLink = acc;
- if (zoomRegex.test(currentLink)) {
- lastLink = currentLink;
- }
- return lastLink;
- }, '');
+ required: false,
+ default: null,
},
},
};
</script>
<template>
- <div v-if="zoomHref" class="border-bottom mb-3 mt-n2">
+ <div v-if="zoomMeetingUrl" class="border-bottom mb-3 mt-n2">
<gl-link
- :href="zoomHref"
+ :href="zoomMeetingUrl"
target="_blank"
class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
>
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index 529b6386221..5a9dd91817e 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar';
import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
import '../vue_shared/vue_resource_interceptor';
@@ -9,6 +10,9 @@ export default function initIssueableApp() {
components: {
issuableApp,
},
+ mounted() {
+ initSidebarTracking();
+ },
render(createElement) {
return createElement('issuable-app', {
props: parseIssuableData(),
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index b651a6e4bfb..9fac880c5f8 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue
index 04f910b6b80..e2bc413e3ce 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/empty_state.vue
@@ -1,9 +1,11 @@
<script>
import { GlLink } from '@gitlab/ui';
+import ManualVariablesForm from './manual_variables_form.vue';
export default {
components: {
GlLink,
+ ManualVariablesForm,
},
props: {
illustrationPath: {
@@ -23,6 +25,21 @@ export default {
required: false,
default: null,
},
+ playable: {
+ type: Boolean,
+ required: true,
+ default: false,
+ },
+ scheduled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variablesSettingsUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
action: {
type: Object,
required: false,
@@ -37,28 +54,40 @@ export default {
},
},
},
+ computed: {
+ shouldRenderManualVariables() {
+ return this.playable && !this.scheduled;
+ },
+ },
};
</script>
<template>
<div class="row empty-state">
<div class="col-12">
- <div :class="illustrationSizeClass" class="svg-content"><img :src="illustrationPath" /></div>
+ <div :class="illustrationSizeClass" class="svg-content">
+ <img :src="illustrationPath" />
+ </div>
</div>
<div class="col-12">
<div class="text-content">
<h4 class="js-job-empty-state-title text-center">{{ title }}</h4>
- <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p>
-
- <div v-if="action" class="text-center">
+ <p v-if="content" class="js-job-empty-state-content">{{ content }}</p>
+ </div>
+ <manual-variables-form
+ v-if="shouldRenderManualVariables"
+ :action="action"
+ :variables-settings-url="variablesSettingsUrl"
+ />
+ <div class="text-content">
+ <div v-if="action && !shouldRenderManualVariables" class="text-center">
<gl-link
:href="action.path"
:data-method="action.method"
class="js-job-empty-state-action btn btn-primary"
+ >{{ action.button_title }}</gl-link
>
- {{ action.button_title }}
- </gl-link>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 79fb67d38cd..8da87f424c4 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -45,6 +45,11 @@ export default {
required: false,
default: null,
},
+ variablesSettingsUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
runnerHelpUrl: {
type: String,
required: false,
@@ -68,6 +73,10 @@ export default {
type: String,
required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
logState: {
type: String,
required: true,
@@ -253,6 +262,7 @@ export default {
:quota-used="job.runners.quota.used"
:quota-limit="job.runners.quota.limit"
:runners-path="runnerHelpUrl"
+ :project-path="projectPath"
/>
<environments-block
@@ -313,6 +323,9 @@ export default {
:title="emptyStateTitle"
:content="emptyStateIllustration.content"
:action="emptyStateAction"
+ :playable="job.playable"
+ :scheduled="job.scheduled"
+ :variables-settings-url="variablesSettingsUrl"
/>
<!-- EO empty state -->
diff --git a/app/assets/javascripts/jobs/components/job_log.vue b/app/assets/javascripts/jobs/components/job_log.vue
index d611b370ab9..a3fbe9338ee 100644
--- a/app/assets/javascripts/jobs/components/job_log.vue
+++ b/app/assets/javascripts/jobs/components/job_log.vue
@@ -48,9 +48,14 @@ export default {
}
},
removeEventListener() {
- this.$el
- .querySelectorAll('.js-section-start')
- .forEach(el => el.removeEventListener('click', this.handleSectionClick));
+ this.$el.querySelectorAll('.js-section-start').forEach(el => {
+ const titleSection = el.nextSibling;
+ titleSection.removeEventListener(
+ 'click',
+ this.handleHeaderClick.bind(this, el, el.dataset.section),
+ );
+ el.removeEventListener('click', this.handleSectionClick);
+ });
},
/**
* The collapsible rows are sent in HTML from the backend
@@ -58,9 +63,28 @@ export default {
*
*/
handleCollapsibleRows() {
- this.$el
- .querySelectorAll('.js-section-start')
- .forEach(el => el.addEventListener('click', this.handleSectionClick));
+ this.$el.querySelectorAll('.js-section-start').forEach(el => {
+ const titleSection = el.nextSibling;
+ titleSection.addEventListener(
+ 'click',
+ this.handleHeaderClick.bind(this, el, el.dataset.section),
+ );
+ el.addEventListener('click', this.handleSectionClick);
+ });
+ },
+
+ handleHeaderClick(arrowElement, section) {
+ this.updateToggleSection(arrowElement, section);
+ },
+
+ updateToggleSection(arrow, section) {
+ // toggle the arrow class
+ arrow.classList.toggle('fa-caret-right');
+ arrow.classList.toggle('fa-caret-down');
+
+ // hide the sections
+ const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`);
+ sibilings.forEach(row => row.classList.toggle('hidden'));
},
/**
* On click, we toggle the hidden class of
@@ -68,14 +92,7 @@ export default {
*/
handleSectionClick(evt) {
const clickedArrow = evt.currentTarget;
- // toggle the arrow class
- clickedArrow.classList.toggle('fa-caret-right');
- clickedArrow.classList.toggle('fa-caret-down');
-
- const { section } = clickedArrow.dataset;
- const sibilings = this.$el.querySelectorAll(`.js-s-${section}:not(.js-section-header)`);
-
- sibilings.forEach(row => row.classList.toggle('hidden'));
+ this.updateToggleSection(clickedArrow, clickedArrow.dataset.section);
},
},
};
diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue
new file mode 100644
index 00000000000..c32a3cac7be
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue
@@ -0,0 +1,179 @@
+<script>
+import _ from 'underscore';
+import { mapActions } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ManualVariablesForm',
+ components: {
+ GlButton,
+ Icon,
+ },
+ props: {
+ action: {
+ type: Object,
+ required: false,
+ default: null,
+ validator(value) {
+ return (
+ value === null ||
+ (_.has(value, 'path') && _.has(value, 'method') && _.has(value, 'button_title'))
+ );
+ },
+ },
+ variablesSettingsUrl: {
+ type: String,
+ required: true,
+ default: '',
+ },
+ },
+ inputTypes: {
+ key: 'key',
+ value: 'value',
+ },
+ i18n: {
+ keyPlaceholder: s__('CiVariables|Input variable key'),
+ valuePlaceholder: s__('CiVariables|Input variable value'),
+ },
+ data() {
+ return {
+ variables: [],
+ key: '',
+ secretValue: '',
+ };
+ },
+ computed: {
+ helpText() {
+ return sprintf(
+ s__(
+ 'CiVariables|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used as default',
+ ),
+ {
+ linkStart: `<a href="${this.variablesSettingsUrl}">`,
+ linkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ },
+ watch: {
+ key(newVal) {
+ this.handleValueChange(newVal, this.$options.inputTypes.key);
+ },
+ secretValue(newVal) {
+ this.handleValueChange(newVal, this.$options.inputTypes.value);
+ },
+ },
+ methods: {
+ ...mapActions(['triggerManualJob']),
+ handleValueChange(newValue, type) {
+ if (newValue !== '') {
+ this.createNewVariable(type);
+ this.resetForm();
+ }
+ },
+ createNewVariable(type) {
+ const newVariable = {
+ key: this.key,
+ secret_value: this.secretValue,
+ id: _.uniqueId(),
+ };
+
+ this.variables.push(newVariable);
+
+ return this.$nextTick().then(() => {
+ this.$refs[`${this.$options.inputTypes[type]}-${newVariable.id}`][0].focus();
+ });
+ },
+ resetForm() {
+ this.key = '';
+ this.secretValue = '';
+ },
+ deleteVariable(id) {
+ this.variables.splice(this.variables.findIndex(el => el.id === id), 1);
+ },
+ },
+};
+</script>
+<template>
+ <div class="js-manual-vars-form col-12">
+ <label>{{ s__('CiVariables|Variables') }}</label>
+
+ <div class="ci-table">
+ <div class="gl-responsive-table-row table-row-header pb-0 pt-0 border-0" role="row">
+ <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Key') }}</div>
+ <div class="table-section section-50" role="rowheader">{{ s__('CiVariables|Value') }}</div>
+ </div>
+
+ <div v-for="variable in variables" :key="variable.id" class="gl-responsive-table-row">
+ <div class="table-section section-50">
+ <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
+ <div class="table-mobile-content append-right-10">
+ <input
+ :ref="`${$options.inputTypes.key}-${variable.id}`"
+ v-model="variable.key"
+ :placeholder="$options.i18n.keyPlaceholder"
+ class="ci-variable-body-item form-control"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-50">
+ <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
+ <div class="table-mobile-content append-right-10">
+ <input
+ :ref="`${$options.inputTypes.value}-${variable.id}`"
+ v-model="variable.secret_value"
+ :placeholder="$options.i18n.valuePlaceholder"
+ class="ci-variable-body-item form-control"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-10">
+ <div class="table-mobile-header" role="rowheader"></div>
+ <div class="table-mobile-content justify-content-end">
+ <gl-button class="btn-transparent btn-blank w-25" @click="deleteVariable(variable.id)">
+ <icon name="clear" />
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ <div class="gl-responsive-table-row">
+ <div class="table-section section-50">
+ <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Key') }}</div>
+ <div class="table-mobile-content append-right-10">
+ <input
+ ref="inputKey"
+ v-model="key"
+ class="js-input-key form-control"
+ :placeholder="$options.i18n.keyPlaceholder"
+ />
+ </div>
+ </div>
+
+ <div class="table-section section-50">
+ <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Value') }}</div>
+ <div class="table-mobile-content append-right-10">
+ <input
+ ref="inputSecretValue"
+ v-model="secretValue"
+ class="ci-variable-body-item form-control"
+ :placeholder="$options.i18n.valuePlaceholder"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="d-flex prepend-top-default justify-content-center">
+ <p class="text-muted" v-html="helpText"></p>
+ </div>
+ <div class="d-flex justify-content-center">
+ <gl-button variant="primary" @click="triggerManualJob(variables)">
+ {{ action.button_title }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index e9704584c9f..06477477aad 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -73,15 +73,14 @@ export default {
},
renderBlock() {
return (
- this.job.merge_request ||
this.job.duration ||
- this.job.finished_data ||
+ this.job.finished_at ||
this.job.erased_at ||
this.job.queued ||
+ this.hasTimeout ||
this.job.runner ||
this.job.coverage ||
- this.job.tags.length ||
- this.job.cancel_path
+ this.job.tags.length
);
},
hasArtifact() {
@@ -160,7 +159,7 @@ export default {
</gl-link>
</div>
- <div :class="{ block: renderBlock }">
+ <div v-if="renderBlock" class="block">
<detail-row
v-if="job.duration"
:value="duration"
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index 25132449458..add7f9b710a 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -10,15 +10,29 @@ export default () => {
JobApp,
},
render(createElement) {
+ const {
+ deploymentHelpUrl,
+ runnerHelpUrl,
+ runnerSettingsUrl,
+ variablesSettingsUrl,
+ endpoint,
+ pagePath,
+ logState,
+ buildStatus,
+ projectPath,
+ } = element.dataset;
+
return createElement('job-app', {
props: {
- deploymentHelpUrl: element.dataset.deploymentHelpUrl,
- runnerHelpUrl: element.dataset.runnerHelpUrl,
- runnerSettingsUrl: element.dataset.runnerSettingsUrl,
- endpoint: element.dataset.endpoint,
- pagePath: element.dataset.buildOptionsPagePath,
- logState: element.dataset.buildOptionsLogState,
- buildStatus: element.dataset.buildOptionsBuildStatus,
+ deploymentHelpUrl,
+ runnerHelpUrl,
+ runnerSettingsUrl,
+ variablesSettingsUrl,
+ endpoint,
+ pagePath,
+ logState,
+ buildStatus,
+ projectPath,
},
});
},
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 12d67a43599..a2daef96a2d 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -209,5 +209,19 @@ export const receiveJobsForStageError = ({ commit }) => {
flash(__('An error occurred while fetching the jobs.'));
};
+export const triggerManualJob = ({ state }, variables) => {
+ const parsedVariables = variables.map(variable => {
+ const copyVar = Object.assign({}, variable);
+ delete copyVar.id;
+ return copyVar;
+ });
+
+ axios
+ .post(state.job.status.action.path, {
+ job_variables_attributes: parsedVariables,
+ })
+ .catch(() => flash(__('An error occurred while triggering the job.')));
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 3f954b43ee3..f50a6e3b19d 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -11,7 +11,7 @@ import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
-import { isEE, isScopedLabel } from '~/lib/utils/common_utils';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -140,7 +140,7 @@ export default class LabelsSelect {
labelCount = data.labels.length;
// EE Specific
- if (isEE) {
+ if (IS_EE) {
/**
* For Scoped labels, the last label selected with the
* same key will be applied to the current issueable.
@@ -311,7 +311,8 @@ export default class LabelsSelect {
// We need to identify which items are actually labels
if (label.id) {
- selectedClass.push('label-item');
+ const selectedLayoutClasses = ['d-flex', 'flex-row', 'text-break-word'];
+ selectedClass.push('label-item', ...selectedLayoutClasses);
linkEl.dataset.labelId = label.id;
}
diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js
new file mode 100644
index 00000000000..07fb2915ca7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/color_utils.js
@@ -0,0 +1,25 @@
+/**
+ * Convert hex color to rgb array
+ *
+ * @param hex string
+ * @returns array|null
+ */
+export const hexToRgb = hex => {
+ // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
+ const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
+ const fullHex = hex.replace(shorthandRegex, (_m, r, g, b) => r + r + g + g + b + b);
+
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(fullHex);
+ return result
+ ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
+ : null;
+};
+
+export const textColorForBackground = backgroundColor => {
+ const [r, g, b] = hexToRgb(backgroundColor);
+
+ if (r + g + b > 500) {
+ return '#333333';
+ }
+ return '#FFFFFF';
+};
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index cc5e12aa467..6e8f63a10a4 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -44,6 +44,11 @@ export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
+export const getCspNonceValue = () => {
+ const metaTag = document.querySelector('meta[name=csp-nonce]');
+ return metaTag && metaTag.content;
+};
+
export const ajaxGet = url =>
axios
.get(url, {
@@ -51,7 +56,7 @@ export const ajaxGet = url =>
responseType: 'text',
})
.then(({ data }) => {
- $.globalEval(data);
+ $.globalEval(data, { nonce: getCspNonceValue() });
});
export const rstrip = val => {
@@ -727,12 +732,64 @@ export const NavigationType = {
};
/**
- * Returns the value of `gon.ee`
- * Used to check if it's the EE codebase or the CE one.
+ * Method to perform case-insensitive search for a string
+ * within multiple properties and return object containing
+ * properties in case there are multiple matches or `null`
+ * if there's no match.
*
- * @returns Boolean
+ * Eg; Suppose we want to allow user to search using for a string
+ * within `iid`, `title`, `url` or `reference` props of a target object;
+ *
+ * const objectToSearch = {
+ * "iid": 1,
+ * "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3",
+ * "url": "/groups/gitlab-org/-/epics/1",
+ * "reference": "&1",
+ * };
+ *
+ * Following is how we call searchBy and the return values it will yield;
+ *
+ * - `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our
+ * query was found within title prop we only return that.
+ * - `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`.
+ * - `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`:
+ * This will return `{ "url": ... }`.
+ * - `searchBy('foo', objectToSearch);`: This will return `null` as no property value
+ * matched with our query.
+ *
+ * You can learn more about behaviour of this method by referring to tests
+ * within `spec/javascripts/lib/utils/common_utils_spec.js`.
+ *
+ * @param {string} query String to search for
+ * @param {object} searchSpace Object containing properties to search in for `query`
*/
-export const isEE = () => window.gon && window.gon.ee;
+export const searchBy = (query = '', searchSpace = {}) => {
+ const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : [];
+
+ if (!query || !targetKeys.length) {
+ return null;
+ }
+
+ const normalizedQuery = query.toLowerCase();
+ const matches = targetKeys
+ .filter(item => {
+ const searchItem = `${searchSpace[item]}`.toLowerCase();
+
+ return (
+ searchItem.indexOf(normalizedQuery) > -1 ||
+ normalizedQuery.indexOf(searchItem) > -1 ||
+ normalizedQuery === searchItem
+ );
+ })
+ .reduce((acc, prop) => {
+ const match = acc;
+ match[prop] = searchSpace[prop];
+
+ return acc;
+ }, {});
+
+ return Object.keys(matches).length ? matches : null;
+};
/**
* Checks if the given Label has a special syntax `::` in
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 062d21ed247..a4715789337 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -2,8 +2,7 @@ import $ from 'jquery';
import _ from 'underscore';
import timeago from 'timeago.js';
import dateFormat from 'dateformat';
-import { pluralize } from './text_utility';
-import { languageCode, s__, __ } from '../../locale';
+import { languageCode, s__, __, n__ } from '../../locale';
window.timeago = timeago;
@@ -231,14 +230,10 @@ export const timeIntervalInWords = intervalInSeconds => {
const secondsInteger = parseInt(intervalInSeconds, 10);
const minutes = Math.floor(secondsInteger / 60);
const seconds = secondsInteger - minutes * 60;
- let text = '';
-
- if (minutes >= 1) {
- text = `${minutes} ${pluralize('minute', minutes)} ${seconds} ${pluralize('second', seconds)}`;
- } else {
- text = `${seconds} ${pluralize('second', seconds)}`;
- }
- return text;
+ const secondsText = n__('%d second', '%d seconds', seconds);
+ return minutes >= 1
+ ? [n__('%d minute', '%d minutes', minutes), secondsText].join(' ')
+ : secondsText;
};
export const dateInWords = (date, abbreviated = false, hideYear = false) => {
diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js
new file mode 100644
index 00000000000..106209a2f3a
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/forms.js
@@ -0,0 +1,12 @@
+export const serializeFormEntries = entries =>
+ entries.reduce((acc, { name, value }) => Object.assign(acc, { [name]: value }), {});
+
+export const serializeForm = form => {
+ const fdata = new FormData(form);
+ const entries = Array.from(fdata.keys()).map(key => {
+ const val = fdata.getAll(key);
+ return { name: key, value: val.length === 1 ? val[0] : val };
+ });
+
+ return serializeFormEntries(entries);
+};
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 37ad1676f7a..5e5d10883a3 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -19,6 +19,7 @@ const httpStatusCodes = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
+ GONE: 410,
UNPROCESSABLE_ENTITY: 422,
};
diff --git a/app/assets/javascripts/lib/utils/icons_path.js b/app/assets/javascripts/lib/utils/icons_path.js
new file mode 100644
index 00000000000..1a1c3c8e7b3
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/icons_path.js
@@ -0,0 +1,3 @@
+// any import of '@gitlab/svgs/dist/icons.svg' will be overridden with this
+// to avoid asset duplication between sprockets and webpack
+export default gon && gon.sprite_icons;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index cc1d85fd97d..d13fbeb5fc7 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -29,14 +29,6 @@ export const humanize = string =>
string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
/**
- * Adds an 's' to the end of the string when count is bigger than 0
- * @param {String} str
- * @param {Number} count
- * @returns {String}
- */
-export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' : '');
-
-/**
* Replaces underscores with dashes
* @param {*} str
* @returns {String}
@@ -44,11 +36,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' :
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Replaces whitespaces with hyphens and converts to lower case
+ * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters
* @param {String} str
* @returns {String}
*/
-export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
+export const slugify = str => {
+ const slug = str
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-zA-Z0-9_.-]+/g, '-');
+
+ return slug === '-' ? '' : slug;
+};
/**
* Replaces whitespaces with underscore and converts to lower case
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 32fd0990374..7ead9d46fbb 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -1,9 +1,15 @@
import { join as joinPaths } from 'path';
+// Returns a decoded url parameter value
+// - Treats '+' as '%20'
+function decodeUrlParameter(val) {
+ return decodeURIComponent(val.replace(/\+/g, '%20'));
+}
+
// Returns an array containing the value(s) of the
// of the key passed as an argument
-export function getParameterValues(sParam) {
- const sPageURL = decodeURIComponent(window.location.search.substring(1));
+export function getParameterValues(sParam, url = window.location) {
+ const sPageURL = decodeURIComponent(new URL(url).search.substring(1));
return sPageURL.split('&').reduce((acc, urlParam) => {
const sParameterName = urlParam.split('=');
@@ -30,7 +36,7 @@ export function mergeUrlParams(params, url) {
.forEach(part => {
if (part.length) {
const kv = part.split('=');
- merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('='));
+ merged[decodeUrlParameter(kv[0])] = decodeUrlParameter(kv.slice(1).join('='));
}
});
}
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 9f30a989295..39f2097c174 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -9,7 +9,11 @@ import './commons';
import './behaviors';
// lib/utils
-import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils';
+import {
+ handleLocationHash,
+ addSelectOnFocusBehaviour,
+ getCspNonceValue,
+} from './lib/utils/common_utils';
import { localTimeAgo } from './lib/utils/datetime_utility';
import { getLocationHash, visitUrl } from './lib/utils/url_utility';
@@ -33,10 +37,23 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import { __ } from './locale';
+import 'ee_else_ce/main_ee';
+
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
window.$ = jQuery;
+// Add nonce to jQuery script handler
+jQuery.ajaxSetup({
+ converters: {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings, func-names
+ 'text script': function(text) {
+ jQuery.globalEval(text, { nonce: getCspNonceValue() });
+ return text;
+ },
+ },
+});
+
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
$.fx.off = true;
@@ -105,6 +122,7 @@ function deferredInitialisation() {
.then(() => {
$('select.select2').select2({
width: 'resolve',
+ minimumResultsForSearch: 10,
dropdownAutoWidth: true,
});
@@ -119,11 +137,15 @@ function deferredInitialisation() {
.catch(() => {});
}
+ const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
+ const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
+
// Initialize tooltips
$body.tooltip({
selector: '.has-tooltip, [data-toggle="tooltip"]',
trigger: 'hover',
boundary: 'viewport',
+ delay,
});
// Initialize popovers
diff --git a/app/assets/javascripts/main_ee.js b/app/assets/javascripts/main_ee.js
new file mode 100644
index 00000000000..84d74775163
--- /dev/null
+++ b/app/assets/javascripts/main_ee.js
@@ -0,0 +1 @@
+// This is an empty file to satisfy ee_else_ce import for the EE main entry point
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js
index e16ddbfef7e..29a0e5a904a 100644
--- a/app/assets/javascripts/manual_ordering.js
+++ b/app/assets/javascripts/manual_ordering.js
@@ -21,7 +21,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
const initManualOrdering = () => {
const issueList = document.querySelector('.manual-ordering');
- if (!issueList || !(gon.features && gon.features.manualSorting)) {
+ if (!issueList || !(gon.current_user_id > 0)) {
return;
}
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index af2697444f2..d719fd8748d 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -17,6 +17,8 @@ export default class Members {
}
dropdownClicked(options) {
+ options.e.preventDefault();
+
this.formSubmit(null, options.$el);
}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index 81773bd140e..cac10474d06 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -8,9 +8,13 @@ import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
import { chartHeight, graphTypes, lineTypes } from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
+import { graphDataValidatorForValues } from '../../utils';
let debouncedResize;
+// TODO: Remove this component in favor of the more general time_series.vue
+// Please port all changes here to time_series.vue as well.
+
export default {
components: {
GlAreaChart,
@@ -23,19 +27,7 @@ export default {
graphData: {
type: Object,
required: true,
- validator(data) {
- return (
- Array.isArray(data.queries) &&
- data.queries.filter(query => {
- if (Array.isArray(query.result)) {
- return (
- query.result.filter(res => Array.isArray(res.values)).length === query.result.length
- );
- }
- return false;
- }).length === data.queries.length
- );
- },
+ validator: graphDataValidatorForValues.bind(null, false),
},
containerWidth: {
type: Number,
@@ -48,7 +40,18 @@ export default {
},
projectPath: {
type: String,
- required: true,
+ required: false,
+ default: () => '',
+ },
+ showBorder: {
+ type: Boolean,
+ required: false,
+ default: () => false,
+ },
+ singleEmbed: {
+ type: Boolean,
+ required: false,
+ default: false,
},
thresholds: {
type: Array,
@@ -123,7 +126,7 @@ export default {
},
},
series: this.scatterSeries,
- dataZoom: this.dataZoomConfig,
+ dataZoom: [this.dataZoomConfig],
};
},
dataZoomConfig() {
@@ -245,52 +248,57 @@ export default {
</script>
<template>
- <div class="prometheus-graph col-12 col-lg-6">
- <div class="prometheus-graph-header">
- <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
- </div>
- <gl-area-chart
- ref="areaChart"
- v-bind="$attrs"
- :data="chartData"
- :option="chartOptions"
- :format-tooltip-text="formatTooltipText"
- :thresholds="thresholds"
- :width="width"
- :height="height"
- @updated="onChartUpdated"
- >
- <template v-if="tooltip.isDeployment">
- <template slot="tooltipTitle">
- {{ __('Deployed') }}
- </template>
- <div slot="tooltipContent" class="d-flex align-items-center">
- <icon name="commit" class="mr-2" />
- <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
- </div>
- </template>
- <template v-else>
- <template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
+ <div
+ class="prometheus-graph col-12"
+ :class="[showBorder ? 'p-2' : 'p-0', { 'col-lg-6': !singleEmbed }]"
+ >
+ <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-area-chart
+ ref="areaChart"
+ v-bind="$attrs"
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :thresholds="thresholds"
+ :width="width"
+ :height="height"
+ @updated="onChartUpdated"
+ >
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
+ {{ __('Deployed') }}
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
- <template slot="tooltipContent">
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="prepend-left-32">
- {{ content.value }}
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
</div>
- </div>
+ </template>
+ <template slot="tooltipContent">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
+ </div>
+ </template>
</template>
- </template>
- </gl-area-chart>
+ </gl-area-chart>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
index 05a2036f4c3..83136d43479 100644
--- a/app/assets/javascripts/monitoring/components/charts/column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import { chartHeight } from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
+import { graphDataValidatorForValues } from '../../utils';
export default {
components: {
@@ -14,23 +15,11 @@ export default {
graphData: {
type: Object,
required: true,
- validator(data) {
- return (
- Array.isArray(data.queries) &&
- data.queries.filter(query => {
- if (Array.isArray(query.result)) {
- return (
- query.result.filter(res => Array.isArray(res.values)).length === query.result.length
- );
- }
- return false;
- }).length === data.queries.length
- );
- },
- containerWidth: {
- type: Number,
- required: true,
- },
+ validator: graphDataValidatorForValues.bind(null, false),
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
},
},
data() {
diff --git a/app/assets/javascripts/monitoring/components/charts/empty_chart.vue b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
new file mode 100644
index 00000000000..73682adc4ee
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/empty_chart.vue
@@ -0,0 +1,41 @@
+<script>
+import chartEmptyStateIllustration from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg';
+import { chartHeight } from '../../constants';
+
+export default {
+ props: {
+ graphTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ height: chartHeight,
+ };
+ },
+ computed: {
+ svgContainerStyle() {
+ return {
+ height: `${this.height}px`,
+ };
+ },
+ },
+ created() {
+ this.chartEmptyStateIllustration = chartEmptyStateIllustration;
+ },
+};
+</script>
+<template>
+ <div class="prometheus-graph col-12 col-lg-6 d-flex flex-column justify-content-center">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5>
+ </div>
+ <div
+ class="prepend-top-8 svg-w-100 d-flex align-items-center"
+ :style="svgContainerStyle"
+ v-html="chartEmptyStateIllustration"
+ ></div>
+ <h5 class="text-center prepend-top-8">{{ __('No data to display') }}</h5>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
index b03a6ca1806..7428b27a9c3 100644
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -1,5 +1,7 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { roundOffFloat } from '~/lib/utils/common_utils';
+import { graphDataValidatorForValues } from '../../utils';
export default {
components: {
@@ -7,22 +9,21 @@ export default {
},
inheritAttrs: false,
props: {
- title: {
- type: String,
- required: true,
- },
- value: {
- type: Number,
- required: true,
- },
- unit: {
- type: String,
+ graphData: {
+ type: Object,
required: true,
+ validator: graphDataValidatorForValues.bind(null, true),
},
},
computed: {
- valueWithUnit() {
- return `${this.value}${this.unit}`;
+ queryInfo() {
+ return this.graphData.queries[0];
+ },
+ engineeringNotation() {
+ return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`;
+ },
+ graphTitle() {
+ return this.queryInfo.label;
},
},
};
@@ -30,8 +31,8 @@ export default {
<template>
<div class="prometheus-graph col-12 col-lg-6">
<div class="prometheus-graph-header">
- <h5 ref="graphTitle" class="prometheus-graph-title">{{ title }}</h5>
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphTitle }}</h5>
</div>
- <gl-single-stat :value="valueWithUnit" :title="title" variant="success" />
+ <gl-single-stat :value="engineeringNotation" :title="graphTitle" variant="success" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
new file mode 100644
index 00000000000..02e7a7ba0a6
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -0,0 +1,342 @@
+<script>
+import { __ } from '~/locale';
+import { mapState } from 'vuex';
+import { GlLink, GlButton } from '@gitlab/ui';
+import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import dateFormat from 'dateformat';
+import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import Icon from '~/vue_shared/components/icon.vue';
+import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants';
+import { makeDataSeries } from '~/helpers/monitor_helper';
+import { graphDataValidatorForValues } from '../../utils';
+
+let debouncedResize;
+
+export default {
+ components: {
+ GlAreaChart,
+ GlLineChart,
+ GlButton,
+ GlChartSeriesLabel,
+ GlLink,
+ Icon,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: graphDataValidatorForValues.bind(null, false),
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ projectPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showBorder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ singleEmbed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ thresholds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ tooltip: {
+ title: '',
+ content: [],
+ commitUrl: '',
+ isDeployment: false,
+ sha: '',
+ },
+ width: 0,
+ height: chartHeight,
+ svgs: {},
+ primaryColor: null,
+ };
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['exportMetricsToCsvEnabled']),
+ chartData() {
+ // Transforms & supplements query data to render appropriate labels & styles
+ // Input: [{ queryAttributes1 }, { queryAttributes2 }]
+ // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
+ return this.graphData.queries.reduce((acc, query) => {
+ const { appearance } = query;
+ const lineType =
+ appearance && appearance.line && appearance.line.type
+ ? appearance.line.type
+ : lineTypes.default;
+ const lineWidth =
+ appearance && appearance.line && appearance.line.width
+ ? appearance.line.width
+ : undefined;
+ const areaStyle = {
+ opacity:
+ appearance && appearance.area && typeof appearance.area.opacity === 'number'
+ ? appearance.area.opacity
+ : undefined,
+ };
+
+ const series = makeDataSeries(query.result, {
+ name: this.formatLegendLabel(query),
+ lineStyle: {
+ type: lineType,
+ width: lineWidth,
+ },
+ showSymbol: false,
+ areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
+ });
+
+ return acc.concat(series);
+ }, []);
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: __('Time'),
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, dateFormats.timeOfDay),
+ },
+ axisPointer: {
+ snap: true,
+ },
+ },
+ yAxis: {
+ name: this.yAxisLabel,
+ axisLabel: {
+ formatter: num => roundOffFloat(num, 3).toString(),
+ },
+ },
+ series: this.scatterSeries,
+ dataZoom: this.dataZoomConfig,
+ };
+ },
+ dataZoomConfig() {
+ const handleIcon = this.svgs['scroll-handle'];
+
+ return handleIcon ? { handleIcon } : {};
+ },
+ earliestDatapoint() {
+ return this.chartData.reduce((acc, series) => {
+ const { data } = series;
+ const { length } = data;
+ if (!length) {
+ return acc;
+ }
+
+ const [first] = data[0];
+ const [last] = data[length - 1];
+ const seriesEarliest = first < last ? first : last;
+
+ return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
+ }, null);
+ },
+ glChartComponent() {
+ const chartTypes = {
+ 'area-chart': GlAreaChart,
+ 'line-chart': GlLineChart,
+ };
+ return chartTypes[this.graphData.type] || GlAreaChart;
+ },
+ isMultiSeries() {
+ return this.tooltip.content.length > 1;
+ },
+ recentDeployments() {
+ return this.deploymentData.reduce((acc, deployment) => {
+ if (deployment.created_at >= this.earliestDatapoint) {
+ const { id, created_at, sha, ref, tag } = deployment;
+ acc.push({
+ id,
+ createdAt: created_at,
+ sha,
+ commitUrl: `${this.projectPath}/commit/${sha}`,
+ tag,
+ tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null,
+ ref: ref.name,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return acc;
+ }, []);
+ },
+ scatterSeries() {
+ return {
+ type: graphTypes.deploymentData,
+ data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
+ symbol: this.svgs.rocket,
+ symbolSize: symbolSizes.default,
+ itemStyle: {
+ color: this.primaryColor,
+ },
+ };
+ },
+ yAxisLabel() {
+ return `${this.graphData.y_label}`;
+ },
+ csvText() {
+ const chartData = this.chartData[0].data;
+ const header = `timestamp,${this.graphData.y_label}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
+ return chartData.reduce((csv, data) => {
+ const row = data.join(',');
+ return `${csv}${row}\r\n`;
+ }, header);
+ },
+ downloadLink() {
+ const data = new Blob([this.csvText], { type: 'text/plain' });
+ return window.URL.createObjectURL(data);
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ this.setSvg('rocket');
+ this.setSvg('scroll-handle');
+ },
+ methods: {
+ formatLegendLabel(query) {
+ return `${query.label}`;
+ },
+ formatTooltipText(params) {
+ this.tooltip.title = dateFormat(params.value, dateFormats.default);
+ this.tooltip.content = [];
+ params.seriesData.forEach(dataPoint => {
+ const [xVal, yVal] = dataPoint.value;
+ this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData;
+ if (this.tooltip.isDeployment) {
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === xVal,
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ this.tooltip.commitUrl = deploy.commitUrl;
+ } else {
+ const { seriesName, color } = dataPoint;
+ const value = yVal.toFixed(3);
+ this.tooltip.content.push({
+ name: seriesName,
+ value,
+ color,
+ });
+ }
+ });
+ },
+ setSvg(name) {
+ getSvgIconPathContent(name)
+ .then(path => {
+ if (path) {
+ this.$set(this.svgs, name, `path://${path}`);
+ }
+ })
+ .catch(e => {
+ // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
+ console.error('SVG could not be rendered correctly: ', e);
+ });
+ },
+ onChartUpdated(chart) {
+ [this.primaryColor] = chart.getOption().color;
+ },
+ onResize() {
+ if (!this.$refs.chart) return;
+ const { width } = this.$refs.chart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="prometheus-graph col-12"
+ :class="[showBorder ? 'p-2' : 'p-0', { 'col-lg-6': !singleEmbed }]"
+ >
+ <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }">
+ <div class="prometheus-graph-header">
+ <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5>
+ <gl-button
+ v-if="exportMetricsToCsvEnabled"
+ :href="downloadLink"
+ :title="__('Download CSV')"
+ :aria-label="__('Download CSV')"
+ style="margin-left: 200px;"
+ download="chart_metrics.csv"
+ >
+ {{ __('Download CSV') }}
+ </gl-button>
+ <div class="prometheus-graph-widgets js-graph-widgets">
+ <slot></slot>
+ </div>
+ </div>
+
+ <component
+ :is="glChartComponent"
+ ref="chart"
+ v-bind="$attrs"
+ :data="chartData"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :thresholds="thresholds"
+ :width="width"
+ :height="height"
+ @updated="onChartUpdated"
+ >
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
+ {{ __('Deployed') }}
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
+ </div>
+ </template>
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template slot="tooltipContent">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
+ </div>
+ </template>
+ </template>
+ </component>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2cbda8ea05d..d330ceb836c 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,34 +1,46 @@
<script>
-import { GlButton, GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormGroup,
+ GlModal,
+ GlModalDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import '~/vue_shared/mixins/is_ee';
-import { getParameterValues } from '~/lib/utils/url_utility';
+import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
-import MonitorAreaChart from './charts/area.vue';
+import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import MonitorTimeSeriesChart from './charts/time_series.vue';
+import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import { timeWindows, timeWindowsKeyNames } from '../constants';
-import { getTimeDiff } from '../utils';
+import { sidebarAnimationDuration, timeWindows } from '../constants';
+import { getTimeDiff, getTimeWindow } from '../utils';
-const sidebarAnimationDuration = 150;
let sidebarMutationObserver;
export default {
components: {
- MonitorAreaChart,
+ MonitorTimeSeriesChart,
+ MonitorSingleStatChart,
+ PanelType,
GraphGroup,
EmptyState,
Icon,
GlButton,
GlDropdown,
GlDropdownItem,
+ GlFormGroup,
GlModal,
},
directives: {
- GlModalDirective,
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
},
props: {
externalDashboardUrl: {
@@ -124,6 +136,21 @@ export default {
required: false,
default: '',
},
+ smallEmptyState: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ alertsEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ prometheusAlertsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -132,6 +159,7 @@ export default {
selectedTimeWindow: '',
selectedTimeWindowKey: '',
formIsValid: null,
+ timeWindows: {},
};
},
computed: {
@@ -148,12 +176,19 @@ export default {
'useDashboardEndpoint',
'allDashboards',
'multipleDashboardsEnabled',
+ 'additionalPanelTypesEnabled',
]),
- groupsWithData() {
- return this.groups.filter(group => this.chartsWithData(group.metrics).length > 0);
+ firstDashboard() {
+ return this.allDashboards[0] || {};
},
selectedDashboardText() {
- return this.currentDashboard || (this.allDashboards[0] && this.allDashboards[0].display_name);
+ return this.currentDashboard || this.firstDashboard.display_name;
+ },
+ addingMetricsAvailable() {
+ return IS_EE && this.canAddMetrics && !this.showEmptyState;
+ },
+ alertWidgetAvailable() {
+ return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint;
},
},
created() {
@@ -163,18 +198,8 @@ export default {
deploymentsEndpoint: this.deploymentsEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
currentDashboard: this.currentDashboard,
+ projectPath: this.projectPath,
});
-
- this.timeWindows = timeWindows;
- this.selectedTimeWindowKey =
- _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours;
-
- // Set default time window if the selectedTimeWindowKey is bogus
- if (!Object.keys(this.timeWindows).includes(this.selectedTimeWindowKey)) {
- this.selectedTimeWindowKey = timeWindowsKeyNames.eightHours;
- }
-
- this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
},
beforeDestroy() {
if (sidebarMutationObserver) {
@@ -185,7 +210,20 @@ export default {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
- this.fetchData(getTimeDiff(this.selectedTimeWindow));
+ const defaultRange = getTimeDiff();
+ const start = getParameterValues('start')[0] || defaultRange.start;
+ const end = getParameterValues('end')[0] || defaultRange.end;
+
+ const range = {
+ start,
+ end,
+ };
+
+ this.timeWindows = timeWindows;
+ this.selectedTimeWindowKey = getTimeWindow(range);
+ this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
+
+ this.fetchData(range);
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
@@ -210,6 +248,21 @@ export default {
chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
+ csvText(graphData) {
+ const chartData = graphData.queries[0].result[0].values;
+ const yLabel = graphData.y_label;
+ const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
+ return chartData.reduce((csv, data) => {
+ const row = data.join(',');
+ return `${csv}${row}\r\n`;
+ }, header);
+ },
+ downloadCsv(graphData) {
+ const data = new Blob([this.csvText(graphData)], { type: 'text/plain' });
+ return window.URL.createObjectURL(data);
+ },
+ // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed
+ // Issue number: https://gitlab.com/gitlab-org/gitlab-ce/issues/63845
getGraphAlerts(queries) {
if (!this.allAlerts) return {};
const metricIdsForChart = queries.map(q => q.metricId);
@@ -218,6 +271,15 @@ export default {
getGraphAlertValues(queries) {
return Object.values(this.getGraphAlerts(queries));
},
+ showToast() {
+ this.$toast.show(__('Link copied to clipboard'));
+ },
+ // TODO: END
+ generateLink(group, title, yLabel) {
+ const dashboard = this.currentDashboard || this.firstDashboard.path;
+ const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
+ return mergeUrlParams(params, window.location.href);
+ },
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
},
@@ -236,7 +298,11 @@ export default {
return this.timeWindows[key] === this.selectedTimeWindow;
},
setTimeWindowParameter(key) {
- return `?time_window=${key}`;
+ const { start, end } = getTimeDiff(key);
+ return `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`;
+ },
+ groupHasData(group) {
+ return this.chartsWithData(group.metrics).length > 0;
},
},
addMetric: {
@@ -248,132 +314,209 @@ export default {
<template>
<div class="prometheus-graphs">
- <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between">
- <div
- v-if="environmentsEndpoint"
- class="dropdowns d-flex align-items-center justify-content-between"
- >
- <div v-if="multipleDashboardsEnabled" class="d-flex align-items-center">
- <label class="mb-0">{{ __('Dashboard') }}</label>
- <gl-dropdown
- class="ml-2 mr-3 js-dashboards-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="selectedDashboardText"
+ <div class="gl-p-3 pb-0 border-bottom bg-gray-light">
+ <div class="row">
+ <template v-if="environmentsEndpoint">
+ <gl-form-group
+ v-if="multipleDashboardsEnabled"
+ :label="__('Dashboard')"
+ label-size="sm"
+ label-for="monitor-dashboards-dropdown"
+ class="col-sm-12 col-md-4 col-lg-2"
>
- <gl-dropdown-item
- v-for="dashboard in allDashboards"
- :key="dashboard.path"
- :active="dashboard.path === currentDashboard"
- active-class="is-active"
- :href="`?dashboard=${dashboard.path}`"
+ <gl-dropdown
+ id="monitor-dashboards-dropdown"
+ class="mb-0 d-flex js-dashboards-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedDashboardText"
>
- {{ dashboard.display_name || dashboard.path }}
- </gl-dropdown-item>
- </gl-dropdown>
- </div>
- <div class="d-flex align-items-center">
- <strong>{{ s__('Metrics|Environment') }}</strong>
- <gl-dropdown
- class="prepend-left-10 js-environments-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="currentEnvironmentName"
- :disabled="environments.length === 0"
+ <gl-dropdown-item
+ v-for="dashboard in allDashboards"
+ :key="dashboard.path"
+ :active="dashboard.path === currentDashboard"
+ active-class="is-active"
+ :href="`?dashboard=${dashboard.path}`"
+ >{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </gl-form-group>
+
+ <gl-form-group
+ :label="s__('Metrics|Environment')"
+ label-size="sm"
+ label-for="monitor-environments-dropdown"
+ class="col-sm-6 col-md-4 col-lg-2"
>
- <gl-dropdown-item
- v-for="environment in environments"
- :key="environment.id"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- :href="environment.metrics_path"
- >{{ environment.name }}</gl-dropdown-item
+ <gl-dropdown
+ id="monitor-environments-dropdown"
+ class="mb-0 d-flex js-environments-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="currentEnvironmentName"
+ :disabled="environments.length === 0"
>
- </gl-dropdown>
- </div>
- <div v-if="!showEmptyState" class="d-flex align-items-center prepend-left-8">
- <strong>{{ s__('Metrics|Show last') }}</strong>
- <gl-dropdown
- class="prepend-left-10 js-time-window-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="selectedTimeWindow"
+ <gl-dropdown-item
+ v-for="environment in environments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ :href="environment.metrics_path"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </gl-form-group>
+
+ <gl-form-group
+ v-if="!showEmptyState"
+ :label="s__('Metrics|Show last')"
+ label-size="sm"
+ label-for="monitor-time-window-dropdown"
+ class="col-sm-6 col-md-4 col-lg-2"
>
- <gl-dropdown-item
- v-for="(value, key) in timeWindows"
- :key="key"
- :active="activeTimeWindow(key)"
- :href="setTimeWindowParameter(key)"
- active-class="active"
- >{{ value }}</gl-dropdown-item
+ <gl-dropdown
+ id="monitor-time-window-dropdown"
+ class="mb-0 d-flex js-time-window-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedTimeWindow"
>
- </gl-dropdown>
- </div>
- </div>
- <div class="d-flex">
- <div v-if="isEE && canAddMetrics && !showEmptyState">
- <gl-button
- v-gl-modal-directive="$options.addMetric.modalId"
- class="js-add-metric-button text-success border-success"
- >{{ $options.addMetric.title }}</gl-button
- >
- <gl-modal
- ref="addMetricModal"
- :modal-id="$options.addMetric.modalId"
- :title="$options.addMetric.title"
- >
- <form ref="customMetricsForm" :action="customMetricsPath" method="post">
- <custom-metrics-form-fields
- :validate-query-path="validateQueryPath"
- form-operation="post"
- @formValidation="setFormValidity"
- />
- </form>
- <div slot="modal-footer">
- <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button>
- <gl-button
- :disabled="!formIsValid"
- variant="success"
- @click="submitCustomMetricsForm"
- >{{ __('Save changes') }}</gl-button
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ :href="setTimeWindowParameter(key)"
+ active-class="active"
+ >{{ value }}</gl-dropdown-item
>
- </div>
- </gl-modal>
- </div>
- <gl-button
- v-if="externalDashboardUrl.length"
- class="js-external-dashboard-link prepend-left-8"
- variant="primary"
- :href="externalDashboardUrl"
- target="_blank"
+ </gl-dropdown>
+ </gl-form-group>
+ </template>
+
+ <gl-form-group
+ v-if="addingMetricsAvailable || externalDashboardUrl.length"
+ label-for="prometheus-graphs-dropdown-buttons"
+ class="dropdown-buttons col-lg d-lg-flex align-items-end"
>
- {{ __('View full dashboard') }}
- <icon name="external-link" />
- </gl-button>
+ <div id="prometheus-graphs-dropdown-buttons">
+ <gl-button
+ v-if="addingMetricsAvailable"
+ v-gl-modal="$options.addMetric.modalId"
+ class="mr-2 mt-1 js-add-metric-button text-success border-success"
+ >
+ {{ $options.addMetric.title }}
+ </gl-button>
+ <gl-modal
+ v-if="addingMetricsAvailable"
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
+ >
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button>
+ <gl-button
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </gl-modal>
+
+ <gl-button
+ v-if="externalDashboardUrl.length"
+ class="mt-1 js-external-dashboard-link"
+ variant="primary"
+ :href="externalDashboardUrl"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('View full dashboard') }}
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+ </gl-form-group>
</div>
</div>
+
<div v-if="!showEmptyState">
<graph-group
- v-for="(groupData, index) in groupsWithData"
- :key="index"
+ v-for="(groupData, index) in groups"
+ :key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
+ :collapse-group="groupHasData(groupData)"
>
- <monitor-area-chart
- v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
- :key="graphIndex"
- :project-path="projectPath"
- :graph-data="graphData"
- :deployment-data="deploymentData"
- :thresholds="getGraphAlertValues(graphData.queries)"
- :container-width="elWidth"
- group-id="monitor-area-chart"
- >
- <alert-widget
- v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
+ <template v-if="additionalPanelTypesEnabled">
+ <panel-type
+ v-for="(graphData, graphIndex) in groupData.metrics"
+ :key="`panel-type-${graphIndex}`"
+ :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
+ :graph-data="graphData"
+ :dashboard-width="elWidth"
:alerts-endpoint="alertsEndpoint"
- :relevant-queries="graphData.queries"
- :alerts-to-manage="getGraphAlerts(graphData.queries)"
- @setAlerts="setAlerts"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ :index="`${index}-${graphIndex}`"
/>
- </monitor-area-chart>
+ </template>
+ <template v-else>
+ <monitor-time-series-chart
+ v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
+ :key="graphIndex"
+ :graph-data="graphData"
+ :deployment-data="deploymentData"
+ :thresholds="getGraphAlertValues(graphData.queries)"
+ :container-width="elWidth"
+ :project-path="projectPath"
+ group-id="monitor-time-series-chart"
+ >
+ <div class="d-flex align-items-center">
+ <alert-widget
+ v-if="alertWidgetAvailable && graphData"
+ :modal-id="`alert-modal-${index}-${graphIndex}`"
+ :alerts-endpoint="alertsEndpoint"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ @setAlerts="setAlerts"
+ />
+ <gl-dropdown
+ v-gl-tooltip
+ class="mx-2"
+ toggle-class="btn btn-transparent border-0"
+ :right="true"
+ :no-caret="true"
+ :title="__('More actions')"
+ >
+ <template slot="button-content">
+ <icon name="ellipsis_v" class="text-secondary" />
+ </template>
+ <gl-dropdown-item :href="downloadCsv(graphData)" download="chart_metrics.csv">
+ {{ __('Download CSV') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ class="js-chart-link"
+ :data-clipboard-text="
+ generateLink(groupData.group, graphData.title, graphData.y_label)
+ "
+ @click="showToast"
+ >
+ {{ __('Generate link to chart') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ v-if="alertWidgetAvailable"
+ v-gl-modal="`alert-modal-${index}-${graphIndex}`"
+ >
+ {{ __('Alerts') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </monitor-time-series-chart>
+ </template>
</graph-group>
</div>
<empty-state
@@ -386,6 +529,7 @@ export default {
:empty-loading-svg-path="emptyLoadingSvgPath"
:empty-no-data-svg-path="emptyNoDataSvgPath"
:empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
+ :compact="smallEmptyState"
/>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
new file mode 100644
index 00000000000..b516a82c170
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -0,0 +1,107 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
+import GraphGroup from './graph_group.vue';
+import MonitorTimeSeriesChart from './charts/time_series.vue';
+import { sidebarAnimationDuration } from '../constants';
+import { getTimeDiff } from '../utils';
+
+let sidebarMutationObserver;
+
+export default {
+ components: {
+ GraphGroup,
+ MonitorTimeSeriesChart,
+ },
+ props: {
+ dashboardUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ const defaultRange = getTimeDiff();
+ const start = getParameterValues('start', this.dashboardUrl)[0] || defaultRange.start;
+ const end = getParameterValues('end', this.dashboardUrl)[0] || defaultRange.end;
+
+ const params = {
+ start,
+ end,
+ };
+
+ return {
+ params,
+ elWidth: 0,
+ };
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['groups', 'metricsWithData']),
+ charts() {
+ const groupWithMetrics = this.groups.find(group =>
+ group.metrics.find(chart => this.chartHasData(chart)),
+ ) || { metrics: [] };
+
+ return groupWithMetrics.metrics.filter(chart => this.chartHasData(chart));
+ },
+ isSingleChart() {
+ return this.charts.length === 1;
+ },
+ },
+ mounted() {
+ this.setInitialState();
+ this.fetchMetricsData(this.params);
+ sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
+ sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
+ attributes: true,
+ childList: false,
+ subtree: false,
+ });
+ },
+ beforeDestroy() {
+ if (sidebarMutationObserver) {
+ sidebarMutationObserver.disconnect();
+ }
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', [
+ 'fetchMetricsData',
+ 'setEndpoints',
+ 'setFeatureFlags',
+ 'setShowErrorBanner',
+ ]),
+ chartHasData(chart) {
+ return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id));
+ },
+ onSidebarMutation() {
+ setTimeout(() => {
+ this.elWidth = this.$el.clientWidth;
+ }, sidebarAnimationDuration);
+ },
+ setInitialState() {
+ this.setFeatureFlags({
+ prometheusEndpointEnabled: true,
+ });
+ this.setEndpoints({
+ dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
+ });
+ this.setShowErrorBanner(false);
+ },
+ },
+};
+</script>
+<template>
+ <div class="metrics-embed" :class="{ 'd-inline-flex col-lg-6 p-0': isSingleChart }">
+ <div v-if="charts.length" class="row w-100 m-n2 pb-4">
+ <monitor-time-series-chart
+ v-for="graphData in charts"
+ :key="graphData.title"
+ :graph-data="graphData"
+ :container-width="elWidth"
+ group-id="monitor-area-chart"
+ :project-path="null"
+ :show-border="true"
+ :single-embed="isSingleChart"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index a3c6de14aa4..1bb40447a3e 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,7 +1,11 @@
<script>
import { __ } from '~/locale';
+import { GlEmptyState } from '@gitlab/ui';
export default {
+ components: {
+ GlEmptyState,
+ },
props: {
documentationPath: {
type: String,
@@ -37,6 +41,11 @@ export default {
type: String,
required: true,
},
+ compact: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -58,6 +67,8 @@ export default {
If this takes a long time, ensure that data is available.`),
buttonText: __('View documentation'),
buttonPath: this.documentationPath,
+ secondaryButtonText: '',
+ secondaryButtonPath: '',
},
noData: {
svgUrl: this.emptyNoDataSvgPath,
@@ -66,13 +77,19 @@ export default {
no data to display.`),
buttonText: __('Configure Prometheus'),
buttonPath: this.settingsPath,
+ secondaryButtonText: '',
+ secondaryButtonPath: '',
},
unableToConnect: {
svgUrl: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'),
- description: 'Ensure connectivity is available from the GitLab server to the ',
+ description: __(
+ 'Ensure connectivity is available from the GitLab server to the Prometheus server',
+ ),
buttonText: __('View documentation'),
buttonPath: this.documentationPath,
+ secondaryButtonText: __('Configure Prometheus'),
+ secondaryButtonPath: this.settingsPath,
},
},
};
@@ -81,45 +98,19 @@ export default {
currentState() {
return this.states[this.selectedState];
},
- showButtonDescription() {
- if (this.selectedState === 'unableToConnect') return true;
- return false;
- },
},
};
</script>
<template>
- <div class="row empty-state js-empty-state">
- <div class="col-12">
- <div class="state-svg svg-content">
- <img :src="currentState.svgUrl" />
- </div>
- </div>
-
- <div class="col-12">
- <div class="text-content">
- <h4 class="state-title text-center">{{ currentState.title }}</h4>
- <p class="state-description">
- {{ currentState.description }}
- <a v-if="showButtonDescription" :href="settingsPath">{{ __('Prometheus server') }}</a>
- </p>
-
- <div class="text-center">
- <a
- v-if="currentState.buttonPath"
- :href="currentState.buttonPath"
- class="btn btn-success"
- >{{ currentState.buttonText }}</a
- >
- <a
- v-if="currentState.secondaryButtonPath"
- :href="currentState.secondaryButtonPath"
- class="btn"
- >{{ currentState.secondaryButtonText }}</a
- >
- </div>
- </div>
- </div>
- </div>
+ <gl-empty-state
+ :title="currentState.title"
+ :description="currentState.description"
+ :primary-button-text="currentState.buttonText"
+ :primary-button-link="currentState.buttonPath"
+ :secondary-button-text="currentState.secondaryButtonText"
+ :secondary-button-link="currentState.secondaryButtonPath"
+ :svg-path="currentState.svgUrl"
+ :compact="compact"
+ />
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index b20ad1802f3..0f5c5b3d60f 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -10,6 +10,10 @@ export default {
required: false,
default: true,
},
+ collapseGroup: {
+ type: Boolean,
+ required: true,
+ },
},
};
</script>
@@ -19,7 +23,7 @@ export default {
<div class="card-header">
<h4>{{ name }}</h4>
</div>
- <div class="card-body prometheus-graph-group"><slot></slot></div>
+ <div v-if="collapseGroup" class="card-body prometheus-graph-group"><slot></slot></div>
</div>
<div v-else class="prometheus-graph-group"><slot></slot></div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
new file mode 100644
index 00000000000..73ff651d510
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -0,0 +1,141 @@
+<script>
+import { mapState } from 'vuex';
+import _ from 'underscore';
+import { __ } from '~/locale';
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlModalDirective,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import MonitorTimeSeriesChart from './charts/time_series.vue';
+import MonitorSingleStatChart from './charts/single_stat.vue';
+import MonitorEmptyChart from './charts/empty_chart.vue';
+
+export default {
+ components: {
+ MonitorSingleStatChart,
+ MonitorTimeSeriesChart,
+ MonitorEmptyChart,
+ Icon,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ clipboardText: {
+ type: String,
+ required: true,
+ },
+ graphData: {
+ type: Object,
+ required: true,
+ },
+ dashboardWidth: {
+ type: Number,
+ required: true,
+ },
+ index: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['deploymentData', 'projectPath']),
+ alertWidgetAvailable() {
+ return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData;
+ },
+ graphDataHasMetrics() {
+ return this.graphData.queries[0].result.length > 0;
+ },
+ csvText() {
+ const chartData = this.graphData.queries[0].result[0].values;
+ const yLabel = this.graphData.y_label;
+ const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
+ return chartData.reduce((csv, data) => {
+ const row = data.join(',');
+ return `${csv}${row}\r\n`;
+ }, header);
+ },
+ downloadCsv() {
+ const data = new Blob([this.csvText], { type: 'text/plain' });
+ return window.URL.createObjectURL(data);
+ },
+ },
+ methods: {
+ getGraphAlerts(queries) {
+ if (!this.allAlerts) return {};
+ const metricIdsForChart = queries.map(q => q.metricId);
+ return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
+ },
+ getGraphAlertValues(queries) {
+ return Object.values(this.getGraphAlerts(queries));
+ },
+ isPanelType(type) {
+ return this.graphData.type && this.graphData.type === type;
+ },
+ showToast() {
+ this.$toast.show(__('Link copied to clipboard'));
+ },
+ },
+};
+</script>
+<template>
+ <monitor-single-stat-chart
+ v-if="isPanelType('single-stat') && graphDataHasMetrics"
+ :graph-data="graphData"
+ />
+ <monitor-time-series-chart
+ v-else-if="graphDataHasMetrics"
+ :graph-data="graphData"
+ :deployment-data="deploymentData"
+ :project-path="projectPath"
+ :thresholds="getGraphAlertValues(graphData.queries)"
+ :container-width="dashboardWidth"
+ group-id="monitor-area-chart"
+ >
+ <div class="d-flex align-items-center">
+ <alert-widget
+ v-if="alertWidgetAvailable && graphData"
+ :modal-id="`alert-modal-${index}`"
+ :alerts-endpoint="alertsEndpoint"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ @setAlerts="setAlerts"
+ />
+ <gl-dropdown
+ v-gl-tooltip
+ class="mx-2"
+ toggle-class="btn btn-transparent border-0"
+ :right="true"
+ :no-caret="true"
+ :title="__('More actions')"
+ >
+ <template slot="button-content">
+ <icon name="ellipsis_v" class="text-secondary" />
+ </template>
+ <gl-dropdown-item :href="downloadCsv" download="chart_metrics.csv">
+ {{ __('Download CSV') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
+ class="js-chart-link"
+ :data-clipboard-text="clipboardText"
+ @click="showToast"
+ >
+ {{ __('Generate link to chart') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item v-if="alertWidgetAvailable" v-gl-modal="`alert-modal-${index}`">
+ {{ __('Alerts') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ </monitor-time-series-chart>
+ <monitor-empty-chart v-else :graph-title="graphData.title" />
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 26f1bf3f68d..13aba3d9f44 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,11 +1,17 @@
import { __ } from '~/locale';
+export const sidebarAnimationDuration = 300; // milliseconds.
+
export const chartHeight = 300;
export const graphTypes = {
deploymentData: 'scatter',
};
+export const symbolSizes = {
+ default: 14,
+};
+
export const lineTypes = {
default: 'solid',
};
@@ -19,11 +25,24 @@ export const timeWindows = {
oneWeek: __('1 week'),
};
-export const timeWindowsKeyNames = {
- thirtyMinutes: 'thirtyMinutes',
- threeHours: 'threeHours',
- eightHours: 'eightHours',
- oneDay: 'oneDay',
- threeDays: 'threeDays',
- oneWeek: 'oneWeek',
+export const dateFormats = {
+ timeOfDay: 'h:MM TT',
+ default: 'dd mmm yyyy, h:MMTT',
};
+
+export const secondsIn = {
+ thirtyMinutes: 60 * 30,
+ threeHours: 60 * 60 * 3,
+ eightHours: 60 * 60 * 8,
+ oneDay: 60 * 60 * 24 * 1,
+ threeDays: 60 * 60 * 24 * 3,
+ oneWeek: 60 * 60 * 24 * 7 * 1,
+};
+
+export const timeWindowsKeyNames = Object.keys(secondsIn).reduce(
+ (otherTimeWindows, timeWindow) => ({
+ ...otherTimeWindows,
+ [timeWindow]: timeWindow,
+ }),
+ {},
+);
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 97d149e9ad5..51cef20455c 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,9 +1,12 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
import store from './stores';
+Vue.use(GlToast);
+
export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
@@ -12,6 +15,7 @@ export default (props = {}) => {
store.dispatch('monitoringDashboard/setFeatureFlags', {
prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint,
multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards,
+ additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes,
});
}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 0fa2a5d6370..0cbad179f17 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -37,10 +37,15 @@ export const setEndpoints = ({ commit }, endpoints) => {
export const setFeatureFlags = (
{ commit },
- { prometheusEndpointEnabled, multipleDashboardsEnabled },
+ { prometheusEndpointEnabled, multipleDashboardsEnabled, additionalPanelTypesEnabled },
) => {
commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled);
commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled);
+ commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
+};
+
+export const setShowErrorBanner = ({ commit }, enabled) => {
+ commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
export const requestMetricsDashboard = ({ commit }) => {
@@ -98,7 +103,9 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
})
.catch(error => {
dispatch('receiveMetricsDataFailure', error);
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ if (state.setShowErrorBanner) {
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ }
});
};
@@ -118,7 +125,9 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ if (state.setShowErrorBanner) {
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ }
});
};
@@ -142,7 +151,7 @@ function fetchPrometheusResult(prometheusEndpoint, params) {
*/
export const fetchPrometheusMetric = ({ commit }, { metric, params }) => {
const { start, end } = params;
- const timeDiff = end - start;
+ const timeDiff = (new Date(end) - new Date(start)) / 1000;
const minStep = 60;
const queryDataPoints = 600;
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 2c78a0b9315..4b1aadbcf05 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -11,7 +11,9 @@ export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED';
+export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED';
export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
+export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index a85a7723c1f..b19520d6638 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -75,6 +75,7 @@ export default {
state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
state.dashboardEndpoint = endpoints.dashboardEndpoint;
state.currentDashboard = endpoints.currentDashboard;
+ state.projectPath = endpoints.projectPath;
},
[types.SET_DASHBOARD_ENABLED](state, enabled) {
state.useDashboardEndpoint = enabled;
@@ -92,4 +93,10 @@ export default {
[types.SET_ALL_DASHBOARDS](state, dashboards) {
state.allDashboards = dashboards;
},
+ [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) {
+ state.additionalPanelTypesEnabled = enabled;
+ },
+ [types.SET_SHOW_ERROR_BANNER](state, enabled) {
+ state.showErrorBanner = enabled;
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index de711d6ccae..440bdc951e0 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -9,12 +9,15 @@ export default () => ({
dashboardEndpoint: invalidUrl,
useDashboardEndpoint: false,
multipleDashboardsEnabled: false,
+ additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
+ showErrorBanner: true,
groups: [],
deploymentData: [],
environments: [],
metricsWithData: [],
allDashboards: [],
currentDashboard: null,
+ projectPath: null,
});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 84e1f1c4c20..938ee2f0a9a 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -36,15 +36,26 @@ function removeTimeSeriesNoData(queries) {
// { metricId: 2, ...query2Attrs }] },
// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
// ]
-function groupQueriesByChartInfo(metrics) {
+export function groupQueriesByChartInfo(metrics) {
const metricsByChart = metrics.reduce((accumulator, metric) => {
const { queries, ...chart } = metric;
- const metricId = chart.id ? chart.id.toString() : null;
const chartKey = `${chart.title}|${chart.y_label}`;
accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
- queries.forEach(queryAttrs => accumulator[chartKey].queries.push({ metricId, ...queryAttrs }));
+ queries.forEach(queryAttrs => {
+ let metricId;
+
+ if (chart.id) {
+ metricId = chart.id.toString();
+ } else if (queryAttrs.metric_id) {
+ metricId = queryAttrs.metric_id.toString();
+ } else {
+ metricId = null;
+ }
+
+ accumulator[chartKey].queries.push({ metricId, ...queryAttrs });
+ });
return accumulator;
}, {});
@@ -58,13 +69,26 @@ export const sortMetrics = metrics =>
.sortBy('weight')
.value();
-export const normalizeQueryResult = timeSeries => ({
- ...timeSeries,
- values: timeSeries.values.map(([timestamp, value]) => [
- new Date(timestamp * 1000).toISOString(),
- Number(value),
- ]),
-});
+export const normalizeQueryResult = timeSeries => {
+ let normalizedResult = {};
+
+ if (timeSeries.values) {
+ normalizedResult = {
+ ...timeSeries,
+ values: timeSeries.values.map(([timestamp, value]) => [
+ new Date(timestamp * 1000).toISOString(),
+ Number(value),
+ ]),
+ };
+ } else if (timeSeries.value) {
+ normalizedResult = {
+ ...timeSeries,
+ value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])],
+ };
+ }
+
+ return normalizedResult;
+};
export const normalizeMetrics = metrics => {
const groupedMetrics = groupQueriesByChartInfo(metrics);
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index ef309c8a398..46b01f753f8 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,33 +1,46 @@
-import { timeWindows } from './constants';
+import { secondsIn, timeWindowsKeyNames } from './constants';
-/**
- * method that converts a predetermined time window to minutes
- * defaults to 8 hours as the default option
- * @param {String} timeWindow - The time window to convert to minutes
- * @returns {number} The time window in minutes
- */
-const getTimeDifferenceSeconds = timeWindow => {
- switch (timeWindow) {
- case timeWindows.thirtyMinutes:
- return 60 * 30;
- case timeWindows.threeHours:
- return 60 * 60 * 3;
- case timeWindows.oneDay:
- return 60 * 60 * 24 * 1;
- case timeWindows.threeDays:
- return 60 * 60 * 24 * 3;
- case timeWindows.oneWeek:
- return 60 * 60 * 24 * 7 * 1;
- default:
- return 60 * 60 * 8;
- }
+export const getTimeDiff = timeWindow => {
+ const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
+ const difference = secondsIn[timeWindow] || secondsIn.eightHours;
+ const start = end - difference;
+
+ return {
+ start: new Date(start * 1000).toISOString(),
+ end: new Date(end * 1000).toISOString(),
+ };
};
-export const getTimeDiff = selectedTimeWindow => {
- const end = Date.now() / 1000; // convert milliseconds to seconds
- const start = end - getTimeDifferenceSeconds(selectedTimeWindow);
+export const getTimeWindow = ({ start, end }) =>
+ Object.entries(secondsIn).reduce((acc, [timeRange, value]) => {
+ if (end - start === value) {
+ return timeRange;
+ }
+ return acc;
+ }, timeWindowsKeyNames.eightHours);
+
+/**
+ * This method is used to validate if the graph data format for a chart component
+ * that needs a time series as a response from a prometheus query (query_range) is
+ * of a valid format or not.
+ * @param {Object} graphData the graph data response from a prometheus request
+ * @returns {boolean} whether the graphData format is correct
+ */
+export const graphDataValidatorForValues = (isValues, graphData) => {
+ const responseValueKeyName = isValues ? 'value' : 'values';
- return { start, end };
+ return (
+ Array.isArray(graphData.queries) &&
+ graphData.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return (
+ query.result.filter(res => Array.isArray(res[responseValueKeyName])).length ===
+ query.result.length
+ );
+ }
+ return false;
+ }).length === graphData.queries.length
+ );
};
export default {};
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
index 842a209a545..622db360d1f 100644
--- a/app/assets/javascripts/mr_notes/init_notes.js
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import store from 'ee_else_ce/mr_notes/stores';
import notesApp from '../notes/components/notes_app.vue';
+import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue';
export default () => {
// eslint-disable-next-line no-new
@@ -56,15 +57,23 @@ export default () => {
},
},
render(createElement) {
- return createElement('notes-app', {
- props: {
- noteableData: this.noteableData,
- notesData: this.notesData,
- userData: this.currentUserData,
- shouldShow: this.activeTab === 'show',
- helpPagePath: this.helpPagePath,
- },
- });
+ const isDiffView = this.activeTab === 'diffs';
+
+ // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`,
+ // it adds a global key listener so it works on the diffs tab as well.
+ // If we create a single Vue app for all of the MR tabs, we should move this
+ // up the tree, to the root.
+ return createElement(discussionKeyboardNavigator, { props: { isDiffView } }, [
+ createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ shouldShow: this.activeTab === 'show',
+ helpPagePath: this.helpPagePath,
+ },
+ }),
+ ]);
},
});
};
diff --git a/app/assets/javascripts/mr_notes/stores/index.js b/app/assets/javascripts/mr_notes/stores/index.js
index c4225c8ec08..8fbd0291a7d 100644
--- a/app/assets/javascripts/mr_notes/stores/index.js
+++ b/app/assets/javascripts/mr_notes/stores/index.js
@@ -9,7 +9,7 @@ Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
- page: mrPageModule,
+ page: mrPageModule(),
notes: notesModule(),
diffs: diffsModule(),
},
diff --git a/app/assets/javascripts/mr_notes/stores/modules/index.js b/app/assets/javascripts/mr_notes/stores/modules/index.js
index 660081f76c8..c28e666943b 100644
--- a/app/assets/javascripts/mr_notes/stores/modules/index.js
+++ b/app/assets/javascripts/mr_notes/stores/modules/index.js
@@ -2,11 +2,11 @@ import actions from '../actions';
import getters from '../getters';
import mutations from '../mutations';
-export default {
+export default () => ({
state: {
activeTab: null,
},
actions,
getters,
mutations,
-};
+});
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
index c203cb0667c..b81600660f6 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
import Icon from '../../vue_shared/components/icon.vue';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
@@ -7,7 +8,8 @@ import query from '../queries/merge_request.query.graphql';
import { mrStates, humanMRStates } from '../constants';
export default {
- name: 'MRPopover',
+ // name: 'MRPopover' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
+ name: 'MRPopover', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
components: {
GlPopover,
GlSkeletonLoading,
@@ -102,9 +104,11 @@ export default {
<ci-icon v-if="detailedStatus" :status="detailedStatus" />
</div>
<h5 class="my-2">{{ mergeRequestTitle }}</h5>
+ <!-- eslint-disable @gitlab/vue-i18n/no-bare-strings -->
<div class="text-secondary">
{{ `${projectPath}!${mergeRequestIID}` }}
</div>
+ <!-- eslint-enable @gitlab/vue-i18n/no-bare-strings -->
</div>
</gl-popover>
</template>
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index 61eabbcb8b2..9e4a92426ee 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -43,7 +43,7 @@ renderer.paragraph = t => {
if (typeof katex !== 'undefined') {
const katexString = text
.replace(/&amp;/g, '&')
- .replace(/&=&/g, '\\space=\\space')
+ .replace(/&=&/g, '\\space=\\space') // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
.replace(/<(\/?)em>/g, '_');
const regex = new RegExp(katexRegexString, 'gi');
const matchLocation = katexString.search(regex);
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index f1130275525..842d9e8da0d 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -25,7 +25,7 @@ export default {
},
computed: {
imgSrc() {
- return `data:${this.outputType};base64,${this.rawCode}`;
+ return `data:${this.outputType};base64,${this.rawCode}`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
},
showOutput() {
return this.index === 0;
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index e7056c03e4a..4a3c1a28279 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -39,7 +39,7 @@ export default {
},
methods: {
cellType(type) {
- return `${type}-cell`;
+ return `${type}-cell`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
},
},
};
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 075c28e8d07..fda494fec07 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -13,6 +13,7 @@ import {
splitCamelCase,
slugifyWithUnderscore,
} from '../../lib/utils/text_utility';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
@@ -65,14 +66,12 @@ export default {
return this.getUserData.id;
},
commentButtonTitle() {
- return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
+ return this.noteType === constants.COMMENT ? __('Comment') : __('Start thread');
},
startDiscussionDescription() {
- let text = 'Discuss a specific suggestion or question';
- if (this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE) {
- text += ' that needs to be resolved';
- }
- return `${text}.`;
+ return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
+ ? __('Discuss a specific suggestion or question that needs to be resolved.')
+ : __('Discuss a specific suggestion or question.');
},
isOpen() {
return this.openState === constants.OPENED || this.openState === constants.REOPENED;
@@ -127,8 +126,8 @@ export default {
},
issuableTypeTitle() {
return this.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE
- ? 'merge request'
- : 'issue';
+ ? __('merge request')
+ : __('issue');
},
trackingLabel() {
return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
@@ -203,7 +202,7 @@ export default {
this.discard();
} else {
Flash(
- 'Something went wrong while adding your comment. Please try again.',
+ __('Something went wrong while adding your comment. Please try again.'),
'alert',
this.$refs.commentForm,
);
@@ -219,8 +218,9 @@ export default {
.catch(() => {
this.enableButton();
this.discard(false);
- const msg = `Your comment could not be submitted!
-Please check your network connection and try again.`;
+ const msg = __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ );
Flash(msg, 'alert', this.$el);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
@@ -235,7 +235,10 @@ Please check your network connection and try again.`;
toggleIssueState() {
if (this.isOpen) {
this.closeIssue()
- .then(() => this.enableButton())
+ .then(() => {
+ this.enableButton();
+ refreshUserMergeRequestCounts();
+ })
.catch(() => {
this.enableButton();
this.toggleStateButtonLoading(false);
@@ -248,7 +251,10 @@ Please check your network connection and try again.`;
});
} else {
this.reopenIssue()
- .then(() => this.enableButton())
+ .then(() => {
+ this.enableButton();
+ refreshUserMergeRequestCounts();
+ })
.catch(({ data }) => {
this.enableButton();
this.toggleStateButtonLoading(false);
@@ -298,7 +304,7 @@ Please check your network connection and try again.`;
const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
this.autosave = new Autosave($(this.$refs.textarea), [
- 'Note',
+ __('Note'),
noteableType,
this.getNoteableData.id,
]);
@@ -359,8 +365,8 @@ Please check your network connection and try again.`;
class="note-textarea js-vue-comment-form js-note-text
js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
data-supports-quick-actions="true"
- aria-label="Description"
- placeholder="Write a comment or drag your files here…"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
@@ -381,7 +387,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
data-track-event="click_button"
@click.prevent="handleSave()"
>
- {{ __(commentButtonTitle) }}
+ {{ commentButtonTitle }}
</button>
<button
:disabled="isSubmitButtonDisabled"
@@ -390,7 +396,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static"
data-toggle="dropdown"
- aria-label="Open comment type dropdown"
+ :aria-label="__('Open comment type dropdown')"
>
<i aria-hidden="true" class="fa fa-caret-down toggle-icon"> </i>
</button>
@@ -404,8 +410,14 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
- <strong>Comment</strong>
- <p>Add a general comment to this {{ noteableDisplayName }}.</p>
+ <strong>{{ __('Comment') }}</strong>
+ <p>
+ {{
+ sprintf(__('Add a general comment to this %{noteableDisplayName}.'), {
+ noteableDisplayName,
+ })
+ }}
+ </p>
</div>
</button>
</li>
@@ -418,7 +430,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
- <strong>Start discussion</strong>
+ <strong>{{ __('Start thread') }}</strong>
<p>{{ startDiscussionDescription }}</p>
</div>
</button>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index 54c242b2fda..df537ba1ed2 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapState, mapActions } from 'vuex';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
@@ -100,7 +101,7 @@ export default {
class="btn-link btn-link-retry btn-no-padding js-toggle-lazy-diff-retry-button"
@click="fetchDiff"
>
- Try again
+ {{ __('Try again') }}
</button>
</td>
<td v-else class="line_content js-success-lazy-load">
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 1357a5268d6..edab750b572 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -40,21 +40,26 @@ export default {
<template>
<div class="discussion-with-resolve-btn clearfix">
- <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" />
+ <reply-placeholder
+ data-qa-selector="discussion_reply_tab"
+ :button-text="s__('MergeRequests|Reply...')"
+ @onClick="$emit('showReplyForm')"
+ />
<div class="btn-group discussion-actions" role="group">
- <resolve-discussion-button
- v-if="discussion.resolvable"
- :is-resolving="isResolving"
- :button-title="resolveButtonTitle"
- @onClick="$emit('resolve')"
- />
+ <div class="btn-group">
+ <resolve-discussion-button
+ v-if="discussion.resolvable"
+ :is-resolving="isResolving"
+ :button-title="resolveButtonTitle"
+ @onClick="$emit('resolve')"
+ />
+ </div>
<resolve-with-issue-button
v-if="discussion.resolvable && resolveWithIssuePath"
:url="resolveWithIssuePath"
/>
</div>
-
<div
v-if="discussion.resolvable && shouldShowJumpToNextDiscussion"
class="btn-group discussion-actions ml-sm-2"
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index efd84f5722c..d7ffa0abb79 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -61,7 +61,7 @@ export default {
</span>
<span class="line-resolve-text">
{{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
- {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }}
+ {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }}
</span>
</div>
<div
@@ -72,7 +72,7 @@ export default {
<a
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
- :title="s__('Resolve all discussions in new issue')"
+ :title="s__('Resolve all threads in new issue')"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
>
<icon name="issue-new" />
@@ -81,7 +81,7 @@ export default {
<div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
- title="Jump to first unresolved discussion"
+ title="Jump to first unresolved thread"
class="btn btn-default discussion-next-btn"
@click="jumpToFirstUnresolvedDiscussion"
>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index eb3fbbe1385..743684e7046 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -61,7 +61,7 @@ export default {
},
methods: {
...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']),
- selectFilter(value) {
+ selectFilter(value, persistFilter = true) {
const filter = parseInt(value, 10);
// close dropdown
@@ -69,7 +69,11 @@ export default {
if (filter === this.currentValue) return;
this.currentValue = filter;
- this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter });
+ this.filterDiscussion({
+ path: this.getNotesDataByProp('discussionsPath'),
+ filter,
+ persistFilter,
+ });
this.toggleCommentsForm();
},
toggleDropdown() {
@@ -85,7 +89,7 @@ export default {
const hash = getLocationHash();
if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) {
- this.selectFilter(this.defaultValue);
+ this.selectFilter(this.defaultValue, false);
this.toggleDropdown(); // close dropdown
this.setTargetNoteHash(hash);
}
diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
new file mode 100644
index 00000000000..7fbfe8eebb2
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue
@@ -0,0 +1,51 @@
+<script>
+/* global Mousetrap */
+import 'mousetrap';
+import { mapGetters, mapActions } from 'vuex';
+import discussionNavigation from '~/notes/mixins/discussion_navigation';
+
+export default {
+ mixins: [discussionNavigation],
+ props: {
+ isDiffView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ currentDiscussionId: null,
+ };
+ },
+ computed: {
+ ...mapGetters(['nextUnresolvedDiscussionId', 'previousUnresolvedDiscussionId']),
+ },
+ mounted() {
+ Mousetrap.bind('n', () => this.jumpToNextDiscussion());
+ Mousetrap.bind('p', () => this.jumpToPreviousDiscussion());
+ },
+ beforeDestroy() {
+ Mousetrap.unbind('n');
+ Mousetrap.unbind('p');
+ },
+ methods: {
+ ...mapActions(['expandDiscussion']),
+ jumpToNextDiscussion() {
+ const nextId = this.nextUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
+
+ this.jumpToDiscussion(nextId);
+ this.currentDiscussionId = nextId;
+ },
+ jumpToPreviousDiscussion() {
+ const prevId = this.previousUnresolvedDiscussionId(this.currentDiscussionId, this.isDiffView);
+
+ this.jumpToDiscussion(prevId);
+ this.currentDiscussionId = prevId;
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 30971ad5227..0b136549c14 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,19 +1,21 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapActions } from 'vuex';
import { SYSTEM_NOTE } from '../constants';
import { __ } from '~/locale';
-import NoteableNote from './noteable_note.vue';
-import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import NoteableNote from './noteable_note.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
import NoteEditedText from './note_edited_text.vue';
+import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue';
export default {
name: 'DiscussionNotes',
components: {
ToggleRepliesWidget,
NoteEditedText,
+ DiscussionNotesRepliesWrapper,
},
props: {
discussion: {
@@ -72,6 +74,7 @@ export default {
},
},
methods: {
+ ...mapActions(['toggleDiscussion']),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -101,7 +104,7 @@ export default {
<component
:is="componentName(firstNote)"
:note="componentData(firstNote)"
- :line="line"
+ :line="line || diffLine"
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
@@ -118,23 +121,27 @@ export default {
/>
<slot slot="avatar-badge" name="avatar-badge"></slot>
</component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="!isExpanded"
- :replies="replies"
- @toggle="$emit('toggleDiscussion')"
- />
- <template v-if="isExpanded">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="line"
- @handleDeleteNote="$emit('deleteNote')"
+ <discussion-notes-replies-wrapper :is-diff-discussion="discussion.diff_discussion">
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ :class="{ 'discussion-toggle-replies': discussion.diff_discussion }"
+ @toggle="toggleDiscussion({ discussionId: discussion.id })"
/>
- </template>
+ <template v-if="isExpanded">
+ <component
+ :is="componentName(note)"
+ v-for="note in replies"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
+ @handleDeleteNote="$emit('deleteNote')"
+ />
+ </template>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
+ </discussion-notes-replies-wrapper>
</template>
<template v-else>
<component
@@ -148,8 +155,8 @@ export default {
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
</component>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</template>
</ul>
- <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
new file mode 100644
index 00000000000..2ddca56ddd5
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_notes_replies_wrapper.vue
@@ -0,0 +1,27 @@
+<script>
+/**
+ * Wrapper for discussion notes replies section.
+ *
+ * This is a functional component using the render method because in some cases
+ * the wrapper is not needed and we want to simply render along the children.
+ */
+export default {
+ functional: true,
+ props: {
+ isDiffDiscussion: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ render(h, { props, children }) {
+ if (props.isDiffDiscussion) {
+ return h('li', { class: 'discussion-collapsible bordered-box clearfix' }, [
+ h('ul', { class: 'notes' }, children),
+ ]);
+ }
+
+ return children;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index ea590905e3c..0204169214b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,6 +1,12 @@
<script>
export default {
name: 'ReplyPlaceholder',
+ props: {
+ buttonText: {
+ type: String,
+ required: true,
+ },
+ },
};
</script>
@@ -12,6 +18,6 @@ export default {
:title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')"
>
- {{ s__('MergeRequests|Reply...') }}
+ {{ buttonText }}
</button>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index e413398696a..f03e6fd73d7 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -25,7 +25,7 @@ export default {
<gl-button
v-gl-tooltip
:href="url"
- :title="s__('MergeRequests|Resolve this discussion in a new issue')"
+ :title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
>
<icon name="issue-new" />
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index 844d0c3e376..6cc873359da 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -165,7 +165,7 @@ export default {
v-gl-tooltip
type="button"
title="Edit comment"
- class="note-action-button js-note-edit btn btn-transparent"
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
@click="onEdit"
>
<icon name="pencil" css-classes="link-highlight" />
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
index be8e42af9ea..1aeb07d6608 100644
--- a/app/assets/javascripts/notes/components/note_actions/reply_button.vue
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -19,7 +19,7 @@ export default {
<gl-button
ref="button"
v-gl-tooltip
- class="note-action-button"
+ class="note-action-button js-note-action-reply"
variant="transparent"
:title="__('Reply to comment')"
@click="$emit('startReplying')"
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 941b6d5cab3..d4a57d5d58d 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -4,6 +4,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import { glEmojiTag } from '../../emoji';
+import { __, sprintf } from '~/locale';
export default {
components: {
@@ -108,23 +109,26 @@ export default {
// Add myself to the beginning of the list so title will start with You.
if (hasReactionByCurrentUser) {
- namesToShow.unshift('You');
+ namesToShow.unshift(__('You'));
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
- title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
+ title = sprintf(__(`%{listToShow}, and %{awardsListLength} more.`), {
+ listToShow: namesToShow.join(', '),
+ awardsListLength: remainingAwardList.length,
+ });
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
- title += ` and ${namesToShow.slice(-1)}`; // Append and text
+ title += sprintf(__(` and %{sliced}`), { sliced: namesToShow.slice(-1) }); // Append and text
} else {
// We have only 2 users so join them with and.
- title = namesToShow.join(' and ');
+ title = namesToShow.join(__(' and '));
}
return title;
@@ -155,7 +159,7 @@ export default {
awardName: parsedName,
};
- this.toggleAwardRequest(data).catch(() => Flash('Something went wrong on our end.'));
+ this.toggleAwardRequest(data).catch(() => Flash(__('Something went wrong on our end.')));
},
},
};
@@ -184,7 +188,7 @@ export default {
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
- aria-label="Add reaction"
+ :aria-label="__('Add reaction')"
data-boundary="viewport"
type="button"
>
diff --git a/app/assets/javascripts/notes/components/note_edited_text.vue b/app/assets/javascripts/notes/components/note_edited_text.vue
index 15ce49d7c31..1af5af5c470 100644
--- a/app/assets/javascripts/notes/components/note_edited_text.vue
+++ b/app/assets/javascripts/notes/components/note_edited_text.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 042ed196933..222badf70d1 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,14 +1,14 @@
<script>
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { mapGetters, mapActions } from 'vuex';
+import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
import eventHub from '../event_hub';
import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
-import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
export default {
name: 'NoteForm',
@@ -174,6 +174,18 @@ export default {
(this.line && this.line.can_receive_suggestion)
);
},
+ changedCommentText() {
+ return sprintf(
+ __(
+ 'This comment has changed since you started editing, please review the %{startTag}updated comment%{endTag} to ensure information is not lost.',
+ ),
+ {
+ startTag: `<a href="${this.noteHash}" target="_blank" rel="noopener noreferrer">`,
+ endTag: '</a>',
+ },
+ false,
+ );
+ },
},
watch: {
noteBody() {
@@ -228,11 +240,11 @@ export default {
<template>
<div ref="editNoteForm" class="note-edit-form current-note-edit-form js-discussion-note-form">
- <div v-if="conflictWhileEditing" class="js-conflict-edit-warning alert alert-danger">
- This comment has changed since you started editing, please review the
- <a :href="noteHash" target="_blank" rel="noopener noreferrer">updated comment</a> to ensure
- information is not lost.
- </div>
+ <div
+ v-if="conflictWhileEditing"
+ class="js-conflict-edit-warning alert alert-danger"
+ v-html="changedCommentText"
+ ></div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<issue-warning
@@ -264,8 +276,8 @@ export default {
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input"
dir="auto"
- aria-label="Description"
- placeholder="Write a comment or drag your files here…"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
@@ -281,13 +293,17 @@ export default {
<input
v-model="isUnresolving"
type="checkbox"
- class="qa-unresolve-review-discussion"
+ data-qa-selector="unresolve_review_discussion_checkbox"
/>
- {{ __('Unresolve discussion') }}
+ {{ __('Unresolve thread') }}
</template>
<template v-else>
- <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" />
- {{ __('Resolve discussion') }}
+ <input
+ v-model="isResolving"
+ type="checkbox"
+ data-qa-selector="resolve_review_discussion_checkbox"
+ />
+ {{ __('Resolve thread') }}
</template>
</label>
</p>
@@ -339,7 +355,7 @@ export default {
type="button"
@click="cancelHandler()"
>
- Cancel
+ {{ __('Cancel') }}
</button>
</template>
</div>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index fbf82fab9e9..3158e086f6c 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -70,7 +70,7 @@ export default {
@click="handleToggle"
>
<i :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
- {{ __('Toggle discussion') }}
+ {{ __('Toggle thread') }}
</button>
</div>
<a
@@ -103,7 +103,7 @@ export default {
</template>
<i
class="fa fa-spinner fa-spin editing-spinner"
- aria-label="Comment is being updated"
+ :aria-label="__('Comment is being updated')"
aria-hidden="true"
></i>
</span>
diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
index e3eb92956b1..ccfe84ab098 100644
--- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue
+++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import { __, sprintf } from '~/locale';
export default {
computed: {
@@ -10,12 +11,24 @@ export default {
signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
+ signedOutText() {
+ return sprintf(
+ __(
+ 'Please %{startTagRegister}register%{endRegisterTag} or %{startTagSignIn}sign in%{endSignInTag} to reply',
+ ),
+ {
+ startTagRegister: `<a href="${this.registerLink}">`,
+ startTagSignIn: `<a href="${this.signInLink}">`,
+ endRegisterTag: '</a>',
+ endSignInTag: '</a>',
+ },
+ false,
+ );
+ },
},
};
</script>
<template>
- <div class="disabled-comment text-center">
- Please <a :href="registerLink">register</a> or <a :href="signInLink">sign in</a> to reply
- </div>
+ <div class="disabled-comment text-center" v-html="signedOutText"></div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index b8eaff32cce..ac743d9f4b8 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -132,7 +132,7 @@ export default {
return this.discussion.diff_discussion && this.renderDiffFile;
},
shouldGroupReplies() {
- return !this.shouldRenderDiffs && !this.discussion.diff_discussion;
+ return !this.shouldRenderDiffs;
},
wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div';
@@ -144,15 +144,6 @@ export default {
return {};
},
- componentClassName() {
- if (this.shouldRenderDiffs) {
- if (!this.lastUpdatedAt && !this.discussion.resolved) {
- return 'unresolved';
- }
- }
-
- return '';
- },
isExpanded() {
return this.discussion.expanded || this.alwaysExpanded;
},
@@ -174,22 +165,20 @@ export default {
active: isActive,
} = this.discussion;
- let text = s__('MergeRequests|started a discussion');
+ let text = s__('MergeRequests|started a thread');
if (isForCommit) {
- text = s__(
- 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}',
- );
+ text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}');
} else if (isDiffDiscussion && commitId) {
text = isActive
- ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}')
+ ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}')
: s__(
- 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
+ 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
);
} else if (isDiffDiscussion) {
text = isActive
- ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}')
+ ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
: s__(
- 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}',
+ 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
);
}
@@ -250,6 +239,11 @@ export default {
clearDraft(this.autosaveKey);
},
saveReply(noteText, form, callback) {
+ if (!noteText) {
+ this.cancelReplyForm();
+ callback();
+ return;
+ }
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
@@ -280,8 +274,9 @@ export default {
this.removePlaceholderNotes();
this.isReplying = true;
this.$nextTick(() => {
- const msg = `Your comment could not be submitted!
-Please check your network connection and try again.`;
+ const msg = __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ );
Flash(msg, 'alert', this.$el);
this.$refs.noteForm.note = noteText;
callback(err);
@@ -309,11 +304,11 @@ Please check your network connection and try again.`;
</script>
<template>
- <timeline-entry-item class="note note-discussion" :class="componentClassName">
+ <timeline-entry-item class="note note-discussion">
<div class="timeline-content">
<div :data-discussion-id="discussion.id" class="discussion js-discussion-container">
<div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
- <div v-once class="timeline-icon">
+ <div v-once class="timeline-icon align-self-start flex-shrink-0">
<user-avatar-link
v-if="author"
:link-href="author.path"
@@ -322,7 +317,7 @@ Please check your network connection and try again.`;
:img-size="40"
/>
</div>
- <div class="timeline-content">
+ <div class="timeline-content w-100">
<note-header
:author="author"
:created-at="firstNote.created_at"
@@ -363,7 +358,6 @@ Please check your network connection and try again.`;
:line="line"
:should-group-replies="shouldGroupReplies"
@startReplying="showReplyForm"
- @toggleDiscussion="toggleDiscussionHandler"
@deleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
@@ -376,7 +370,7 @@ Please check your network connection and try again.`;
<div
v-else-if="showReplies"
:class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
+ class="discussion-reply-holder clearfix"
>
<user-avatar-link
v-if="!isReplying && userCanReply"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index aa80e25a3e0..9019f0542b6 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -5,7 +5,7 @@ import { escape } from 'underscore';
import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import draftMixin from 'ee_else_ce/notes/mixins/draft';
-import { s__, sprintf } from '../../locale';
+import { __, s__, sprintf } from '../../locale';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue';
@@ -14,6 +14,7 @@ import NoteBody from './note_body.vue';
import eventHub from '../event_hub';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
+import httpStatusCodes from '~/lib/utils/http_status';
export default {
name: 'NoteableNote',
@@ -122,15 +123,25 @@ export default {
},
methods: {
- ...mapActions(['deleteNote', 'updateNote', 'toggleResolveNote', 'scrollToNoteIfNeeded']),
+ ...mapActions([
+ 'deleteNote',
+ 'removeNote',
+ 'updateNote',
+ 'toggleResolveNote',
+ 'scrollToNoteIfNeeded',
+ ]),
editHandler() {
this.isEditing = true;
this.$emit('handleEdit');
},
deleteHandler() {
- const typeOfComment = this.note.isDraft ? 'pending comment' : 'comment';
- // eslint-disable-next-line no-alert
- if (window.confirm(`Are you sure you want to delete this ${typeOfComment}?`)) {
+ const typeOfComment = this.note.isDraft ? __('pending comment') : __('comment');
+ if (
+ // eslint-disable-next-line no-alert
+ window.confirm(
+ sprintf(__('Are you sure you want to delete this %{typeOfComment}?'), { typeOfComment }),
+ )
+ ) {
this.isDeleting = true;
this.$emit('handleDeleteNote', this.note);
@@ -141,7 +152,7 @@ export default {
this.isDeleting = false;
})
.catch(() => {
- Flash('Something went wrong while deleting your note. Please try again.');
+ Flash(__('Something went wrong while deleting your note. Please try again.'));
this.isDeleting = false;
});
}
@@ -181,21 +192,27 @@ export default {
this.updateSuccess();
callback();
})
- .catch(() => {
- this.isRequesting = false;
- this.isEditing = true;
- this.$nextTick(() => {
- const msg = 'Something went wrong while editing your comment. Please try again.';
- Flash(msg, 'alert', this.$el);
- this.recoverNoteContent(noteText);
+ .catch(response => {
+ if (response.status === httpStatusCodes.GONE) {
+ this.removeNote(this.note);
+ this.updateSuccess();
callback();
- });
+ } else {
+ this.isRequesting = false;
+ this.isEditing = true;
+ this.$nextTick(() => {
+ const msg = __('Something went wrong while editing your comment. Please try again.');
+ Flash(msg, 'alert', this.$el);
+ this.recoverNoteContent(noteText);
+ callback();
+ });
+ }
});
},
formCancelHandler(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
// eslint-disable-next-line no-alert
- if (!window.confirm('Are you sure you want to cancel editing this comment?')) return;
+ if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 4d00e957973..a0695f9e191 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import { mapGetters, mapActions } from 'vuex';
import { getLocationHash } from '../../lib/utils/url_utility';
import Flash from '../../flash';
@@ -170,7 +171,7 @@ export default {
.catch(() => {
this.setLoadingState(false);
this.setNotesFetchedState(true);
- Flash('Something went wrong while fetching comments. Please try again.');
+ Flash(__('Something went wrong while fetching comments. Please try again.'));
});
},
initPolling() {
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 57dd1c5cab2..c70c0e4095c 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,5 +1,4 @@
import Vue from 'vue';
-import { isEE } from '~/lib/utils/common_utils';
import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
@@ -41,9 +40,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
},
mounted() {
- if (isEE) {
- initNoteStats();
- }
+ initNoteStats();
},
render(createElement) {
return createElement('notes-app', {
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 2329727bca2..16b7598ee09 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -20,13 +20,13 @@ export default {
resolveButtonTitle() {
if (this.updatedNoteBody) {
if (this.discussionResolved) {
- return __('Comment & unresolve discussion');
+ return __('Comment & unresolve thread');
}
- return __('Comment & resolve discussion');
+ return __('Comment & resolve thread');
}
- return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion');
+ return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread');
},
},
methods: {
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index 237e70c0a4c..3d239d8cb6b 100644
--- a/app/assets/javascripts/notes/services/notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,18 +1,17 @@
import Vue from 'vue';
-import Api from '~/api';
import VueResource from 'vue-resource';
import * as constants from '../constants';
Vue.use(VueResource);
export default {
- fetchDiscussions(endpoint, filter) {
- const config = filter !== undefined ? { params: { notes_filter: filter } } : null;
+ fetchDiscussions(endpoint, filter, persistFilter = true) {
+ const config =
+ filter !== undefined
+ ? { params: { notes_filter: filter, persist_filter: persistFilter } }
+ : null;
return Vue.http.get(endpoint, config);
},
- deleteNote(endpoint) {
- return Vue.http.delete(endpoint);
- },
replyToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
@@ -39,13 +38,7 @@ export default {
return Vue.http.get(endpoint, options);
},
- toggleAward(endpoint, data) {
- return Vue.http.post(endpoint, data, { emulateJSON: true });
- },
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
},
- applySuggestion(id) {
- return Api.applySuggestion(id);
- },
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 63658d49a05..411bd585672 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -14,6 +14,7 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale';
+import Api from '~/api';
let eTagPoll;
@@ -45,13 +46,13 @@ export const setNotesFetchedState = ({ commit }, state) =>
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
-export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) =>
+export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) =>
service
- .fetchDiscussions(path, filter)
+ .fetchDiscussions(path, filter, persistFilter)
.then(res => res.json())
.then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
});
export const updateDiscussion = ({ commit, state }, discussion) => {
@@ -60,18 +61,22 @@ export const updateDiscussion = ({ commit, state }, discussion) => {
return utils.findNoteObjectById(state.discussions, discussion.id);
};
-export const deleteNote = ({ commit, dispatch, state }, note) =>
- service.deleteNote(note.path).then(() => {
- const discussion = state.discussions.find(({ id }) => id === note.discussion_id);
+export const removeNote = ({ commit, dispatch, state }, note) => {
+ const discussion = state.discussions.find(({ id }) => id === note.discussion_id);
- commit(types.DELETE_NOTE, note);
+ commit(types.DELETE_NOTE, note);
- dispatch('updateMergeRequestWidget');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateMergeRequestWidget');
+ dispatch('updateResolvableDiscussionsCounts');
- if (isInMRPage()) {
- dispatch('diffs/removeDiscussionsFromDiff', discussion);
- }
+ if (isInMRPage()) {
+ dispatch('diffs/removeDiscussionsFromDiff', discussion);
+ }
+};
+
+export const deleteNote = ({ dispatch }, note) =>
+ axios.delete(note.path).then(() => {
+ dispatch('removeNote', note);
});
export const updateNote = ({ commit, dispatch }, { endpoint, note }) =>
@@ -117,7 +122,7 @@ export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoi
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
} else {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
}
@@ -135,7 +140,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
}
return res;
});
@@ -168,7 +173,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
commit(mutationType, res);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
dispatch('updateMergeRequestWidget');
});
@@ -356,11 +361,11 @@ export const poll = ({ commit, state, getters, dispatch }) => {
};
export const stopPolling = () => {
- eTagPoll.stop();
+ if (eTagPoll) eTagPoll.stop();
};
export const restartPolling = () => {
- eTagPoll.restart();
+ if (eTagPoll) eTagPoll.restart();
};
export const fetchData = ({ commit, state, getters }) => {
@@ -383,12 +388,9 @@ export const toggleAward = ({ commit, getters }, { awardName, noteId }) => {
export const toggleAwardRequest = ({ dispatch }, data) => {
const { endpoint, awardName } = data;
- return service
- .toggleAward(endpoint, { name: awardName })
- .then(res => res.json())
- .then(() => {
- dispatch('toggleAward', data);
- });
+ return axios.post(endpoint, { name: awardName }).then(() => {
+ dispatch('toggleAward', data);
+ });
};
export const scrollToNoteIfNeeded = (context, el) => {
@@ -413,9 +415,9 @@ export const setLoadingState = ({ commit }, data) => {
commit(types.SET_NOTES_LOADING_STATE, data);
};
-export const filterDiscussion = ({ dispatch }, { path, filter }) => {
+export const filterDiscussion = ({ dispatch }, { path, filter, persistFilter }) => {
dispatch('setLoadingState', true);
- dispatch('fetchDiscussions', { path, filter })
+ dispatch('fetchDiscussions', { path, filter, persistFilter })
.then(() => {
dispatch('setLoadingState', false);
dispatch('setNotesFetchedState', true);
@@ -442,15 +444,14 @@ export const startTaskList = ({ dispatch }) =>
}),
);
-export const updateResolvableDiscussonsCounts = ({ commit }) =>
+export const updateResolvableDiscussionsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
{ commit, dispatch },
{ discussionId, noteId, suggestionId, flashContainer },
) =>
- service
- .applySuggestion(suggestionId)
+ Api.applySuggestion(suggestionId)
.then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
.then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
.catch(err => {
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 8aa8f5037b3..3d0ec8cd3a7 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -171,17 +171,33 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif
return lastDiscussionId === discussionId;
};
+export const findUnresolvedDiscussionIdNeighbor = (state, getters) => ({
+ discussionId,
+ diffOrder,
+ step,
+}) => {
+ const ids = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
+ const index = ids.indexOf(discussionId) + step;
+
+ if (index < 0 && step < 0) {
+ return ids[ids.length - 1];
+ }
+
+ if (index === ids.length && step > 0) {
+ return ids[0];
+ }
+
+ return ids[index];
+};
+
// Gets the ID of the discussion following the one provided, respecting order (diff or date)
// @param {Boolean} discussionId - id of the current discussion
// @param {Boolean} diffOrder - is ordered by diff?
-export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => {
- const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder);
- const currentIndex = idsOrdered.indexOf(discussionId);
- const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2);
+export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) =>
+ getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: 1 });
- // Get the first ID if there is none after the currentIndex
- return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0];
-};
+export const previousUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) =>
+ getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: -1 });
// @param {Boolean} diffOrder - is ordered by diff?
export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index ed4cef4a917..97dcd54fe88 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -21,7 +21,7 @@ export const getQuickActionText = note => {
text = __('Applying multiple commands');
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
- text = sprintf(__('Applying command to %{commandDescription}', { commandDescription }));
+ text = sprintf(__('Applying command to %{commandDescription}'), { commandDescription });
}
}
diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
index ed518611d0b..e90e27a402a 100644
--- a/app/assets/javascripts/operation_settings/components/external_dashboard.vue
+++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
@@ -50,13 +50,18 @@ export default {
<form>
<gl-form-group
:label="s__('ExternalMetrics|Full dashboard URL')"
+ label-for="full-dashboard-url"
:description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')"
>
+ <!-- placeholder with a url is a false positive -->
+ <!-- eslint-disable @gitlab/vue-i18n/no-bare-attribute-strings -->
<gl-form-input
+ id="full-dashboard-url"
v-model="userDashboardUrl"
placeholder="https://my-org.gitlab.io/my-dashboards"
@keydown.enter.native.prevent="updateExternalDashboardUrl"
/>
+ <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings -->
</gl-form-group>
<gl-button variant="success" @click="updateExternalDashboardUrl">
{{ __('Save Changes') }}
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index 6e00e31b828..7a6a486f551 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -3,26 +3,31 @@ import _ from 'underscore';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
+import { textColorForBackground } from '~/lib/utils/color_utils';
export default () => {
- $('input#broadcast_message_color').on('input', function onMessageColorInput() {
+ const $broadcastMessageColor = $('input#broadcast_message_color');
+ const $broadcastMessagePreview = $('div.broadcast-message-preview');
+ $broadcastMessageColor.on('input', function onMessageColorInput() {
const previewColor = $(this).val();
- $('div.broadcast-message-preview').css('background-color', previewColor);
+ $broadcastMessagePreview.css('background-color', previewColor);
});
$('input#broadcast_message_font').on('input', function onMessageFontInput() {
const previewColor = $(this).val();
- $('div.broadcast-message-preview').css('color', previewColor);
+ $broadcastMessagePreview.css('color', previewColor);
});
- const previewPath = $('textarea#broadcast_message_message').data('previewPath');
+ const $broadcastMessage = $('textarea#broadcast_message_message');
+ const previewPath = $broadcastMessage.data('previewPath');
+ const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview');
- $('textarea#broadcast_message_message').on(
+ $broadcastMessage.on(
'input',
_.debounce(function onMessageInput() {
const message = $(this).val();
if (message === '') {
- $('.js-broadcast-message-preview').text(__('Your message here'));
+ $jsBroadcastMessagePreview.text(__('Your message here'));
} else {
axios
.post(previewPath, {
@@ -31,10 +36,40 @@ export default () => {
},
})
.then(({ data }) => {
- $('.js-broadcast-message-preview').html(data.message);
+ $jsBroadcastMessagePreview.html(data.message);
})
.catch(() => flash(__('An error occurred while rendering preview broadcast message')));
}
}, 250),
);
+
+ const updateColorPreview = () => {
+ const selectedBackgroundColor = $broadcastMessageColor.val();
+ const contrastTextColor = textColorForBackground(selectedBackgroundColor);
+
+ // save contrastTextColor to hidden input field
+ $('input.text-font-color').val(contrastTextColor);
+
+ // Updates the preview color with the hex-color input
+ const selectedColorStyle = {
+ backgroundColor: selectedBackgroundColor,
+ color: contrastTextColor,
+ };
+
+ $('.label-color-preview').css(selectedColorStyle);
+
+ return $broadcastMessagePreview.css(selectedColorStyle);
+ };
+
+ const setSuggestedColor = e => {
+ const color = $(e.currentTarget).data('color');
+ $broadcastMessageColor
+ .val(color)
+ // Notify the form, that color has changed
+ .trigger('input');
+ updateColorPreview();
+ return e.preventDefault();
+ };
+
+ $(document).on('click', '.suggest-colors a', setSuggestedColor);
};
diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js
index d0c9ae66c6a..43992938d07 100644
--- a/app/assets/javascripts/pages/admin/clusters/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/index.js
@@ -1,5 +1,5 @@
import PersistentUserCallout from '~/persistent_user_callout';
-import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+import initGkeDropdowns from '~/create_cluster/gke_cluster';
function initGcpSignupCallout() {
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
index 1b56b97f751..d51d411f3c6 100644
--- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js
+++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js
@@ -82,7 +82,7 @@ export default class Todos {
})
.catch(() => {
this.updateRowState(target, true);
- return flash(__('Error updating todo status.'));
+ return flash(__('Error updating status of to-do item.'));
});
}
@@ -124,7 +124,7 @@ export default class Todos {
this.updateAllState(target, data);
this.updateBadges(data);
})
- .catch(() => flash(__('Error updating status for all todos.')));
+ .catch(() => flash(__('Error updating status for all to-do items.')));
}
updateAllState(target, data) {
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js
index 451be6497de..a33d242908b 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/index.js
@@ -1,5 +1,5 @@
import PersistentUserCallout from '~/persistent_user_callout';
-import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+import initGkeDropdowns from '~/create_cluster/gke_cluster';
function initGcpSignupCallout() {
const callout = document.querySelector('.gcp-signup-offer');
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 23fb5656008..dcdee77a8ab 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,11 +1,15 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
+import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import { FILTERED_SEARCH } from '~/pages/constants';
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import initManualOrdering from '~/manual_ordering';
+const ISSUE_BULK_UPDATE_PREFIX = 'issue_';
+
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+ issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 12a26fd88fa..7520cfb6da0 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,11 +1,15 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
+import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import { FILTERED_SEARCH } from '~/pages/constants';
+const ISSUABLE_BULK_UPDATE_PREFIX = 'merge_request_';
+
document.addEventListener('DOMContentLoaded', () => {
addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
+ issuableInitBulkUpdateSidebar.init(ISSUABLE_BULK_UPDATE_PREFIX);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 29de3b7806c..37e8c75f299 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -5,5 +5,5 @@ import initDiverganceGraph from '~/branches/divergence_graph';
document.addEventListener('DOMContentLoaded', () => {
AjaxLoadingSpinner.init();
new DeleteModal(); // eslint-disable-line no-new
- initDiverganceGraph();
+ initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
});
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
index 8001d2dd1da..f091c01fc98 100644
--- a/app/assets/javascripts/pages/projects/clusters/show/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -1,5 +1,7 @@
import ClustersBundle from '~/clusters/clusters_bundle';
+import initGkeNamespace from '~/projects/gke_cluster_namespace';
document.addEventListener('DOMContentLoaded', () => {
new ClustersBundle(); // eslint-disable-line no-new
+ initGkeNamespace();
});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index d4bd02c14e9..196798a9076 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,4 +1,5 @@
-import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+import initGkeDropdowns from '~/create_cluster/gke_cluster';
+import initGkeNamespace from '~/projects/gke_cluster_namespace';
import PersistentUserCallout from '../../persistent_user_callout';
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
@@ -16,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => {
PersistentUserCallout.factory(callout);
initGkeDropdowns();
+ initGkeNamespace();
}
new Project(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index 941c4552579..2205a7bafe3 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -17,7 +17,5 @@ export default () => {
new MilestoneSelect();
new IssuableTemplateSelectors();
- if (gon.features.graphql) {
- initSuggestions();
- }
+ initSuggestions();
};
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index f0d529758d5..332b6811af6 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -1,9 +1,10 @@
-/* eslint-disable func-names, no-var, no-return-assign, one-var, object-shorthand, vars-on-top */
+/* eslint-disable func-names, no-var, no-return-assign, object-shorthand, vars-on-top */
import $ from 'jquery';
import Cookies from 'js-cookie';
import { __ } from '~/locale';
-import { visitUrl } from '~/lib/utils/url_utility';
+import { visitUrl, mergeUrlParams } from '~/lib/utils/url_utility';
+import { serializeForm } from '~/lib/utils/forms';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import projectSelect from '../../project_select';
@@ -107,9 +108,10 @@ export default class Project {
refLink.href = '#';
return $('.js-project-refs-dropdown').each(function() {
- var $dropdown, selected;
- $dropdown = $(this);
- selected = $dropdown.data('selected');
+ var $dropdown = $(this);
+ var selected = $dropdown.data('selected');
+ var fieldName = $dropdown.data('fieldName');
+ var shouldVisit = Boolean($dropdown.data('visit'));
return $dropdown.glDropdown({
data(term, callback) {
axios
@@ -127,7 +129,7 @@ export default class Project {
filterRemote: true,
filterByText: true,
inputFieldName: $dropdown.data('inputFieldName'),
- fieldName: $dropdown.data('fieldName'),
+ fieldName,
renderRow: function(ref) {
var li = refListItem.cloneNode(false);
@@ -158,15 +160,12 @@ export default class Project {
clicked: function(options) {
const { e } = options;
e.preventDefault();
- if ($('input[name="ref"]').length) {
+ if ($(`input[name="${fieldName}"]`).length) {
var $form = $dropdown.closest('form');
-
- var $visit = $dropdown.data('visit');
- var shouldVisit = $visit ? true : $visit;
var action = $form.attr('action');
- var divider = action.indexOf('?') === -1 ? '?' : '&';
+
if (shouldVisit) {
- visitUrl(`${action}${divider}${$form.serialize()}`);
+ visitUrl(mergeUrlParams(serializeForm($form[0]), action));
}
}
},
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index ff6dadeff7d..533065b2d4d 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -1,5 +1,6 @@
<script>
-import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
+import { featureAccessLevelNone } from '../constants';
export default {
components: {
@@ -43,7 +44,7 @@ export default {
if (this.featureEnabled) {
return this.options;
}
- return [[0, 'Enable feature to choose access level']];
+ return [featureAccessLevelNone];
},
displaySelectInput() {
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 0bcfb740469..a223a8f5b08 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -1,11 +1,20 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
+import { __ } from '~/locale';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
-import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
+import {
+ visibilityOptions,
+ visibilityLevelDescriptions,
+ featureAccessLevelMembers,
+ featureAccessLevelEveryone,
+} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
+const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone');
+
export default {
components: {
projectFeatureSetting,
@@ -19,6 +28,11 @@ export default {
type: Object,
required: true,
},
+ canDisableEmails: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
canChangeVisibilityLevel: {
type: Boolean,
required: false,
@@ -95,6 +109,7 @@ export default {
lfsEnabled: true,
requestAccessEnabled: true,
highlightChangesClass: false,
+ emailsDisabled: false,
};
return { ...defaults, ...this.currentSettings };
@@ -102,9 +117,9 @@ export default {
computed: {
featureAccessLevelOptions() {
- const options = [[10, 'Only Project Members']];
+ const options = [featureAccessLevelMembers];
if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
- options.push([20, 'Everyone With Access']);
+ options.push(featureAccessLevelEveryone);
}
return options;
},
@@ -117,7 +132,7 @@ export default {
pagesFeatureAccessLevelOptions() {
if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
- return this.featureAccessLevelOptions.concat([[30, 'Everyone']]);
+ return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]);
}
return this.featureAccessLevelOptions;
},
@@ -200,17 +215,17 @@ export default {
<option
:value="visibilityOptions.PRIVATE"
:disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
- >Private</option
+ >{{ __('Private') }}</option
>
<option
:value="visibilityOptions.INTERNAL"
:disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
- >Internal</option
+ >{{ __('Internal') }}</option
>
<option
:value="visibilityOptions.PUBLIC"
:disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
- >Public</option
+ >{{ __('Public') }}</option
>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
@@ -332,5 +347,14 @@ export default {
/>
</project-setting-row>
</div>
+ <project-setting-row v-if="canDisableEmails" class="mb-3">
+ <label class="js-emails-disabled">
+ <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
+ <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }}
+ </label>
+ <span class="form-text text-muted">{{
+ __('This setting will override user notification preferences for all project members.')
+ }}</span>
+ </project-setting-row>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index ac0dca31c37..73269c6f3ba 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -15,3 +15,30 @@ export const visibilityLevelDescriptions = {
'The project can be accessed by anyone, regardless of authentication.',
),
};
+
+const featureAccessLevel = {
+ NOT_ENABLED: 0,
+ PROJECT_MEMBERS: 10,
+ EVERYONE: 20,
+};
+
+const featureAccessLevelDescriptions = {
+ [featureAccessLevel.NOT_ENABLED]: __('Enable feature to choose access level'),
+ [featureAccessLevel.PROJECT_MEMBERS]: __('Only Project Members'),
+ [featureAccessLevel.EVERYONE]: __('Everyone With Access'),
+};
+
+export const featureAccessLevelNone = [
+ featureAccessLevel.NOT_ENABLED,
+ featureAccessLevelDescriptions[featureAccessLevel.NOT_ENABLED],
+];
+
+export const featureAccessLevelMembers = [
+ featureAccessLevel.PROJECT_MEMBERS,
+ featureAccessLevelDescriptions[featureAccessLevel.PROJECT_MEMBERS],
+];
+
+export const featureAccessLevelEveryone = [
+ featureAccessLevel.EVERYONE,
+ featureAccessLevelDescriptions[featureAccessLevel.EVERYONE],
+];
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index 9b58d42b47d..d41199f6374 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -1,6 +1,5 @@
import bp from '../../../breakpoints';
-import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils';
-import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility';
+import { s__, sprintf } from '~/locale';
export default class Wikis {
constructor() {
@@ -12,32 +11,37 @@ export default class Wikis {
sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e));
}
- this.newWikiForm = document.querySelector('form.new-wiki-page');
- if (this.newWikiForm) {
- this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e));
+ this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page'));
+ this.editTitleInput = document.querySelector('form.wiki-form #wiki_title');
+ this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message');
+ this.commitMessageI18n = this.isNewWikiPage
+ ? s__('WikiPageCreate|Create %{pageTitle}')
+ : s__('WikiPageEdit|Update %{pageTitle}');
+
+ if (this.editTitleInput) {
+ // Initialize the commit message on load
+ if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value);
+
+ // Set the commit message as the page title is changed
+ this.editTitleInput.addEventListener('keyup', e => this.handleWikiTitleChange(e));
}
window.addEventListener('resize', () => this.renderSidebar());
this.renderSidebar();
}
- handleNewWikiSubmit(e) {
- if (!this.newWikiForm) return;
-
- const slugInput = this.newWikiForm.querySelector('#new_wiki_path');
-
- const slug = slugInput.value;
+ handleWikiTitleChange(e) {
+ this.setWikiCommitMessage(e.target.value);
+ }
- if (slug.length > 0) {
- const wikisPath = slugInput.getAttribute('data-wikis-path');
+ setWikiCommitMessage(rawTitle) {
+ let title = rawTitle;
- // If the wiki is empty, we need to merge the current URL params to keep the "create" view.
- const params = parseQueryStringIntoObject(window.location.search.substr(1));
- const url = mergeUrlParams(params, `${wikisPath}/${slug}`);
- redirectTo(url);
+ // Replace hyphens with spaces
+ if (title) title = title.replace(/-+/g, ' ');
- e.preventDefault();
- }
+ const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title });
+ this.commitMessageInput.value = newCommitMessage;
}
handleToggleSidebar(e) {
diff --git a/app/assets/javascripts/pages/search/show/refresh_counts.js b/app/assets/javascripts/pages/search/show/refresh_counts.js
new file mode 100644
index 00000000000..fa75ee6075d
--- /dev/null
+++ b/app/assets/javascripts/pages/search/show/refresh_counts.js
@@ -0,0 +1,24 @@
+import axios from '~/lib/utils/axios_utils';
+
+function showCount(el, count) {
+ el.textContent = count;
+ el.classList.remove('hidden');
+}
+
+function refreshCount(el) {
+ const { url } = el.dataset;
+
+ return axios
+ .get(url)
+ .then(({ data }) => showCount(el, data.count))
+ .catch(e => {
+ // eslint-disable-next-line no-console
+ console.error(`Failed to fetch search count from '${url}'.`, e);
+ });
+}
+
+export default function refreshCounts() {
+ const elements = Array.from(document.querySelectorAll('.js-search-count'));
+
+ return Promise.all(elements.map(refreshCount));
+}
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index d5a8e712d6b..86ec78e1df8 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -2,6 +2,8 @@ import $ from 'jquery';
import Flash from '~/flash';
import Api from '~/api';
import { __ } from '~/locale';
+import Project from '~/pages/projects/project';
+import refreshCounts from './refresh_counts';
export default class Search {
constructor() {
@@ -13,6 +15,7 @@ export default class Search {
this.groupId = $groupDropdown.data('groupId');
this.eventListeners();
+ refreshCounts();
$groupDropdown.glDropdown({
selectable: true,
@@ -37,9 +40,6 @@ export default class Search {
text(obj) {
return obj.full_name;
},
- toggleLabel(obj) {
- return `${$groupDropdown.data('defaultLabel')} ${obj.full_name}`;
- },
clicked: () => Search.submitSearch(),
});
@@ -70,11 +70,10 @@ export default class Search {
text(obj) {
return obj.name_with_namespace;
},
- toggleLabel(obj) {
- return `${$projectDropdown.data('defaultLabel')} ${obj.name_with_namespace}`;
- },
clicked: () => Search.submitSearch(),
});
+
+ Project.initRefSwitcher();
}
eventListeners() {
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 1d8b388e935..4ac4efec45d 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -143,7 +143,7 @@ export default class UserTabs {
this.loadOverviewTab();
}
- const loadableActions = ['groups', 'contributed', 'projects', 'snippets'];
+ const loadableActions = ['groups', 'contributed', 'projects', 'starred', 'snippets'];
if (loadableActions.indexOf(action) > -1) {
this.loadTab(action, endpoint);
}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index 6d39abd4a1f..bbbd9789dc9 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -14,7 +14,6 @@ export default {
},
data() {
return {
- loading: false,
pages: [],
};
},
@@ -35,19 +34,24 @@ export default {
load() {
this.pages = [];
return pdfjsLib
- .getDocument(this.document)
- .then(this.renderPages)
- .then(() => this.$emit('pdflabload'))
- .catch(error => this.$emit('pdflaberror', error))
- .then(() => {
- this.loading = false;
+ .getDocument({
+ url: this.document,
+ cMapUrl: '/assets/webpack/cmaps/',
+ cMapPacked: true,
+ })
+ .promise.then(this.renderPages)
+ .then(pages => {
+ this.pages = pages;
+ this.$emit('pdflabload');
+ })
+ .catch(error => {
+ this.$emit('pdflaberror', error);
});
},
renderPages(pdf) {
const pagePromises = [];
- this.loading = true;
for (let num = 1; num <= pdf.numPages; num += 1) {
- pagePromises.push(pdf.getPage(num).then(p => this.pages.push(p)));
+ pagePromises.push(pdf.getPage(num));
}
return Promise.all(pagePromises);
},
@@ -59,8 +63,8 @@ export default {
<div v-if="hasPDF" class="pdf-viewer">
<page
v-for="(page, index) in pages"
+ v-if="page"
:key="index"
- :v-if="!loading"
:page="page"
:number="index + 1"
/>
diff --git a/app/assets/javascripts/pdf/page/index.vue b/app/assets/javascripts/pdf/page/index.vue
index f16aaca6cd7..65f84e75e86 100644
--- a/app/assets/javascripts/pdf/page/index.vue
+++ b/app/assets/javascripts/pdf/page/index.vue
@@ -18,7 +18,7 @@ export default {
},
computed: {
viewport() {
- return this.page.getViewport(this.scale);
+ return this.page.getViewport({ scale: this.scale });
},
context() {
return this.$refs.canvas.getContext('2d');
@@ -36,10 +36,12 @@ export default {
this.rendering = true;
this.page
.render(this.renderContext)
- .then(() => {
+ .promise.then(() => {
this.rendering = false;
})
- .catch(error => this.$emit('pdflaberror', error));
+ .catch(error => {
+ this.$emit('pdflaberror', error);
+ });
},
};
</script>
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index d5f1cea8356..a271284dd89 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -16,11 +16,14 @@ export default {
type: String,
required: true,
},
- header: {
+ title: {
type: String,
- required: true,
+ required: false,
+ default() {
+ return this.metric;
+ },
},
- details: {
+ header: {
type: String,
required: true,
},
@@ -34,14 +37,14 @@ export default {
return this.currentRequest.details[this.metric];
},
detailsList() {
- return this.metricDetails[this.details];
+ return this.metricDetails.details;
},
},
};
</script>
<template>
<div
- v-if="currentRequest.details"
+ v-if="currentRequest.details && metricDetails"
:id="`peek-view-${metric}`"
class="view qa-performance-bar-detailed-metric"
>
@@ -63,7 +66,7 @@ export default {
<template v-if="detailsList.length">
<tr v-for="(item, index) in detailsList" :key="index">
<td>
- <span>{{ item.duration }}ms</span>
+ <span>{{ sprintf(__('%{duration}ms'), { duration: item.duration }) }}</span>
</td>
<td>
<div class="js-toggle-container">
@@ -101,6 +104,6 @@ export default {
<div slot="footer"></div>
</gl-modal>
- {{ metric }}
+ {{ title }}
</div>
</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 185003c306e..9ad6e75b86b 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,17 +1,14 @@
<script>
-import $ from 'jquery';
import { glEmojiTag } from '~/emoji';
import detailedMetric from './detailed_metric.vue';
import requestSelector from './request_selector.vue';
-import simpleMetric from './simple_metric.vue';
import { s__ } from '~/locale';
export default {
components: {
detailedMetric,
requestSelector,
- simpleMetric,
},
props: {
store: {
@@ -30,21 +27,30 @@ export default {
type: String,
required: true,
},
- profileUrl: {
- type: String,
- required: true,
- },
},
detailedMetrics: [
- { metric: 'pg', header: s__('PerformanceBar|SQL queries'), details: 'queries', keys: ['sql'] },
+ {
+ metric: 'active-record',
+ title: 'pg',
+ header: s__('PerformanceBar|SQL queries'),
+ keys: ['sql'],
+ },
{
metric: 'gitaly',
header: s__('PerformanceBar|Gitaly calls'),
- details: 'details',
keys: ['feature', 'request'],
},
+ {
+ metric: 'rugged',
+ header: s__('PerformanceBar|Rugged calls'),
+ keys: ['feature', 'args'],
+ },
+ {
+ metric: 'redis',
+ header: s__('PerformanceBar|Redis calls'),
+ keys: ['cmd'],
+ },
],
- simpleMetrics: ['redis'],
data() {
return { currentRequestId: '' };
},
@@ -63,9 +69,6 @@ export default {
initialRequest() {
return this.currentRequestId === this.requestId;
},
- lineProfileModal() {
- return $('#modal-peek-line-profile');
- },
hasHost() {
return this.currentRequest && this.currentRequest.details && this.currentRequest.details.host;
},
@@ -73,16 +76,11 @@ export default {
if (this.hasHost && this.currentRequest.details.host.canary) {
return glEmojiTag('baby_chick');
}
-
return '';
},
},
mounted() {
this.currentRequest = this.requestId;
-
- if (this.lineProfileModal.length) {
- this.lineProfileModal.modal('toggle');
- }
},
methods: {
changeCurrentRequest(newRequestId) {
@@ -109,33 +107,10 @@ export default {
:key="metric.metric"
:current-request="currentRequest"
:metric="metric.metric"
+ :title="metric.title"
:header="metric.header"
- :details="metric.details"
:keys="metric.keys"
/>
- <div v-if="initialRequest" id="peek-view-rblineprof" class="view">
- <button
- v-if="lineProfileModal.length"
- class="btn-link btn-blank"
- data-toggle="modal"
- data-target="#modal-peek-line-profile"
- >
- {{ s__('PerformanceBar|profile') }}
- </button>
- <a v-else :href="profileUrl">{{ s__('PerformanceBar|profile') }}</a>
- </div>
- <simple-metric
- v-for="metric in $options.simpleMetrics"
- :key="metric"
- :current-request="currentRequest"
- :metric="metric"
- />
- <div id="peek-view-gc" class="view">
- <span v-if="currentRequest.details" class="bold">
- <span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span
- >ms / <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span> gc
- </span>
- </div>
<div
v-if="currentRequest.details && currentRequest.details.tracing"
id="peek-view-trace"
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
deleted file mode 100644
index 358a57d5bc5..00000000000
--- a/app/assets/javascripts/performance_bar/components/simple_metric.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- props: {
- currentRequest: {
- type: Object,
- required: true,
- },
- metric: {
- type: String,
- required: true,
- },
- },
- computed: {
- duration() {
- return (
- this.currentRequest.details[this.metric] &&
- this.currentRequest.details[this.metric].duration
- );
- },
- calls() {
- return (
- this.currentRequest.details[this.metric] && this.currentRequest.details[this.metric].calls
- );
- },
- },
-};
-</script>
-<template>
- <div :id="`peek-view-${metric}`" class="view">
- <span v-if="currentRequest.details" class="bold"> {{ duration }} / {{ calls }} </span>
- {{ metric }}
- </div>
-</template>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 4a08e158f6b..8d6a3781048 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -1,13 +1,17 @@
+import { parseBoolean } from './lib/utils/common_utils';
import axios from './lib/utils/axios_utils';
import { __ } from './locale';
import Flash from './flash';
+const DEFERRED_LINK_CLASS = 'deferred-link';
+
export default class PersistentUserCallout {
constructor(container) {
- const { dismissEndpoint, featureId } = container.dataset;
+ const { dismissEndpoint, featureId, deferLinks } = container.dataset;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
+ this.deferLinks = parseBoolean(deferLinks);
this.init();
}
@@ -15,9 +19,21 @@ export default class PersistentUserCallout {
init() {
const closeButton = this.container.querySelector('.js-close');
closeButton.addEventListener('click', event => this.dismiss(event));
+
+ if (this.deferLinks) {
+ this.container.addEventListener('click', event => {
+ const isDeferredLink = event.target.classList.contains(DEFERRED_LINK_CLASS);
+
+ if (isDeferredLink) {
+ const { href, target } = event.target;
+
+ this.dismiss(event, { href, target });
+ }
+ });
+ }
}
- dismiss(event) {
+ dismiss(event, deferredLinkOptions = null) {
event.preventDefault();
axios
@@ -26,6 +42,11 @@ export default class PersistentUserCallout {
})
.then(() => {
this.container.remove();
+
+ if (deferredLinkOptions) {
+ const { href, target } = deferredLinkOptions;
+ window.open(href, target);
+ }
})
.catch(() => {
Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index b2e365e5cde..39afa87afc3 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
+import { __ } from '~/locale';
export default {
name: 'PipelineHeaderSection',
@@ -54,7 +55,7 @@ export default {
if (this.pipeline.retry_path) {
actions.push({
- label: 'Retry',
+ label: __('Retry'),
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
type: 'button',
@@ -64,7 +65,7 @@ export default {
if (this.pipeline.cancel_path) {
actions.push({
- label: 'Cancel running',
+ label: __('Cancel running'),
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
type: 'button',
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 65a2b61396c..a08f732dda7 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -67,7 +67,7 @@ export default {
<span
v-if="pipeline.flags.latest"
v-gl-tooltip
- :title="__('Latest pipeline for this branch')"
+ :title="__('Latest pipeline for the most recent commit on this branch')"
class="js-pipeline-url-latest badge badge-success"
>
{{ __('latest') }}
@@ -94,16 +94,19 @@ export default {
tabindex="0"
class="js-pipeline-url-autodevops badge badge-info autodevops-badge"
role="button"
+ >{{ __('Auto DevOps') }}</gl-link
>
- Auto DevOps
- </gl-link>
<span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning">
{{ __('stuck') }}
</span>
<span
v-if="pipeline.flags.detached_merge_request_pipeline"
v-gl-tooltip
- :title="__('This pipeline is run on the source branch')"
+ :title="
+ __(
+ 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.',
+ )
+ "
class="js-pipeline-url-detached badge badge-info"
>
{{ __('detached') }}
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
index 244d332f38f..4b2d816c6a0 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
@@ -1,9 +1,11 @@
<script>
import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
-import { s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import flash from '~/flash';
+import { s__, __, sprintf } from '~/locale';
import GlCountdown from '~/vue_shared/components/gl_countdown.vue';
+import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
-import Icon from '../../vue_shared/components/icon.vue';
export default {
directives: {
@@ -44,7 +46,24 @@ export default {
this.isLoading = true;
- eventHub.$emit('postAction', action.path);
+ /**
+ * Ideally, the component would not make an api call directly.
+ * However, in order to use the eventhub and know when to
+ * toggle back the `isLoading` property we'd need an ID
+ * to track the request with a wacther - since this component
+ * is rendered at least 20 times in the same page, moving the
+ * api call directly here is the most performant solution
+ */
+ axios
+ .post(`${action.path}.json`)
+ .then(() => {
+ this.isLoading = false;
+ eventHub.$emit('updateTable');
+ })
+ .catch(() => {
+ this.isLoading = false;
+ flash(__('An error occurred while making the request.'));
+ });
},
isActionDisabled(action) {
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index 2ab0ad4d013..3f07b77ed32 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 03d332cd430..d3ba0c97f6b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -44,6 +44,11 @@ export default {
cancelingPipeline: null,
};
},
+ watch: {
+ pipelines() {
+ this.cancelingPipeline = null;
+ },
+ },
created() {
eventHub.$on('openConfirmationModal', this.setModalData);
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index e32e2f785bd..5275de3bc8b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -241,7 +241,11 @@ export default {
return this.cancelingPipeline === this.pipeline.id;
},
},
-
+ watch: {
+ pipeline() {
+ this.isRetrying = false;
+ },
+ },
methods: {
handleCancelClick() {
eventHub.$emit('openConfirmationModal', {
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 3cc9d0a3a4e..126a9a47a2b 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -60,12 +60,14 @@ export default {
eventHub.$on('postAction', this.postAction);
eventHub.$on('retryPipeline', this.postAction);
eventHub.$on('clickedDropdown', this.updateTable);
+ eventHub.$on('updateTable', this.updateTable);
eventHub.$on('refreshPipelinesTable', this.fetchPipelines);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
eventHub.$off('retryPipeline', this.postAction);
eventHub.$off('clickedDropdown', this.updateTable);
+ eventHub.$off('updateTable', this.updateTable);
eventHub.$off('refreshPipelinesTable', this.fetchPipelines);
},
destroyed() {
@@ -107,8 +109,8 @@ export default {
}
// Stop polling
this.poll.stop();
- // Update the table
- return this.getPipelines().then(() => this.poll.restart());
+ // Restarting the poll also makes an initial request
+ this.poll.restart();
},
fetchPipelines() {
if (!this.isMakingRequest) {
@@ -153,7 +155,7 @@ export default {
postAction(endpoint) {
this.service
.postAction(endpoint)
- .then(() => this.fetchPipelines())
+ .then(() => this.updateTable())
.catch(() => Flash(__('An error occurred while making the request.')));
},
},
diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js
new file mode 100644
index 00000000000..126b1ee1132
--- /dev/null
+++ b/app/assets/javascripts/privacy_policy_update_callout.js
@@ -0,0 +1,8 @@
+import PersistentUserCallout from '~/persistent_user_callout';
+
+function initPrivacyPolicyUpdateCallout() {
+ const callout = document.querySelector('.privacy-policy-update-64341');
+ PersistentUserCallout.factory(callout);
+}
+
+export default initPrivacyPolicyUpdateCallout;
diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/projects/gke_cluster_namespace/index.js
new file mode 100644
index 00000000000..0ec4d8807b0
--- /dev/null
+++ b/app/assets/javascripts/projects/gke_cluster_namespace/index.js
@@ -0,0 +1,37 @@
+/**
+ * Disables & hides the namespace inputs when the gitlab-managed checkbox is checked/unchecked.
+ */
+
+const setDisabled = (el, isDisabled) => {
+ if (isDisabled) {
+ el.classList.add('hidden');
+ el.querySelector('input').setAttribute('disabled', true);
+ } else {
+ el.classList.remove('hidden');
+ el.querySelector('input').removeAttribute('disabled');
+ }
+};
+
+const setState = glManagedCheckbox => {
+ const glManaged = document.querySelector('.js-namespace-prefixed');
+ const selfManaged = document.querySelector('.js-namespace');
+
+ if (glManagedCheckbox.checked) {
+ setDisabled(glManaged, false);
+ setDisabled(selfManaged, true);
+ } else {
+ setDisabled(glManaged, true);
+ setDisabled(selfManaged, false);
+ }
+};
+
+const initGkeNamespace = () => {
+ const glManagedCheckbox = document.querySelector('.js-gl-managed');
+
+ if (glManagedCheckbox) {
+ setState(glManagedCheckbox); // this is needed in order to set the initial state
+ glManagedCheckbox.addEventListener('change', () => setState(glManagedCheckbox));
+ }
+};
+
+export default initGkeNamespace;
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ea82ff4e340..9066844f687 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
-import { slugifyWithHyphens } from '../lib/utils/text_utility';
+import { slugify } from '../lib/utils/text_utility';
import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
@@ -34,7 +34,7 @@ const deriveProjectPathFromUrl = $projectImportUrl => {
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
- const slug = slugifyWithHyphens($projectNameInput.val());
+ const slug = slugify($projectNameInput.val());
$projectPathInput.val(slug);
};
diff --git a/app/assets/javascripts/projects/projects_filterable_list.js b/app/assets/javascripts/projects/projects_filterable_list.js
new file mode 100644
index 00000000000..433c894e668
--- /dev/null
+++ b/app/assets/javascripts/projects/projects_filterable_list.js
@@ -0,0 +1,7 @@
+import FilterableList from '~/filterable_list';
+
+export default class ProjectsFilterableList extends FilterableList {
+ getFilterEndpoint() {
+ return this.getPagePath().replace('/projects?', '/projects.json?');
+ }
+}
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index bfc55013a71..12ee1ce2f0c 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -3,7 +3,7 @@ import Visibility from 'visibilityjs';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import Poll from '~/lib/utils/poll';
import Flash from '~/flash';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import { GlLoadingIcon } from '@gitlab/ui';
import CommitPipelineService from '../services/commit_pipeline_service';
@@ -38,7 +38,9 @@ export default {
},
computed: {
statusTitle() {
- return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text });
+ return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ciStatus}'), {
+ ciStatus: this.ciStatus.text,
+ });
},
},
mounted() {
@@ -56,7 +58,7 @@ export default {
},
errorCallback() {
this.ciStatus = {
- text: 'not found',
+ text: __('not found'),
icon: 'status_notfound',
group: 'notfound',
};
diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js
index c67d59d2be5..913b62ba26d 100644
--- a/app/assets/javascripts/projects_list.js
+++ b/app/assets/javascripts/projects_list.js
@@ -1,4 +1,4 @@
-import FilterableList from './filterable_list';
+import ProjectsFilterableList from './projects/projects_filterable_list';
/**
* Makes search request for projects when user types a value in the search input.
@@ -11,7 +11,7 @@ export default class ProjectsList {
const holder = document.querySelector('.js-projects-list-holder');
if (form && filter && holder) {
- const list = new FilterableList(form, filter, holder);
+ const list = new ProjectsFilterableList(form, filter, holder);
list.initSearch();
}
}
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index ee973017387..346dc470a59 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -1,13 +1,17 @@
<script>
import { mapGetters, mapActions } from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
+import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import CollapsibleContainer from './collapsible_container.vue';
+import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
+ clipboardButton,
CollapsibleContainer,
+ GlEmptyState,
GlLoadingIcon,
},
props: {
@@ -15,16 +19,82 @@ export default {
type: String,
required: true,
},
+ characterError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ containersErrorImage: {
+ type: String,
+ required: true,
+ },
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
},
store,
computed: {
...mapGetters(['isLoading', 'repos']),
+ dockerConnectionErrorText() {
+ return sprintf(
+ s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
+ issue with your project name or path.
+ %{docLinkStart}More Information%{docLinkEnd}`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ introText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
+ project can have its own space to store its Docker images.
+ %{docLinkStart}More Information%{docLinkEnd}`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ noContainerImagesText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ dockerBuildCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker build -t ${this.repositoryUrl} .`;
+ },
+ dockerPushCommand() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `docker push ${this.repositoryUrl}`;
+ },
},
created() {
this.setMainEndpoint(this.endpoint);
},
mounted() {
- this.fetchRepos();
+ if (!this.characterError) {
+ this.fetchRepos();
+ }
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos']),
@@ -33,20 +103,63 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-empty-state
+ v-if="characterError"
+ :title="s__('ContainerRegistry|Docker connection error')"
+ :svg-path="containersErrorImage"
+ >
+ <template #description>
+ <p v-html="dockerConnectionErrorText"></p>
+ </template>
+ </gl-empty-state>
+
+ <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" />
+
+ <div v-else-if="!isLoading && repos.length">
+ <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
+ <p v-html="introText"></p>
+ <collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
+ </div>
+
+ <gl-empty-state
+ v-else
+ :title="s__('ContainerRegistry|There are no container images stored for this project')"
+ :svg-path="noContainersImage"
+ class="container-message"
+ >
+ <template #description>
+ <p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p>
+ {{
+ s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ )
+ }}
+ </p>
- <collapsible-container
- v-for="item in repos"
- v-else-if="!isLoading && repos.length"
- :key="item.id"
- :repo="item"
- />
+ <div class="input-group append-bottom-10">
+ <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerBuildCommand"
+ :title="s__('ContainerRegistry|Copy build command to clipboard')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
- <p v-else-if="!isLoading && !repos.length">
- {{
- __(`No container images stored for this project.
- Add one by following the instructions above.`)
- }}
- </p>
+ <div class="input-group">
+ <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
+ <span class="input-group-append">
+ <clipboard-button
+ :text="dockerPushCommand"
+ :title="s__('ContainerRegistry|Copy push command to clipboard')"
+ class="input-group-text"
+ />
+ </span>
+ </div>
+ </template>
+ </gl-empty-state>
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index 1e266dd4ced..bfb2305c48c 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -31,6 +31,7 @@ export default {
data() {
return {
isOpen: false,
+ modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
};
},
computed: {
@@ -80,10 +81,10 @@ export default {
<gl-button
v-if="repo.canDelete"
v-gl-tooltip
- v-gl-modal="'confirm-repo-deletion-modal'"
+ v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
- class="js-remove-repo"
+ class="js-remove-repo btn-inverted"
variant="danger"
>
<icon name="remove" />
@@ -100,12 +101,7 @@ export default {
{{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
</div>
</div>
-
- <gl-modal
- modal-id="confirm-repo-deletion-modal"
- ok-variant="danger"
- @ok="handleDeleteRepository"
- >
+ <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
v-html="
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 0ec5e2c7a87..e9067bc2b56 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,7 +1,13 @@
<script>
import { mapActions } from 'vuex';
-import { GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
-import { n__ } from '../../locale';
+import {
+ GlButton,
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlModal,
+ GlModalDirective,
+} from '@gitlab/ui';
+import { n__, s__, sprintf } from '../../locale';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
@@ -14,6 +20,7 @@ export default {
components: {
ClipboardButton,
TablePagination,
+ GlFormCheckbox,
GlButton,
Icon,
GlModal,
@@ -31,32 +38,98 @@ export default {
},
data() {
return {
- itemToBeDeleted: null,
+ itemsToBeDeleted: [],
+ modalId: `confirm-image-deletion-modal-${this.repo.id}`,
+ selectAllChecked: false,
+ modalDescription: '',
};
},
computed: {
+ bulkDeletePath() {
+ return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
+ },
shouldRenderPagination() {
return this.repo.pagination.total > this.repo.pagination.perPage;
},
+ modalTitle() {
+ return n__(
+ 'ContainerRegistry|Remove image',
+ 'ContainerRegistry|Remove images',
+ this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
+ );
+ },
+ },
+ mounted() {
+ this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents);
},
methods: {
- ...mapActions(['fetchList', 'deleteItem']),
+ ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
+ setModalDescription(itemIndex = -1) {
+ if (itemIndex === -1) {
+ this.modalDescription = sprintf(
+ s__(`ContainerRegistry|You are about to delete <b>%{count}</b> images. This will
+ delete the images and all tags pointing to them.`),
+ { count: this.itemsToBeDeleted.length },
+ );
+ } else {
+ const { tag } = this.repo.list[itemIndex];
+
+ this.modalDescription = sprintf(
+ s__(`ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will
+ delete the image and all tags pointing to this image.`),
+ { title: `${this.repo.name}:${tag}` },
+ );
+ }
+ },
layers(item) {
return item.layers ? n__('%d layer', '%d layers', item.layers) : '';
},
formatSize(size) {
return numberToHumanSize(size);
},
- setItemToBeDeleted(item) {
- this.itemToBeDeleted = item;
+ removeModalEvents() {
+ this.$refs.deleteModal.$refs.modal.$off('ok');
},
- handleDeleteRegistry() {
- const { itemToBeDeleted } = this;
- this.itemToBeDeleted = null;
- this.deleteItem(itemToBeDeleted)
+ deleteSingleItem(index) {
+ this.setModalDescription(index);
+
+ this.$refs.deleteModal.$refs.modal.$once('ok', () => {
+ this.removeModalEvents();
+ this.handleSingleDelete(this.repo.list[index]);
+ });
+ },
+ deleteMultipleItems() {
+ if (this.itemsToBeDeleted.length === 1) {
+ this.setModalDescription(this.itemsToBeDeleted[0]);
+ } else if (this.itemsToBeDeleted.length > 1) {
+ this.setModalDescription();
+ }
+
+ this.$refs.deleteModal.$refs.modal.$once('ok', () => {
+ this.removeModalEvents();
+ this.handleMultipleDelete();
+ });
+ },
+ handleSingleDelete(itemToDelete) {
+ this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
.catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
},
+ handleMultipleDelete() {
+ const { itemsToBeDeleted } = this;
+ this.itemsToBeDeleted = [];
+
+ if (this.bulkDeletePath) {
+ this.multiDeleteItems({
+ path: this.bulkDeletePath,
+ items: itemsToBeDeleted.map(x => this.repo.list[x].tag),
+ })
+ .then(() => this.fetchList({ repo: this.repo }))
+ .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
+ } else {
+ this.showError(errorMessagesTypes.DELETE_REGISTRY);
+ }
+ },
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
this.showError(errorMessagesTypes.FETCH_REGISTRY),
@@ -65,6 +138,35 @@ export default {
showError(message) {
createFlash(errorMessages[message]);
},
+ onSelectAllChange() {
+ if (this.selectAllChecked) {
+ this.deselectAll();
+ } else {
+ this.selectAll();
+ }
+ },
+ selectAll() {
+ this.itemsToBeDeleted = this.repo.list.map((x, index) => index);
+ this.selectAllChecked = true;
+ },
+ deselectAll() {
+ this.itemsToBeDeleted = [];
+ this.selectAllChecked = false;
+ },
+ updateItemsToBeDeleted(index) {
+ const delIndex = this.itemsToBeDeleted.findIndex(x => x === index);
+
+ if (delIndex > -1) {
+ this.itemsToBeDeleted.splice(delIndex, 1);
+ this.selectAllChecked = false;
+ } else {
+ this.itemsToBeDeleted.push(index);
+
+ if (this.itemsToBeDeleted.length === this.repo.list.length) {
+ this.selectAllChecked = true;
+ }
+ }
+ },
},
};
</script>
@@ -73,15 +175,44 @@ export default {
<table class="table tags">
<thead>
<tr>
+ <th>
+ <gl-form-checkbox
+ v-if="repo.canDelete"
+ class="js-select-all-checkbox"
+ :checked="selectAllChecked"
+ @change="onSelectAllChange"
+ />
+ </th>
<th>{{ s__('ContainerRegistry|Tag') }}</th>
<th>{{ s__('ContainerRegistry|Tag ID') }}</th>
<th>{{ s__('ContainerRegistry|Size') }}</th>
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
- <th></th>
+ <th>
+ <gl-button
+ v-if="repo.canDelete"
+ v-gl-tooltip
+ v-gl-modal="modalId"
+ :disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
+ class="js-delete-registry float-right"
+ variant="danger"
+ :title="s__('ContainerRegistry|Remove selected images')"
+ :aria-label="s__('ContainerRegistry|Remove selected images')"
+ @click="deleteMultipleItems()"
+ ><icon name="remove"
+ /></gl-button>
+ </th>
</tr>
</thead>
<tbody>
- <tr v-for="item in repo.list" :key="item.tag">
+ <tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
+ <td class="check">
+ <gl-form-checkbox
+ v-if="item.canDelete"
+ class="js-select-checkbox"
+ :checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)"
+ @change="updateItemsToBeDeleted(index)"
+ />
+ </td>
<td class="monospace">
{{ item.tag }}
<clipboard-button
@@ -110,16 +241,15 @@ export default {
</span>
</td>
- <td class="content">
+ <td class="content action-buttons">
<gl-button
v-if="item.canDelete"
- v-gl-tooltip
- v-gl-modal="'confirm-image-deletion-modal'"
+ v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove image')"
:aria-label="s__('ContainerRegistry|Remove image')"
variant="danger"
- class="js-delete-registry d-none d-sm-block float-right"
- @click="setItemToBeDeleted(item)"
+ class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
+ @click="deleteSingleItem(index)"
>
<icon name="remove" />
</gl-button>
@@ -134,23 +264,10 @@ export default {
:page-info="repo.pagination"
/>
- <gl-modal
- modal-id="confirm-image-deletion-modal"
- ok-variant="danger"
- @ok="handleDeleteRegistry"
- >
- <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove image') }}</template>
- <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image and tags') }}</template>
- <p
- v-html="
- sprintf(
- s__(
- 'ContainerRegistry|You are about to delete the image <b>%{title}</b>. This will delete the image and all tags pointing to this image.',
- ),
- { title: repo.name },
- )
- "
- ></p>
+ <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
+ <template v-slot:modal-title>{{ modalTitle }}</template>
+ <template v-slot:modal-ok>{{ s__('ContainerRegistry|Remove image(s) and tags') }}</template>
+ <p v-html="modalDescription"></p>
</gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index 025afefe7f0..d8daec29fda 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -14,12 +14,22 @@ export default () =>
const { dataset } = document.querySelector(this.$options.el);
return {
endpoint: dataset.endpoint,
+ characterError: Boolean(dataset.characterError),
+ helpPagePath: dataset.helpPagePath,
+ noContainersImage: dataset.noContainersImage,
+ containersErrorImage: dataset.containersErrorImage,
+ repositoryUrl: dataset.repositoryUrl,
};
},
render(createElement) {
return createElement('registry-app', {
props: {
endpoint: this.endpoint,
+ characterError: this.characterError,
+ helpPagePath: this.helpPagePath,
+ noContainersImage: this.noContainersImage,
+ containersErrorImage: this.containersErrorImage,
+ repositoryUrl: this.repositoryUrl,
},
});
},
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index 0f5e9cc73a0..a2e0130e79e 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -36,6 +36,8 @@ export const fetchList = ({ commit }, { repo, page }) => {
};
export const deleteItem = (_, item) => axios.delete(item.destroyPath);
+export const multiDeleteItems = (_, { path, items }) =>
+ axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
index 6d908524da9..f0112a5a623 100644
--- a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -65,7 +65,7 @@ export default {
<template>
<div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
- <div id="merge-requests" class="card-slim mt-3">
+ <div id="merge-requests" class="card card-slim mt-3">
<div class="card-header">
<div class="card-title mt-0 mb-0 h5 merge-requests-title">
<span class="mr-1">
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index f510b905a2e..7580c2d0ad0 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,10 +1,11 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { sprintf } from '../../locale';
+import { __, sprintf } from '../../locale';
export default {
name: 'ReleaseBlock',
@@ -27,13 +28,13 @@ export default {
},
computed: {
releasedTimeAgo() {
- return sprintf('released %{time}', {
- time: this.timeFormated(this.release.created_at),
+ return sprintf(__('released %{time}'), {
+ time: this.timeFormated(this.release.released_at),
});
},
userImageAltDescription() {
return this.author && this.author.username
- ? sprintf("%{username}'s avatar", { username: this.author.username })
+ ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
: null;
},
commit() {
@@ -56,8 +57,8 @@ export default {
<div class="card-body">
<h2 class="card-title mt-0">
{{ release.name }}
- <gl-badge v-if="release.pre_release" variant="warning" class="align-middle">{{
- __('Pre-release')
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
}}</gl-badge>
</h2>
@@ -74,7 +75,7 @@ export default {
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
{{ releasedTimeAgo }}
</span>
</div>
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 04fba43b2f3..386653b9444 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -16,7 +16,7 @@ export default {
statusIconSize: {
type: Number,
required: false,
- default: 32,
+ default: 24,
},
},
computed: {
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
index 162421b037f..cb9c1642608 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -1,4 +1,5 @@
<script>
+// import { sprintf, __ } from '~/locale';
import Modal from '~/vue_shared/components/gl_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
@@ -47,9 +48,9 @@ export default {
</a>
</template>
- <template v-else-if="field.type === $options.fieldTypes.miliseconds">
- {{ field.value }} ms
- </template>
+ <template v-else-if="field.type === $options.fieldTypes.miliseconds">{{
+ sprintf(__('%{value} ms'), { value: field.value })
+ }}</template>
<template v-else-if="field.type === $options.fieldTypes.text">
{{ field.value }}
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 2be9c37b00a..f3f7d2648a8 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -27,7 +27,7 @@ export default {
statusIconSize: {
type: Number,
required: false,
- default: 32,
+ default: 24,
},
isNew: {
type: Boolean,
@@ -43,12 +43,15 @@ export default {
};
</script>
<template>
- <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue">
+ <li
+ :class="{ 'is-dismissed': issue.isDismissed }"
+ class="report-block-list-issue align-items-center"
+ >
<issue-status-icon
v-if="showReportSectionStatusIcon"
:status="status"
:status-icon-size="statusIconSize"
- class="append-right-5"
+ class="append-right-default"
/>
<component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
diff --git a/app/assets/javascripts/reports/components/report_link.vue b/app/assets/javascripts/reports/components/report_link.vue
index 052bc53d610..e32e1ac49ca 100644
--- a/app/assets/javascripts/reports/components/report_link.vue
+++ b/app/assets/javascripts/reports/components/report_link.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
export default {
name: 'ReportIssueLink',
props: {
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 3d576caaf8f..24612c8681a 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -165,7 +165,7 @@ export default {
<template>
<section class="media-section">
<div class="media">
- <status-icon :status="statusIconName" />
+ <status-icon :status="statusIconName" :size="24" />
<div class="media-body d-flex flex-align-self-center">
<span class="js-code-text code-text">
{{ headerText }}
diff --git a/app/assets/javascripts/reports/components/summary_row.vue b/app/assets/javascripts/reports/components/summary_row.vue
index 97a68531d29..aba798e63d0 100644
--- a/app/assets/javascripts/reports/components/summary_row.vue
+++ b/app/assets/javascripts/reports/components/summary_row.vue
@@ -44,10 +44,14 @@ export default {
};
</script>
<template>
- <div class="report-block-list-issue report-block-list-issue-parent">
- <div class="report-block-list-icon append-right-10 prepend-left-5">
- <gl-loading-icon v-if="statusIcon === 'loading'" css-class="report-block-list-loading-icon" />
- <ci-icon v-else :status="iconStatus" />
+ <div class="report-block-list-issue report-block-list-issue-parent align-items-center">
+ <div class="report-block-list-icon append-right-default">
+ <gl-loading-icon
+ v-if="statusIcon === 'loading'"
+ css-class="report-block-list-loading-icon"
+ size="md"
+ />
+ <ci-icon v-else :status="iconStatus" :size="24" />
</div>
<div class="report-block-list-issue-description">
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 67963dc1923..afb58a60155 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -1,12 +1,41 @@
<script>
+import { GlDropdown, GlDropdownDivider, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
+import { __ } from '../../locale';
+import Icon from '../../vue_shared/components/icon.vue';
import getRefMixin from '../mixins/get_ref';
import getProjectShortPath from '../queries/getProjectShortPath.query.graphql';
+import getProjectPath from '../queries/getProjectPath.query.graphql';
+import getPermissions from '../queries/getPermissions.query.graphql';
+
+const ROW_TYPES = {
+ header: 'header',
+ divider: 'divider',
+};
export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownHeader,
+ GlDropdownItem,
+ Icon,
+ },
apollo: {
projectShortPath: {
query: getProjectShortPath,
},
+ projectPath: {
+ query: getProjectPath,
+ },
+ userPermissions: {
+ query: getPermissions,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ };
+ },
+ update: data => data.project.userPermissions,
+ },
},
mixins: [getRefMixin],
props: {
@@ -15,10 +44,52 @@ export default {
required: false,
default: '/',
},
+ canCollaborate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ canEditTree: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ newBranchPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ newTagPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ newBlobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ forkNewBlobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ forkNewDirectoryPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ forkUploadBlobPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
projectShortPath: '',
+ projectPath: '',
+ userPermissions: {},
};
},
computed: {
@@ -39,11 +110,112 @@ export default {
[{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }],
);
},
+ canCreateMrFromFork() {
+ return this.userPermissions.forkProject && this.userPermissions.createMergeRequestIn;
+ },
+ dropdownItems() {
+ const items = [];
+
+ if (this.canEditTree) {
+ items.push(
+ {
+ type: ROW_TYPES.header,
+ text: __('This directory'),
+ },
+ {
+ attrs: {
+ href: this.newBlobPath,
+ class: 'qa-new-file-option',
+ },
+ text: __('New file'),
+ },
+ {
+ attrs: {
+ href: '#modal-upload-blob',
+ 'data-target': '#modal-upload-blob',
+ 'data-toggle': 'modal',
+ },
+ text: __('Upload file'),
+ },
+ {
+ attrs: {
+ href: '#modal-create-new-dir',
+ 'data-target': '#modal-create-new-dir',
+ 'data-toggle': 'modal',
+ },
+ text: __('New directory'),
+ },
+ );
+ } else if (this.canCreateMrFromFork) {
+ items.push(
+ {
+ attrs: {
+ href: this.forkNewBlobPath,
+ 'data-method': 'post',
+ },
+ text: __('New file'),
+ },
+ {
+ attrs: {
+ href: this.forkUploadBlobPath,
+ 'data-method': 'post',
+ },
+ text: __('Upload file'),
+ },
+ {
+ attrs: {
+ href: this.forkNewDirectoryPath,
+ 'data-method': 'post',
+ },
+ text: __('New directory'),
+ },
+ );
+ }
+
+ if (this.userPermissions.pushCode) {
+ items.push(
+ {
+ type: ROW_TYPES.divider,
+ },
+ {
+ type: ROW_TYPES.header,
+ text: __('This repository'),
+ },
+ {
+ attrs: {
+ href: this.newBranchPath,
+ },
+ text: __('New branch'),
+ },
+ {
+ attrs: {
+ href: this.newTagPath,
+ },
+ text: __('New tag'),
+ },
+ );
+ }
+
+ return items;
+ },
+ renderAddToTreeDropdown() {
+ return this.canCollaborate || this.canCreateMrFromFork;
+ },
},
methods: {
isLast(i) {
return i === this.pathLinks.length - 1;
},
+ getComponent(type) {
+ switch (type) {
+ case ROW_TYPES.divider:
+ return 'gl-dropdown-divider';
+ case ROW_TYPES.header:
+ return 'gl-dropdown-header';
+ default:
+ return 'gl-dropdown-item';
+ }
+ },
},
};
</script>
@@ -56,6 +228,20 @@ export default {
{{ link.name }}
</router-link>
</li>
+ <li v-if="renderAddToTreeDropdown" class="breadcrumb-item">
+ <gl-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1">
+ <template slot="button-content">
+ <span class="sr-only">{{ __('Add to tree') }}</span>
+ <icon name="plus" :size="16" class="float-left" />
+ <icon name="arrow-down" :size="16" class="float-left" />
+ </template>
+ <template v-for="(item, i) in dropdownItems">
+ <component :is="getComponent(item.type)" :key="i" v-bind="item.attrs">
+ {{ item.text }}
+ </component>
+ </template>
+ </gl-dropdown>
+ </li>
</ol>
</nav>
</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 26493556063..e2060d4aeec 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '../../vue_shared/components/icon.vue';
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 1e66ccbfa29..610c7e8d99e 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -76,7 +76,7 @@ export default {
variables: {
projectPath: this.projectPath,
ref: this.ref,
- path: this.path,
+ path: this.path || '/',
nextPageCursor: this.nextPageCursor,
pageSize: PAGE_SIZE,
},
@@ -137,6 +137,7 @@ export default {
:path="entry.flatPath"
:type="entry.type"
:url="entry.webUrl"
+ :submodule-tree-url="entry.treeUrl"
:lfs-oid="entry.lfsOid"
/>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index c31e7fa71a2..171841178a3 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -62,6 +62,11 @@ export default {
required: false,
default: null,
},
+ submoduleTreeUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
@@ -110,11 +115,10 @@ export default {
<component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated">
{{ fullPath }}
</component>
- <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">
- LFS
- </gl-badge>
+ <!-- eslint-disable-next-line @gitlab/vue-i18n/no-bare-strings -->
+ <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge>
<template v-if="isSubmodule">
- @ <gl-link href="#" class="commit-sha">{{ shortSha }}</gl-link>
+ @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link>
</template>
</td>
<td class="d-none d-sm-table-cell tree-commit">
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index ea051eaa414..f9727960040 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -5,6 +5,7 @@ import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
+import { parseBoolean } from '../lib/utils/common_utils';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
@@ -36,19 +37,42 @@ export default function setupVueRepositoryList() {
.forEach(elem => elem.classList.toggle('hidden', !isRoot));
});
- // eslint-disable-next-line no-new
- new Vue({
- el: document.getElementById('js-repo-breadcrumb'),
- router,
- apolloProvider,
- render(h) {
- return h(Breadcrumbs, {
- props: {
- currentPath: this.$route.params.pathMatch,
- },
- });
- },
- });
+ const breadcrumbEl = document.getElementById('js-repo-breadcrumb');
+
+ if (breadcrumbEl) {
+ const {
+ canCollaborate,
+ canEditTree,
+ newBranchPath,
+ newTagPath,
+ newBlobPath,
+ forkNewBlobPath,
+ forkNewDirectoryPath,
+ forkUploadBlobPath,
+ } = breadcrumbEl.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: breadcrumbEl,
+ router,
+ apolloProvider,
+ render(h) {
+ return h(Breadcrumbs, {
+ props: {
+ currentPath: this.$route.params.pathMatch,
+ canCollaborate: parseBoolean(canCollaborate),
+ canEditTree: parseBoolean(canEditTree),
+ newBranchPath,
+ newTagPath,
+ newBlobPath,
+ forkNewBlobPath,
+ forkNewDirectoryPath,
+ forkUploadBlobPath,
+ },
+ });
+ },
+ });
+ }
// eslint-disable-next-line no-new
new Vue({
diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql
index 4c24fc4087f..c4814f8e63a 100644
--- a/app/assets/javascripts/repository/queries/getFiles.query.graphql
+++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql
@@ -1,3 +1,5 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
fragment TreeEntry on Entry {
id
name
@@ -5,11 +7,6 @@ fragment TreeEntry on Entry {
type
}
-fragment PageInfo on PageInfo {
- hasNextPage
- endCursor
-}
-
query getFiles(
$projectPath: ID!
$path: String
@@ -35,6 +32,8 @@ query getFiles(
edges {
node {
...TreeEntry
+ webUrl
+ treeUrl
}
}
pageInfo {
diff --git a/app/assets/javascripts/repository/queries/getPermissions.query.graphql b/app/assets/javascripts/repository/queries/getPermissions.query.graphql
new file mode 100644
index 00000000000..092fa44e2d0
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getPermissions.query.graphql
@@ -0,0 +1,9 @@
+query getPermissions($projectPath: ID!) {
+ project(fullPath: $projectPath) {
+ userPermissions {
+ pushCode
+ forkProject
+ createMergeRequestIn
+ }
+ }
+}
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 930c0d5e958..40a2158de78 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -101,10 +101,12 @@ Sidebar.prototype.toggleTodo = function(e) {
this.todoUpdateDone(data);
})
.catch(() =>
- flash(sprintf(__('There was an error %{message} todo.')), {
- message:
- ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
- }),
+ flash(
+ sprintf(__('There was an error %{message} todo.'), {
+ message:
+ ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
+ }),
+ ),
);
};
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index 32c9d6eccb8..a1a8cd3acbd 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import dateFormat from 'dateformat';
import { X_INTERVAL } from '../constants';
import { validateGraphData } from '../utils';
+import { __ } from '~/locale';
let debouncedResize;
@@ -42,7 +43,7 @@ export default {
},
generateSeries() {
return {
- name: 'Invocations',
+ name: __('Invocations'),
type: 'line',
data: this.chartData.requests.map(data => [data.time, data.value]),
symbolSize: 0,
@@ -124,7 +125,9 @@ export default {
<div class="prometheus-graph">
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ <div ref="graphWidgets" class="prometheus-graph-widgets">
+ <slot></slot>
+ </div>
</div>
<gl-area-chart
ref="areaChart"
@@ -135,12 +138,8 @@ export default {
:width="width"
:include-legend-avg-max="false"
>
- <template slot="tooltipTitle">
- {{ tooltipPopoverTitle }}
- </template>
- <template slot="tooltipContent">
- {{ tooltipPopoverContent }}
- </template>
+ <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template>
+ <template slot="tooltipContent">{{ tooltipPopoverContent }}</template>
</gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index b8906cfca4e..d542dad8119 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -89,7 +89,9 @@ export default {
}}
</p>
</div>
- <div v-else><p>No pods loaded at this time.</p></div>
+ <div v-else>
+ <p>{{ s__('ServerlessDetails|No pods loaded at this time.') }}</p>
+ </div>
<area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
<missing-prometheus
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 94341050b86..9e66869515c 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,4 +1,5 @@
<script>
+import { sprintf, s__ } from '~/locale';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
@@ -37,6 +38,28 @@ export default {
isInstalled() {
return this.installed === true;
},
+ noServerlessConfigFile() {
+ return sprintf(
+ s__(
+ 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.',
+ ),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
+ noGitlabYamlConfigured() {
+ return sprintf(
+ s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
+ mismatchedServerlessFunctions() {
+ return sprintf(
+ s__(
+ "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.",
+ ),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
},
created() {
this.fetchFunctions({
@@ -82,25 +105,29 @@ export default {
<h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4>
<p class="state-description">
{{
- s__(`Serverless|There is currently no function data available from Knative.
- This could be for a variety of reasons including:`)
+ s__(
+ 'Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:',
+ )
}}
</p>
<ul>
- <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li>
- <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li>
<li>
- The functions listed in the <code>serverless.yml</code> file don't match the namespace
- of your cluster.
+ {{ noServerlessConfigFile }}
+ </li>
+ <li>
+ {{ noGitlabYamlConfigured }}
+ </li>
+ <li>
+ {{ mismatchedServerlessFunctions }}
</li>
- <li>The deploy job has not finished.</li>
+ <li>{{ s__('Serverless|The deploy job has not finished.') }}</li>
</ul>
<p>
{{
- s__(`Serverless|If you believe none of these apply, please check
- back later as the function data may be in the process of becoming
- available.`)
+ s__(
+ 'Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available.',
+ )
}}
</p>
<div class="text-center">
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
new file mode 100644
index 00000000000..71a1fc31315
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue
@@ -0,0 +1,48 @@
+<script>
+import { __, sprintf } from '~/locale';
+
+export default {
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ imgSize: {
+ type: Number,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ computed: {
+ assigneeAlt() {
+ return sprintf(__("%{userName}'s avatar"), { userName: this.user.name });
+ },
+ avatarUrl() {
+ return this.user.avatar || this.user.avatar_url || gon.default_avatar_url;
+ },
+ isMergeRequest() {
+ return this.issuableType === 'merge_request';
+ },
+ hasMergeIcon() {
+ return this.isMergeRequest && !this.user.can_merge;
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="position-relative">
+ <img
+ :alt="assigneeAlt"
+ :src="avatarUrl"
+ :width="imgSize"
+ :class="`s${imgSize}`"
+ class="avatar avatar-inline m-0"
+ />
+ <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i>
+ </span>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
new file mode 100644
index 00000000000..6633a63d046
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue
@@ -0,0 +1,83 @@
+<script>
+import { __, sprintf } from '~/locale';
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { joinPaths } from '~/lib/utils/url_utility';
+import AssigneeAvatar from './assignee_avatar.vue';
+
+export default {
+ components: {
+ AssigneeAvatar,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ tooltipPlacement: {
+ type: String,
+ default: 'bottom',
+ required: false,
+ },
+ tooltipHasName: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ issuableType: {
+ type: String,
+ default: 'issue',
+ required: false,
+ },
+ },
+ computed: {
+ cannotMerge() {
+ return this.issuableType === 'merge_request' && !this.user.can_merge;
+ },
+ tooltipTitle() {
+ if (this.cannotMerge && this.tooltipHasName) {
+ return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name });
+ } else if (this.cannotMerge) {
+ return __('Cannot merge');
+ } else if (this.tooltipHasName) {
+ return this.user.name;
+ }
+
+ return '';
+ },
+ tooltipOption() {
+ return {
+ container: 'body',
+ placement: this.tooltipPlacement,
+ boundary: 'viewport',
+ };
+ },
+ assigneeUrl() {
+ return joinPaths(`${this.rootPath}`, `${this.user.username}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <!-- must be `d-inline-block` or parent flex-basis causes width issues -->
+ <gl-link
+ v-gl-tooltip="tooltipOption"
+ :href="assigneeUrl"
+ :title="tooltipTitle"
+ class="d-inline-block"
+ >
+ <!-- use d-flex so that slot can be appropriately styled -->
+ <span class="d-flex">
+ <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" />
+ <slot :user="user"></slot>
+ </span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
index 0ad2b3a73a2..63b93a80ead 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue
@@ -1,4 +1,7 @@
<script>
+import { n__ } from '~/locale';
+import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
+
export default {
name: 'AssigneeTitle',
props: {
@@ -24,7 +27,12 @@ export default {
computed: {
assigneeTitle() {
const assignees = this.numberOfAssignees;
- return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ return n__('Assignee', `%d Assignees`, assignees);
+ },
+ },
+ methods: {
+ trackEdit() {
+ trackEvent('click_edit_button', 'assignee');
},
},
};
@@ -32,18 +40,23 @@ export default {
<template>
<div class="title hide-collapsed">
{{ assigneeTitle }}
- <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"> </i>
- <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#">
+ <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i>
+ <a
+ v-if="editable"
+ class="js-sidebar-dropdown-toggle edit-link float-right"
+ href="#"
+ @click.prevent="trackEdit"
+ >
{{ __('Edit') }}
</a>
<a
v-if="showToggle"
- aria-label="Toggle sidebar"
+ :aria-label="__('Toggle sidebar')"
class="gutter-toggle float-right js-sidebar-toggle"
href="#"
role="button"
>
- <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"> </i>
+ <i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
</a>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index 0074d7099dc..d9739e8d197 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,11 +1,14 @@
<script>
-import { __ } from '~/locale';
-import tooltip from '~/vue_shared/directives/tooltip';
+import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue';
+import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue';
export default {
+ // name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Assignees',
- directives: {
- tooltip,
+ components: {
+ CollapsedAssigneeList,
+ UncollapsedAssigneeList,
},
props: {
rootPath: {
@@ -22,222 +25,52 @@ export default {
},
issuableType: {
type: String,
- require: true,
+ required: false,
default: 'issue',
},
},
- data() {
- return {
- defaultRenderCount: 5,
- defaultMaxCounter: 99,
- showLess: 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`);
- }
-
- if (!this.users.length) {
- const emptyTooltipLabel = __('Assignee(s)');
- names.push(emptyTooltipLabel);
- }
-
- return names.join(', ');
- },
- sidebarAvatarCounter() {
- let counter = `+${this.users.length - 1}`;
-
- if (this.users.length > this.defaultMaxCounter) {
- counter = `${this.defaultMaxCounter}+`;
- }
+ sortedAssigness() {
+ const canMergeUsers = this.users.filter(user => user.can_merge);
+ const canNotMergeUsers = this.users.filter(user => !user.can_merge);
- return counter;
- },
- mergeNotAllowedTooltipMessage() {
- const assigneesCount = this.users.length;
-
- if (this.issuableType !== 'merge_request' || assigneesCount === 0) {
- return null;
- }
-
- const cannotMergeCount = this.users.filter(u => u.can_merge === false).length;
- const canMergeCount = assigneesCount - cannotMergeCount;
-
- if (canMergeCount === assigneesCount) {
- // Everyone can merge
- return null;
- } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) {
- return 'No one can merge';
- } else if (assigneesCount === 1) {
- return 'Cannot merge';
- }
-
- return `${canMergeCount}/${assigneesCount} can merge`;
+ return [...canMergeUsers, ...canNotMergeUsers];
},
},
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 || gon.default_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;
- },
},
};
</script>
<template>
<div>
- <div
- v-tooltip
- :class="{ 'multiple-users': hasMoreThanOneAssignee }"
- :title="collapsedTooltipTitle"
- class="sidebar-collapsed-icon sidebar-collapsed-user"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
- >
- <i v-if="hasNoUsers" aria-label="None" class="fa fa-user"> </i>
- <button
- v-for="(user, index) in users"
- v-if="shouldRenderCollapsedAssignee(index)"
- :key="user.id"
- type="button"
- class="btn-link"
- >
- <img
- :alt="assigneeAlt(user)"
- :src="avatarUrl(user)"
- width="24"
- class="avatar avatar-inline s24"
- />
- <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>
+ <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
+
<div class="value hide-collapsed">
- <span
- v-if="mergeNotAllowedTooltipMessage"
- v-tooltip
- :title="mergeNotAllowedTooltipMessage"
- data-placement="left"
- class="float-right cannot-be-merged"
- >
- <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i>
- </span>
<template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself">
- None
+ {{ __('None') }}
<template v-if="editable">
- - <button type="button" class="btn-link" @click="assignSelf">assign yourself</button>
+ -
+ <button type="button" class="btn-link" @click="assignSelf">
+ {{ __('assign yourself') }}
+ </button>
</template>
</span>
</template>
- <template v-else-if="hasOneUser">
- <a :href="assigneeUrl(firstUser)" class="author-link bold">
- <img
- :alt="assigneeAlt(firstUser)"
- :src="avatarUrl(firstUser)"
- width="32"
- class="avatar avatar-inline s32"
- />
- <span class="author"> {{ firstUser.name }} </span>
- <span class="username"> {{ assigneeUsername(firstUser) }} </span>
- </a>
- </template>
- <template v-else>
- <div class="user-list">
- <div
- v-for="(user, index) in users"
- v-if="renderAssignee(index)"
- :key="user.id"
- class="user-item"
- >
- <a
- :href="assigneeUrl(user)"
- :data-title="user.name"
- class="user-link has-tooltip"
- data-container="body"
- data-placement="bottom"
- >
- <img
- :alt="assigneeAlt(user)"
- :src="avatarUrl(user)"
- width="32"
- class="avatar avatar-inline s32"
- />
- </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>
+
+ <uncollapsed-assignee-list
+ v-else
+ :users="sortedAssigness"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
new file mode 100644
index 00000000000..2f654409561
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue
@@ -0,0 +1,27 @@
+<script>
+import AssigneeAvatar from './assignee_avatar.vue';
+
+export default {
+ components: {
+ AssigneeAvatar,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+};
+</script>
+
+<template>
+ <button type="button" class="btn-link">
+ <assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" />
+ <span class="author"> {{ user.name }} </span>
+ </button>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
new file mode 100644
index 00000000000..5b4a43399ca
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue
@@ -0,0 +1,121 @@
+<script>
+import { __, sprintf } from '~/locale';
+import { GlTooltipDirective } from '@gitlab/ui';
+import CollapsedAssignee from './collapsed_assignee.vue';
+
+const DEFAULT_MAX_COUNTER = 99;
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ CollapsedAssignee,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ computed: {
+ isMergeRequest() {
+ return this.issuableType === 'merge_request';
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ allAssigneesCanMerge() {
+ return this.users.every(user => user.can_merge);
+ },
+ sidebarAvatarCounter() {
+ if (this.users.length > DEFAULT_MAX_COUNTER) {
+ return `${DEFAULT_MAX_COUNTER}+`;
+ }
+
+ return `+${this.users.length - 1}`;
+ },
+ collapsedUsers() {
+ const collapsedLength = this.hasMoreThanTwoAssignees ? 1 : this.users.length;
+
+ return this.users.slice(0, collapsedLength);
+ },
+ tooltipTitleMergeStatus() {
+ if (!this.isMergeRequest) {
+ return '';
+ }
+
+ const mergeLength = this.users.filter(u => u.can_merge).length;
+
+ if (mergeLength === this.users.length) {
+ return '';
+ } else if (mergeLength > 0) {
+ return sprintf(__('%{mergeLength}/%{usersLength} can merge'), {
+ mergeLength,
+ usersLength: this.users.length,
+ });
+ }
+
+ return this.users.length === 1 ? __('cannot merge') : __('no one can merge');
+ },
+ tooltipTitle() {
+ const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (!this.users.length) {
+ return __('Assignee(s)');
+ }
+
+ if (this.users.length > names.length) {
+ names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length }));
+ }
+
+ const text = names.join(', ');
+
+ return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text;
+ },
+
+ tooltipOptions() {
+ return { container: 'body', placement: 'left', boundary: 'viewport' };
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ v-gl-tooltip="tooltipOptions"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee }"
+ :title="tooltipTitle"
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ >
+ <i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i>
+ <collapsed-assignee
+ v-for="user in collapsedUsers"
+ :key="user.id"
+ :user="user"
+ :issuable-type="issuableType"
+ />
+ <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button">
+ <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span>
+ <i
+ v-if="isMergeRequest && !allAssigneesCanMerge"
+ aria-hidden="true"
+ class="fa fa-exclamation-triangle merge-icon"
+ ></i>
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index cfa7029b388..c6cc04a139f 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -2,8 +2,10 @@
import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import Store from '~/sidebar/stores/sidebar_store';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
+import { __ } from '~/locale';
export default {
name: 'SidebarAssignees',
@@ -27,7 +29,7 @@ export default {
},
issuableType: {
type: String,
- require: true,
+ required: false,
default: 'issue',
},
},
@@ -72,9 +74,12 @@ export default {
this.mediator
.saveAssignees(this.field)
.then(setLoadingFalse.bind(this))
+ .then(() => {
+ refreshUserMergeRequestCounts();
+ })
.catch(() => {
setLoadingFalse();
- return new Flash('Error occurred when saving assignees');
+ return new Flash(__('Error occurred when saving assignees'));
});
},
},
diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
new file mode 100644
index 00000000000..3a4623121f4
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -0,0 +1,96 @@
+<script>
+import { __, sprintf } from '~/locale';
+import AssigneeAvatarLink from './assignee_avatar_link.vue';
+
+const DEFAULT_RENDER_COUNT = 5;
+
+export default {
+ components: {
+ AssigneeAvatarLink,
+ },
+ props: {
+ users: {
+ type: Array,
+ required: true,
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ issuableType: {
+ type: String,
+ required: false,
+ default: 'issue',
+ },
+ },
+ data() {
+ return {
+ showLess: true,
+ };
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ hiddenAssigneesLabel() {
+ const { numberOfHiddenAssignees } = this;
+ return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees });
+ },
+ renderShowMoreSection() {
+ return this.users.length > DEFAULT_RENDER_COUNT;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - DEFAULT_RENDER_COUNT;
+ },
+ uncollapsedUsers() {
+ const uncollapsedLength = this.showLess
+ ? Math.min(this.users.length, DEFAULT_RENDER_COUNT)
+ : this.users.length;
+ return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users;
+ },
+ username() {
+ return `@${this.firstUser.username}`;
+ },
+ },
+ methods: {
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ },
+};
+</script>
+
+<template>
+ <assignee-avatar-link
+ v-if="hasOneUser"
+ v-slot="{ user }"
+ tooltip-placement="left"
+ :tooltip-has-name="false"
+ :user="firstUser"
+ :root-path="rootPath"
+ :issuable-type="issuableType"
+ >
+ <div class="ml-2">
+ <span class="author"> {{ user.name }} </span>
+ <span class="username"> {{ username }} </span>
+ </div>
+ </assignee-avatar-link>
+ <div v-else>
+ <div class="user-list">
+ <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item">
+ <assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" />
+ </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>
+ </div>
+</template>
diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
index 597b723a9d9..1c75b6148e8 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -5,6 +5,7 @@ import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
+import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
components: {
@@ -51,6 +52,11 @@ export default {
toggleForm() {
this.edit = !this.edit;
},
+ onEditClick() {
+ this.toggleForm();
+
+ trackEvent('click_edit_button', 'confidentiality');
+ },
updateConfidentialAttribute(confidential) {
this.service
.update('issue', { confidential })
@@ -82,7 +88,7 @@ export default {
v-if="isEditable"
class="float-right confidential-edit"
href="#"
- @click.prevent="toggleForm"
+ @click.prevent="onEditClick"
>
{{ __('Edit') }}
</a>
diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
index 4b9bb5c7b0e..5d0e39e8195 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import eventHub from '../../event_hub';
+import { __ } from '~/locale';
export default {
props: {
@@ -15,7 +16,7 @@ export default {
},
computed: {
toggleButtonText() {
- return this.isConfidential ? 'Turn Off' : 'Turn On';
+ return this.isConfidential ? __('Turn Off') : __('Turn On');
},
updateConfidentialBool() {
return !this.isConfidential;
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index c5cfa92f3c8..ec2a7b93a98 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -6,6 +6,7 @@ import issuableMixin from '~/vue_shared/mixins/issuable';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '~/sidebar/event_hub';
import editForm from './edit_form.vue';
+import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
export default {
components: {
@@ -65,7 +66,11 @@ export default {
toggleForm() {
this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen;
},
+ onEditClick() {
+ this.toggleForm();
+ trackEvent('click_edit_button', 'lock_issue');
+ },
updateLockedAttribute(locked) {
this.mediator.service
.update(this.issuableType, {
@@ -109,7 +114,7 @@ export default {
v-if="isEditable"
class="float-right lock-edit"
type="button"
- @click.prevent="toggleForm"
+ @click.prevent="onEditClick"
>
{{ __('Edit') }}
</button>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index a75daca156c..1f5f19d1931 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue';
import toggleButton from '~/vue_shared/components/toggle_button.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
+import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar';
const ICON_ON = 'notifications';
const ICON_OFF = 'notifications-off';
@@ -63,6 +64,8 @@ export default {
// Component event emission.
this.$emit('toggleSubscription', this.id);
+
+ trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1);
},
onClickCollapsedIcon() {
this.$emit('toggleSidebar');
@@ -73,22 +76,22 @@ export default {
<template>
<div>
- <div class="sidebar-collapsed-icon" @click="onClickCollapsedIcon">
- <span
- v-tooltip
- :title="notificationTooltip"
- data-container="body"
- data-placement="left"
- data-boundary="viewport"
- >
- <icon
- :name="notificationIcon"
- :size="16"
- aria-hidden="true"
- class="sidebar-item-icon is-active"
- />
- </span>
- </div>
+ <span
+ v-tooltip
+ class="sidebar-collapsed-icon"
+ :title="notificationTooltip"
+ data-container="body"
+ data-placement="left"
+ data-boundary="viewport"
+ @click="onClickCollapsedIcon"
+ >
+ <icon
+ :name="notificationIcon"
+ :size="16"
+ aria-hidden="true"
+ class="sidebar-item-icon is-active"
+ />
+ </span>
<span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span>
<toggle-button
ref="toggleButton"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
index 657ac837baf..24d5b14ded9 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.vue
@@ -79,7 +79,7 @@ export default {
} else if (this.showSpentOnlyState) {
return `${this.timeSpent} / --`;
} else if (this.showNoTimeTrackingState) {
- return 'None';
+ return __('None');
}
return '';
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index bc263bc36e4..06aca547183 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -2,6 +2,7 @@
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
import tooltip from '../../../vue_shared/directives/tooltip';
import { GlProgressBar } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
export default {
name: 'TimeTrackingComparisonPane',
@@ -43,8 +44,14 @@ export default {
return stringifyTime(this.parsedTimeRemaining);
},
timeRemainingTooltip() {
- const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
- return `${prefix} ${this.timeRemainingHumanReadable}`;
+ const { timeRemainingHumanReadable, timeRemainingMinutes } = this;
+ return timeRemainingMinutes < 0
+ ? sprintf(s__('TimeTracking|Over by %{timeRemainingHumanReadable}'), {
+ timeRemainingHumanReadable,
+ })
+ : sprintf(s__('TimeTracking|Time remaining: %{timeRemainingHumanReadable}'), {
+ timeRemainingHumanReadable,
+ });
},
/* Diff values for comparison meter */
timeRemainingMinutes() {
@@ -74,12 +81,12 @@ export default {
<gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" />
<div class="compare-display-container">
<div class="compare-display float-left">
- <span class="compare-label"> {{ s__('TimeTracking|Spent') }} </span>
- <span class="compare-value spent"> {{ timeSpentHumanReadable }} </span>
+ <span class="compare-label">{{ s__('TimeTracking|Spent') }}</span>
+ <span class="compare-value spent">{{ timeSpentHumanReadable }}</span>
</div>
<div class="compare-display estimated float-right">
- <span class="compare-label"> {{ s__('TimeTrackingEstimated|Est') }} </span>
- <span class="compare-value"> {{ timeEstimateHumanReadable }} </span>
+ <span class="compare-label">{{ s__('TimeTrackingEstimated|Est') }}</span>
+ <span class="compare-value">{{ timeEstimateHumanReadable }}</span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
index 7c7356e2afa..c2f30310e2e 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue
@@ -1,4 +1,6 @@
<script>
+import { sprintf, s__ } from '~/locale';
+
export default {
name: 'TimeTrackingSpentOnlyPane',
props: {
@@ -7,11 +9,22 @@ export default {
required: true,
},
},
+ computed: {
+ timeSpent() {
+ return sprintf(
+ s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'),
+ {
+ startTag: '<span class="bold">',
+ endTag: '</span>',
+ timeSpentHumanReadable: this.timeSpentHumanReadable,
+ },
+ false,
+ );
+ },
+ },
};
</script>
<template>
- <div class="time-tracking-spend-only-pane">
- <span class="bold">Spent:</span> {{ timeSpentHumanReadable }}
- </div>
+ <div class="time-tracking-spend-only-pane" v-html="timeSpent"></div>
</template>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 57125c78cf6..e6f2fe2b5fc 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -5,8 +5,8 @@ import { GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-const MARK_TEXT = __('Mark todo as done');
-const TODO_TEXT = __('Add todo');
+const MARK_TEXT = __('Mark as done');
+const TODO_TEXT = __('Add a To Do');
export default {
directives: {
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index 70f89152f70..97afeecd8ac 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -18,7 +18,7 @@ export default class Star {
const isStarred = $starSpan.hasClass('starred');
$this
.parent()
- .find('.star-count')
+ .find('.count')
.text(data.star_count);
if (isStarred) {
diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js
index be9ebc81c6b..c9bf234fcce 100644
--- a/app/assets/javascripts/test_utils/simulate_drag.js
+++ b/app/assets/javascripts/test_utils/simulate_drag.js
@@ -153,7 +153,11 @@ export default function simulateDrag(options) {
if (progress >= 1) {
if (options.ondragend) options.ondragend();
- simulateEvent(toEl, 'mouseup');
+
+ if (options.performDrop) {
+ simulateEvent(toEl, 'mouseup');
+ }
+
clearInterval(dragInterval);
window.SIMULATE_DRAG_ACTIVE = 0;
}
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
new file mode 100644
index 00000000000..a852f937eec
--- /dev/null
+++ b/app/assets/javascripts/tracking.js
@@ -0,0 +1,73 @@
+import $ from 'jquery';
+
+const extractData = (el, opts = {}) => {
+ const { trackEvent, trackLabel = '', trackProperty = '' } = el.dataset;
+ let trackValue = el.dataset.trackValue || el.value || '';
+ if (el.type === 'checkbox' && !el.checked) trackValue = false;
+ return [
+ trackEvent + (opts.suffix || ''),
+ {
+ label: trackLabel,
+ property: trackProperty,
+ value: trackValue,
+ },
+ ];
+};
+
+export default class Tracking {
+ static trackable() {
+ return !['1', 'yes'].includes(
+ window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack,
+ );
+ }
+
+ static enabled() {
+ return typeof window.snowplow === 'function' && this.trackable();
+ }
+
+ static event(category = document.body.dataset.page, event = 'generic', data = {}) {
+ if (!this.enabled()) return false;
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ if (!category) throw new Error('Tracking: no category provided for tracking.');
+
+ return window.snowplow(
+ 'trackStructEvent',
+ category,
+ event,
+ Object.assign({}, { label: '', property: '', value: '' }, data),
+ );
+ }
+
+ constructor(category = document.body.dataset.page) {
+ this.category = category;
+ }
+
+ bind(container = document) {
+ if (!this.constructor.enabled()) return;
+ container.querySelectorAll(`[data-track-event]`).forEach(el => {
+ if (this.customHandlingFor(el)) return;
+ // jquery is required for select2, so we use it always
+ // see: https://github.com/select2/select2/issues/4686
+ $(el).on('click', this.eventHandler(this.category));
+ });
+ }
+
+ customHandlingFor(el) {
+ const classes = el.classList;
+
+ // bootstrap dropdowns
+ if (classes.contains('dropdown')) {
+ $(el).on('show.bs.dropdown', this.eventHandler(this.category, { suffix: '_show' }));
+ $(el).on('hide.bs.dropdown', this.eventHandler(this.category, { suffix: '_hide' }));
+ return true;
+ }
+
+ return false;
+ }
+
+ eventHandler(category = null, opts = {}) {
+ return e => {
+ this.constructor.event(category || this.category, ...extractData(e.currentTarget, opts));
+ };
+ }
+}
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 33cedf78331..12c939aa70f 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -62,6 +62,8 @@ function UsersSelect(currentUser, els, options = {}) {
options.showCurrentUser = $dropdown.data('currentUser');
options.todoFilter = $dropdown.data('todoFilter');
options.todoStateFilter = $dropdown.data('todoStateFilter');
+ options.iid = $dropdown.data('iid');
+ options.issuableType = $dropdown.data('issuableType');
showNullUser = $dropdown.data('nullUser');
defaultNullUser = $dropdown.data('nullUserDefault');
showMenuAbove = $dropdown.data('showMenuAbove');
@@ -239,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) {
'<% 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">
+ `<% 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">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
closingTag: '</a>',
@@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
+ $el.tooltip('dispose');
+
if ($dropdown.hasClass('js-multiselect')) {
const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown
@@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) {
user.name,
)}</a></li>`;
} else {
- img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
+ // 0 margin, because it's now handled by a wrapper
+ img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' 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'>
- ${_.escape(user.name)}
- </strong>
- ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
- </a>
- </li>
- `;
+ return _this.renderRow(options.issuableType, user, selected, username, img);
},
});
};
@@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) {
author_id: options.authorId || null,
skip_users: options.skipUsers || null,
};
+
+ if (options.issuableType === 'merge_request') {
+ params.merge_request_iid = options.iid || null;
+ }
+
return axios.get(url, { params }).then(({ data }) => {
callback(data);
});
@@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(url) {
return url;
};
+UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) {
+ const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : '';
+ const tooltipClass = tooltip ? `has-tooltip` : '';
+ const selectedClass = selected === true ? 'is-active' : '';
+ const linkClasses = `${selectedClass} ${tooltipClass}`;
+ const tooltipAttributes = tooltip
+ ? `data-container="body" data-placement="left" data-title="${tooltip}"`
+ : '';
+
+ return `
+ <li data-user-id=${user.id}>
+ <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
+ ${this.renderRowAvatar(issuableType, user, img)}
+ <span class="d-flex flex-column overflow-hidden">
+ <strong class="dropdown-menu-user-full-name">
+ ${_.escape(user.name)}
+ </strong>
+ ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
+ </span>
+ </a>
+ </li>
+ `;
+};
+
+UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) {
+ if (user.beforeDivider) {
+ return img;
+ }
+
+ const mergeIcon =
+ issuableType === 'merge_request' && !user.can_merge
+ ? '<i class="fa fa-exclamation-triangle merge-icon"></i>'
+ : '';
+
+ return `<span class="position-relative mr-2">
+ ${img}
+ ${mergeIcon}
+ </span>`;
+};
+
export default UsersSelect;
diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js
deleted file mode 100644
index 04bfb5e9532..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/comment.js
+++ /dev/null
@@ -1,148 +0,0 @@
-import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants';
-import { clearNote, postError } from './note';
-import {
- buttonClearStyles,
- selectCommentBox,
- selectCommentButton,
- selectNote,
- selectNoteContainer,
-} from './utils';
-
-const comment = `
- <div>
- <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea>
- <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p>
- </div>
- <div class="gitlab-button-wrapper">
- <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button>
- <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button>
- </div>
-`;
-
-const resetCommentButton = () => {
- const commentButton = selectCommentButton();
-
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- commentButton.innerText = 'Send feedback';
- commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success');
- commentButton.style.opacity = 1;
-};
-
-const resetCommentBox = () => {
- const commentBox = selectCommentBox();
- commentBox.style.pointerEvents = 'auto';
- commentBox.style.color = BLACK;
-};
-
-const resetCommentText = () => {
- const commentBox = selectCommentBox();
- commentBox.value = '';
-};
-
-const resetComment = () => {
- resetCommentButton();
- resetCommentBox();
- resetCommentText();
-};
-
-const confirmAndClear = feedbackInfo => {
- const commentButton = selectCommentButton();
- const currentNote = selectNote();
- const noteContainer = selectNoteContainer();
-
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- commentButton.innerText = 'Feedback sent';
- noteContainer.style.visibility = 'visible';
- currentNote.insertAdjacentHTML('beforeend', feedbackInfo);
-
- setTimeout(resetComment, 1000);
- setTimeout(clearNote, 6000);
-};
-
-const setInProgressState = () => {
- const commentButton = selectCommentButton();
- const commentBox = selectCommentBox();
-
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- commentButton.innerText = 'Sending feedback';
- commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary');
- commentButton.style.opacity = 0.5;
- commentBox.style.color = MUTED;
- commentBox.style.pointerEvents = 'none';
-};
-
-const postComment = ({
- href,
- platform,
- browser,
- userAgent,
- innerWidth,
- innerHeight,
- projectId,
- projectPath,
- mergeRequestId,
- mrUrl,
- token,
-}) => {
- // Clear any old errors
- clearNote(COMMENT_BOX);
-
- setInProgressState();
-
- const commentText = selectCommentBox().value.trim();
-
- if (!commentText) {
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- postError('Your comment appears to be empty.', COMMENT_BOX);
- resetCommentBox();
- resetCommentButton();
- return;
- }
-
- const detailText = `
- \n
-<details>
- <summary>Metadata</summary>
- Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}.
- <br /><br />
- <em>User agent: ${userAgent}</em>
-</details>
- `;
-
- const url = `
- ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`;
-
- const body = `${commentText} ${detailText}`;
-
- fetch(url, {
- method: 'POST',
- headers: {
- 'PRIVATE-TOKEN': token,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ body }),
- })
- .then(response => {
- if (response.ok) {
- return response.json();
- }
-
- throw new Error(`${response.status}: ${response.statusText}`);
- })
- .then(data => {
- const commentId = data.notes[0].id;
- const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`;
- const feedbackInfo = `Feedback sent. View at <a class="gitlab-link" href="${feedbackLink}">${projectPath} #${mergeRequestId} (comment ${commentId})</a>`;
- confirmAndClear(feedbackInfo);
- })
- .catch(err => {
- postError(
- `Your comment could not be sent. Please try again. Error: ${err.message}`,
- COMMENT_BOX,
- );
- resetCommentBox();
- resetCommentButton();
- });
-};
-
-export { comment, postComment };
diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/components/constants.js
deleted file mode 100644
index 07fcb179d15..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/constants.js
+++ /dev/null
@@ -1,41 +0,0 @@
-// component selectors
-const COLLAPSE_BUTTON = 'gitlab-collapse';
-const COMMENT_BOX = 'gitlab-comment';
-const COMMENT_BUTTON = 'gitlab-comment-button';
-const FORM = 'gitlab-form';
-const FORM_CONTAINER = 'gitlab-form-wrapper';
-const LOGIN = 'gitlab-login';
-const LOGOUT = 'gitlab-logout-button';
-const NOTE = 'gitlab-validation-note';
-const NOTE_CONTAINER = 'gitlab-note-wrapper';
-const REMEMBER_TOKEN = 'gitlab-remember_token';
-const REVIEW_CONTAINER = 'gitlab-review-container';
-const TOKEN_BOX = 'gitlab-token';
-
-// colors — these are applied programmatically
-// rest of styles belong in ./styles
-const BLACK = 'rgba(46, 46, 46, 1)';
-const CLEAR = 'rgba(255, 255, 255, 0)';
-const MUTED = 'rgba(223, 223, 223, 0.5)';
-const RED = 'rgba(219, 59, 33, 1)';
-const WHITE = 'rgba(250, 250, 250, 1)';
-
-export {
- COLLAPSE_BUTTON,
- COMMENT_BOX,
- COMMENT_BUTTON,
- FORM,
- FORM_CONTAINER,
- LOGIN,
- LOGOUT,
- NOTE,
- NOTE_CONTAINER,
- REMEMBER_TOKEN,
- REVIEW_CONTAINER,
- TOKEN_BOX,
- BLACK,
- CLEAR,
- MUTED,
- RED,
- WHITE,
-};
diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js
deleted file mode 100644
index 50b52d7d3a2..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { comment, postComment } from './comment';
-import {
- COLLAPSE_BUTTON,
- COMMENT_BUTTON,
- FORM_CONTAINER,
- LOGIN,
- LOGOUT,
- REVIEW_CONTAINER,
-} from './constants';
-import { authorizeUser, login } from './login';
-import { note } from './note';
-import { selectContainer } from './utils';
-import { buttonAndForm, logoutUser, toggleForm } from './wrapper';
-import { collapseButton } from './wrapper_icons';
-
-export {
- authorizeUser,
- buttonAndForm,
- collapseButton,
- comment,
- login,
- logoutUser,
- note,
- postComment,
- selectContainer,
- toggleForm,
- COLLAPSE_BUTTON,
- COMMENT_BUTTON,
- FORM_CONTAINER,
- LOGIN,
- LOGOUT,
- REVIEW_CONTAINER,
-};
diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js
deleted file mode 100644
index 0a71299f041..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/login.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants';
-import { clearNote, postError } from './note';
-import { buttonClearStyles, selectRemember, selectToken } from './utils';
-import { addCommentForm } from './wrapper';
-
-const login = `
- <div>
- <label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label>
- <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password">
- </div>
- <div class="gitlab-checkbox-wrapper">
- <input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember">
- <label for="${REMEMBER_TOKEN}" class="gitlab-checkbox-label">Remember me</label>
- </div>
- <div class="gitlab-button-wrapper">
- <button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${LOGIN}"> Submit </button>
- </div>
-`;
-
-const storeToken = (token, state) => {
- const { localStorage } = window;
- const rememberMe = selectRemember().checked;
-
- // All the browsers we support have localStorage, so let's silently fail
- // and go on with the rest of the functionality.
- try {
- if (rememberMe) {
- localStorage.setItem('token', token);
- }
- } finally {
- state.token = token;
- }
-};
-
-const authorizeUser = state => {
- // Clear any old errors
- clearNote(TOKEN_BOX);
-
- const token = selectToken().value;
-
- if (!token) {
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- postError('Please enter your token.', TOKEN_BOX);
- return;
- }
-
- storeToken(token, state);
- addCommentForm();
-};
-
-export { authorizeUser, login };
diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js
deleted file mode 100644
index 0150f640aae..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/note.js
+++ /dev/null
@@ -1,35 +0,0 @@
-import { NOTE, NOTE_CONTAINER, RED } from './constants';
-import { selectById, selectNote, selectNoteContainer } from './utils';
-
-const note = `
- <div id="${NOTE_CONTAINER}" style="visibility: hidden;">
- <p id="${NOTE}" class="gitlab-message"></p>
- </div>
-`;
-
-const clearNote = inputId => {
- const currentNote = selectNote();
- const noteContainer = selectNoteContainer();
-
- currentNote.innerText = '';
- currentNote.style.color = '';
- noteContainer.style.visibility = 'hidden';
-
- if (inputId) {
- const field = document.getElementById(inputId);
- field.style.borderColor = '';
- }
-};
-
-const postError = (message, inputId) => {
- const currentNote = selectNote();
- const noteContainer = selectNoteContainer();
- const field = selectById(inputId);
- field.style.borderColor = RED;
- currentNote.style.color = RED;
- currentNote.innerText = message;
- noteContainer.style.visibility = 'visible';
- setTimeout(clearNote.bind(null, inputId), 5000);
-};
-
-export { clearNote, note, postError };
diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js
deleted file mode 100644
index 00f4460925d..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/utils.js
+++ /dev/null
@@ -1,48 +0,0 @@
-/* global document */
-
-import {
- COLLAPSE_BUTTON,
- COMMENT_BOX,
- COMMENT_BUTTON,
- FORM,
- FORM_CONTAINER,
- NOTE,
- NOTE_CONTAINER,
- REMEMBER_TOKEN,
- REVIEW_CONTAINER,
- TOKEN_BOX,
-} from './constants';
-
-// this style must be applied inline in a handful of components
-/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
-const buttonClearStyles = `
- -webkit-appearance: none;
-`;
-
-// selector functions to abstract out a little
-const selectById = id => document.getElementById(id);
-const selectCollapseButton = () => document.getElementById(COLLAPSE_BUTTON);
-const selectCommentBox = () => document.getElementById(COMMENT_BOX);
-const selectCommentButton = () => document.getElementById(COMMENT_BUTTON);
-const selectContainer = () => document.getElementById(REVIEW_CONTAINER);
-const selectForm = () => document.getElementById(FORM);
-const selectFormContainer = () => document.getElementById(FORM_CONTAINER);
-const selectNote = () => document.getElementById(NOTE);
-const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER);
-const selectRemember = () => document.getElementById(REMEMBER_TOKEN);
-const selectToken = () => document.getElementById(TOKEN_BOX);
-
-export {
- buttonClearStyles,
- selectById,
- selectCollapseButton,
- selectContainer,
- selectCommentBox,
- selectCommentButton,
- selectForm,
- selectFormContainer,
- selectNote,
- selectNoteContainer,
- selectRemember,
- selectToken,
-};
diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js
deleted file mode 100644
index f2eaf1d7916..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { comment } from './comment';
-import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants';
-import { login } from './login';
-import { clearNote } from './note';
-import {
- selectCollapseButton,
- selectForm,
- selectFormContainer,
- selectNoteContainer,
-} from './utils';
-import { commentIcon, compressIcon } from './wrapper_icons';
-
-const form = content => `
- <form id="${FORM}">
- ${content}
- </form>
-`;
-
-const buttonAndForm = ({ content, toggleButton }) => `
- <div id="${FORM_CONTAINER}" class="gitlab-form-open">
- ${toggleButton}
- ${form(content)}
- </div>
-`;
-
-const addCommentForm = () => {
- const formWrapper = selectForm();
- formWrapper.innerHTML = comment;
-};
-
-const addLoginForm = () => {
- const formWrapper = selectForm();
- formWrapper.innerHTML = login;
-};
-
-function logoutUser() {
- const { localStorage } = window;
-
- // All the browsers we support have localStorage, so let's silently fail
- // and go on with the rest of the functionality.
- try {
- localStorage.removeItem('token');
- } catch (err) {
- return;
- }
-
- clearNote();
- addLoginForm();
-}
-
-function toggleForm() {
- const collapseButton = selectCollapseButton();
- const currentForm = selectForm();
- const formContainer = selectFormContainer();
- const noteContainer = selectNoteContainer();
- const OPEN = 'open';
- const CLOSED = 'closed';
-
- /*
- You may wonder why we spread the arrays before we reverse them.
- In the immortal words of MDN,
- Careful: reverse is destructive. It also changes the original array
- */
-
- const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open'];
- const closedButtonClasses = [...openButtonClasses].reverse();
- const openContainerClasses = ['gitlab-wrapper-closed', 'gitlab-wrapper-open'];
- const closedContainerClasses = [...openContainerClasses].reverse();
-
- const stateVals = {
- [OPEN]: {
- buttonClasses: openButtonClasses,
- containerClasses: openContainerClasses,
- icon: compressIcon,
- display: 'flex',
- backgroundColor: WHITE,
- },
- [CLOSED]: {
- buttonClasses: closedButtonClasses,
- containerClasses: closedContainerClasses,
- icon: commentIcon,
- display: 'none',
- backgroundColor: CLEAR,
- },
- };
-
- const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN;
- const currentVals = stateVals[nextState];
-
- formContainer.classList.replace(...currentVals.containerClasses);
- formContainer.style.backgroundColor = currentVals.backgroundColor;
- formContainer.classList.toggle('gitlab-form-open');
- currentForm.style.display = currentVals.display;
- collapseButton.classList.replace(...currentVals.buttonClasses);
- collapseButton.innerHTML = currentVals.icon;
-
- if (noteContainer && noteContainer.innerText.length > 0) {
- noteContainer.style.display = currentVals.display;
- }
-}
-
-export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm };
diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js
deleted file mode 100644
index b686fd4f5c2..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { buttonClearStyles } from './utils';
-
-const commentIcon = `
- <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/comment</title><path d="M4 11.132l1.446-.964A1 1 0 0 1 6 10h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v6.132zM6.303 12l-2.748 1.832A1 1 0 0 1 2 13V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6.303z" id="gitlab-comment-icon"/></svg>
-`;
-
-const compressIcon = `
- <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/compress</title><path d="M5.27 12.182l-1.562 1.561a1 1 0 0 1-1.414 0h-.001a1 1 0 0 1 0-1.415l1.56-1.56L2.44 9.353a.5.5 0 0 1 .353-.854H7.09a.5.5 0 0 1 .5.5v4.294a.5.5 0 0 1-.853.353l-1.467-1.465zm6.911-6.914l1.464 1.464a.5.5 0 0 1-.353.854H8.999a.5.5 0 0 1-.5-.5V2.793a.5.5 0 0 1 .854-.354l1.414 1.415 1.56-1.561a1 1 0 1 1 1.415 1.414l-1.561 1.56z" id="gitlab-compress-icon"/></svg>
-`;
-
-const collapseButton = `
- <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-button gitlab-button-secondary gitlab-collapse gitlab-collapse-open'>${compressIcon}</button>
-`;
-
-export { commentIcon, compressIcon, collapseButton };
diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js
deleted file mode 100644
index f94eb88835a..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import './styles/toolbar.css';
-
-import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components';
-import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store';
-
-/*
-
- Welcome to the visual review toolbar files. A few useful notes:
-
- - These files build a static script that is served from our webpack
- assets folder. (https://gitlab.com/assets/webpack/visual_review_toolbar.js)
-
- - To compile this file, run `yarn webpack-vrt`.
-
- - Vue is not used in these files because we do not want to ask users to
- install another library at this time. It's all pure vanilla javascript.
-
-*/
-
-window.addEventListener('load', () => {
- initializeState(window, document);
-
- const mainContent = buttonAndForm(getInitialView(window));
- const container = document.createElement('div');
- container.setAttribute('id', REVIEW_CONTAINER);
- container.insertAdjacentHTML('beforeend', note);
- container.insertAdjacentHTML('beforeend', mainContent);
-
- document.body.insertBefore(container, document.body.firstChild);
-
- selectContainer().addEventListener('click', event => {
- eventLookup(event)();
- });
-
- window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200));
-});
diff --git a/app/assets/javascripts/visual_review_toolbar/store/events.js b/app/assets/javascripts/visual_review_toolbar/store/events.js
deleted file mode 100644
index 93996be8473..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/store/events.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import {
- authorizeUser,
- logoutUser,
- postComment,
- toggleForm,
- COLLAPSE_BUTTON,
- COMMENT_BUTTON,
- LOGIN,
- LOGOUT,
-} from '../components';
-
-import { state } from './state';
-
-const noop = () => {};
-
-const eventLookup = ({ target: { id } }) => {
- switch (id) {
- case COLLAPSE_BUTTON:
- return toggleForm;
- case COMMENT_BUTTON:
- return postComment.bind(null, state);
- case LOGIN:
- return authorizeUser.bind(null, state);
- case LOGOUT:
- return logoutUser;
- default:
- return noop;
- }
-};
-
-const updateWindowSize = wind => {
- state.innerWidth = wind.innerWidth;
- state.innerHeight = wind.innerHeight;
-};
-
-export { eventLookup, updateWindowSize };
diff --git a/app/assets/javascripts/visual_review_toolbar/store/index.js b/app/assets/javascripts/visual_review_toolbar/store/index.js
deleted file mode 100644
index 7143588c0bf..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/store/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { eventLookup, updateWindowSize } from './events';
-import { getInitialView, initializeState } from './state';
-import debounce from './utils';
-
-export { debounce, eventLookup, getInitialView, initializeState, updateWindowSize };
diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js
deleted file mode 100644
index 22702d524b8..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/store/state.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import { comment, login, collapseButton } from '../components';
-
-const state = {
- browser: '',
- href: '',
- innerWidth: '',
- innerHeight: '',
- mergeRequestId: '',
- mrUrl: '',
- platform: '',
- projectId: '',
- userAgent: '',
- token: '',
-};
-
-// adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator#Example_2_Browser_detect_and_return_an_index
-const getBrowserId = sUsrAg => {
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- const aKeys = ['MSIE', 'Edge', 'Firefox', 'Safari', 'Chrome', 'Opera'];
- let nIdx = aKeys.length - 1;
-
- for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx -= 1);
- return aKeys[nIdx];
-};
-
-const initializeState = (wind, doc) => {
- const {
- innerWidth,
- innerHeight,
- location: { href },
- navigator: { platform, userAgent },
- } = wind;
-
- const browser = getBrowserId(userAgent);
-
- const scriptEl = doc.getElementById('review-app-toolbar-script');
- const { projectId, mergeRequestId, mrUrl, projectPath } = scriptEl.dataset;
-
- // This mutates our default state object above. It's weird but it makes the linter happy.
- Object.assign(state, {
- browser,
- href,
- innerWidth,
- innerHeight,
- mergeRequestId,
- mrUrl,
- platform,
- projectId,
- projectPath,
- userAgent,
- });
-};
-
-function getInitialView({ localStorage }) {
- const loginView = {
- content: login,
- toggleButton: collapseButton,
- };
-
- const commentView = {
- content: comment,
- toggleButton: collapseButton,
- };
-
- try {
- const token = localStorage.getItem('token');
-
- if (token) {
- state.token = token;
- return commentView;
- }
- return loginView;
- } catch (err) {
- return loginView;
- }
-}
-
-export { initializeState, getInitialView, state };
diff --git a/app/assets/javascripts/visual_review_toolbar/store/utils.js b/app/assets/javascripts/visual_review_toolbar/store/utils.js
deleted file mode 100644
index 5cf145351b3..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/store/utils.js
+++ /dev/null
@@ -1,15 +0,0 @@
-const debounce = (fn, time) => {
- let current;
-
- const debounced = () => {
- if (current) {
- clearTimeout(current);
- }
-
- current = setTimeout(fn, time);
- };
-
- return debounced;
-};
-
-export default debounce;
diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
deleted file mode 100644
index 00a55c0027a..00000000000
--- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
+++ /dev/null
@@ -1,177 +0,0 @@
-/*
- As a standalone script, the toolbar has its own css
- */
-
-#gitlab-collapse > * {
- pointer-events: none;
-}
-
-#gitlab-comment {
- background-color: #fafafa;
-}
-
-#gitlab-form {
- display: flex;
- flex-direction: column;
- width: 100%;
- margin-bottom: 0;
-}
-
-#gitlab-note-wrapper {
- display: flex;
- flex-direction: column;
- background-color: #fafafa;
- border-radius: 4px;
- margin-bottom: .5rem;
- padding: 1rem;
-}
-
-#gitlab-form-wrapper {
- overflow: auto;
- display: flex;
- flex-direction: row-reverse;
- border-radius: 4px;
-}
-
-#gitlab-review-container {
- max-width: 22rem;
- max-height: 22rem;
- overflow: auto;
- display: flex;
- flex-direction: column;
- position: fixed;
- bottom: 1rem;
- right: 1rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
- 'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
- 'Noto Color Emoji';
- font-size: .8rem;
- font-weight: 400;
- color: #2e2e2e;
-}
-
-.gitlab-wrapper-open {
- max-width: 22rem;
- max-height: 22rem;
-}
-
-.gitlab-wrapper-closed {
- max-width: 3.4rem;
- max-height: 3.4rem;
-}
-
-.gitlab-button {
- cursor: pointer;
- transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear;
-}
-
-.gitlab-button-secondary {
- background: none #fafafa;
- margin: 0 .5rem;
- border: 1px solid #e3e3e3;
-}
-
-.gitlab-button-secondary:hover {
- background-color: #f0f0f0;
- border-color: #e3e3e3;
- color: #2e2e2e;
-}
-
-.gitlab-button-secondary:active {
- color: #2e2e2e;
- background-color: #e1e1e1;
- border-color: #dadada;
-}
-
-.gitlab-button-success:hover {
- color: #fff;
- background-color: #137e3f;
- border-color: #127339;
-}
-
-.gitlab-button-success:active {
- background-color: #168f48;
- border-color: #12753a;
- color: #fff;
-}
-
-.gitlab-button-success {
- background-color: #1aaa55;
- border: 1px solid #168f48;
- color: #fff;
-}
-
-.gitlab-button-wide {
- width: 100%;
-}
-
-.gitlab-button-wrapper {
- margin-top: 1rem;
- display: flex;
- align-items: baseline;
- justify-content: flex-end;
-}
-
-.gitlab-collapse {
- width: 2.4rem;
- height: 2.2rem;
- margin-left: 1rem;
- padding: .5rem;
-}
-
-.gitlab-collapse-closed {
- align-self: center;
-}
-
-.gitlab-checkbox-label {
- padding: 0 .2rem;
-}
-
-.gitlab-checkbox-wrapper {
- display: flex;
- align-items: baseline;
-}
-
-.gitlab-form-open {
- padding: 1rem;
- background-color: #fafafa;
-}
-
-.gitlab-label {
- font-weight: 600;
- display: inline-block;
- width: 100%;
-}
-
-.gitlab-link {
- color: #1b69b6;
- text-decoration: none;
- background-color: transparent;
- background-image: none;
-}
-
-.gitlab-link:hover {
- text-decoration: underline;
-}
-
-.gitlab-message {
- padding: .25rem 0;
- margin: 0;
- line-height: 1.2rem;
-}
-
-.gitlab-metadata-note {
- font-size: .7rem;
- line-height: 1rem;
- color: #666;
- margin-bottom: 0;
-}
-
-.gitlab-input {
- width: 100%;
- border: 1px solid #dfdfdf;
- border-radius: 4px;
- padding: .1rem .2rem;
- min-height: 2rem;
- max-width: 17rem;
-}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
index 34cdb70ce14..bb6921225c2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -14,6 +14,8 @@ import ReviewAppLink from './review_app_link.vue';
import MRWidgetService from '../services/mr_widget_service';
export default {
+ // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Deployment',
components: {
LoadingButton,
@@ -125,7 +127,9 @@ export default {
this.isStopping = false;
})
.catch(() => {
- createFlash('Something went wrong while stopping this environment. Please try again.');
+ createFlash(
+ __('Something went wrong while stopping this environment. Please try again.'),
+ );
this.isStopping = false;
});
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index e20a16900d4..fb826be19f5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -139,7 +139,7 @@ export default {
type="button"
class="btn dropdown-toggle qa-dropdown-toggle"
data-toggle="dropdown"
- aria-label="Download as"
+ :aria-label="__('Download as')"
aria-haspopup="true"
aria-expanded="false"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
index 4b57693e8f1..57d4d8b7ae6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_icon.vue
@@ -14,6 +14,6 @@ export default {
<template>
<div class="circle-icon-container append-right-default align-self-start align-self-lg-center">
- <icon :name="name" />
+ <icon :name="name" :size="24" />
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
index a347269c916..53bf9d5ab6f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_merge_help.vue
@@ -23,7 +23,7 @@ export default {
};
</script>
<template>
- <section class="mr-widget-help">
+ <section class="mr-widget-help font-italic">
<template v-if="missingBranch">
{{ missingBranchInfo }}
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index f5fa68308bc..40c095aa954 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -96,16 +96,14 @@ export default {
<template>
<div class="ci-widget media js-ci-widget">
<template v-if="!hasPipeline || hasCIError">
- <div
- class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default"
- >
- <icon :size="32" name="status_failed_borderless" />
+ <div class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error">
+ <icon :size="24" name="status_failed_borderless" />
</div>
- <div class="media-body" v-html="errorText"></div>
+ <div class="media-body prepend-left-default" v-html="errorText"></div>
</template>
<template v-else-if="hasPipeline">
<a :href="status.details_path" class="align-self-start append-right-default">
- <ci-icon :status="status" :size="32" :borderless="true" class="add-border" />
+ <ci-icon :status="status" :size="24" :borderless="true" class="add-border" />
</a>
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 17ac8ada32d..8fdf61a6b8d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -18,8 +18,8 @@ export default {
Deployment,
MrWidgetContainer,
MrWidgetPipeline,
- MergeTrainInfo: () =>
- import('ee_component/vue_merge_request_widget/components/merge_train_info.vue'),
+ MergeTrainPositionIndicator: () =>
+ import('ee_component/vue_merge_request_widget/components/merge_train_position_indicator.vue'),
},
props: {
mr: {
@@ -60,9 +60,9 @@ export default {
return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
},
showVisualReviewAppLink() {
- return Boolean(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable);
+ return this.mr.visualReviewAppAvailable;
},
- showMergeTrainInfo() {
+ showMergeTrainPositionIndicator() {
return _.isNumber(this.mr.mergeTrainIndex);
},
},
@@ -90,8 +90,8 @@ export default {
:visual-review-app-meta="visualReviewAppMeta"
/>
</div>
- <merge-train-info
- v-if="showMergeTrainInfo"
+ <merge-train-position-indicator
+ v-if="showMergeTrainPositionIndicator"
class="mr-widget-extension"
:merge-train-index="mr.mergeTrainIndex"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 392eb6fb425..d0df8309dc7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -32,10 +32,13 @@ export default {
};
</script>
<template>
- <div class="space-children d-flex append-right-10 widget-status-icon">
- <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div>
-
- <ci-icon v-else :status="statusObj" :size="24" />
+ <div class="d-flex align-self-start">
+ <div class="square s24 h-auto d-flex-center append-right-default">
+ <div v-if="isLoading" class="mr-widget-icon d-inline-flex">
+ <gl-loading-icon size="md" class="mr-loading-icon d-inline-flex" />
+ </div>
+ <ci-icon v-else :status="statusObj" :size="24" />
+ </div>
<button
v-if="showDisabledButton"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
index 0312b147b62..01524f4b650 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -83,7 +83,7 @@ export default {
<gl-button
:aria-label="ariaLabel"
variant="blank"
- class="commit-edit-toggle square s24 mr-2"
+ class="commit-edit-toggle square s24 append-right-default"
@click.stop="toggle()"
>
<icon :name="collapseIcon" :size="16" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
index 5958c2cf87e..8e8e67228ed 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue
@@ -6,6 +6,7 @@ import statusIcon from '../mr_widget_status_icon.vue';
import MrWidgetAuthor from '../../components/mr_widget_author.vue';
import eventHub from '../../event_hub';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
+import { __ } from '~/locale';
export default {
name: 'MRWidgetAutoMergeEnabled',
@@ -55,7 +56,7 @@ export default {
})
.catch(() => {
this.isCancellingAutoMerge = false;
- Flash('Something went wrong. Please try again.');
+ Flash(__('Something went wrong. Please try again.'));
});
},
removeSourceBranch() {
@@ -76,7 +77,7 @@ export default {
})
.catch(() => {
this.isRemovingSourceBranch = false;
- Flash('Something went wrong. Please try again.');
+ Flash(__('Something went wrong. Please try again.'));
});
},
},
@@ -107,15 +108,15 @@ export default {
<section class="mr-info-list">
<p>
{{ s__('mrWidget|The changes will be merged into') }}
- <a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a>
+ <a :href="mr.targetBranchPath" class="label-branch">{{ mr.targetBranch }}</a>
</p>
<p v-if="mr.shouldRemoveSourceBranch">
{{ s__('mrWidget|The source branch will be deleted') }}
</p>
<p v-else class="d-flex align-items-start">
- <span class="append-right-10">
- {{ s__('mrWidget|The source branch will not be deleted') }}
- </span>
+ <span class="append-right-10">{{
+ s__('mrWidget|The source branch will not be deleted')
+ }}</span>
<a
v-if="canRemoveSourceBranch"
:disabled="isRemovingSourceBranch"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index b9562fbc260..fb07c03e34d 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import { s__, __ } from '~/locale';
@@ -84,6 +85,8 @@ export default {
.removeSourceBranch()
.then(res => res.data)
.then(data => {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
if (data.message === 'Branch was deleted') {
eventHub.$emit('MRWidgetUpdateRequested', () => {
this.isMakingRequest = false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 0bcccc50eb2..339e154affc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -4,6 +4,7 @@ import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub';
import statusIcon from '../mr_widget_status_icon.vue';
import Flash from '../../../flash';
+import { __, sprintf } from '~/locale';
export default {
name: 'MRWidgetRebase',
@@ -40,6 +41,18 @@ export default {
showDisabledButton() {
return ['failed', 'loading'].includes(this.status);
},
+ fastForwardMergeText() {
+ return sprintf(
+ __(
+ `Fast-forward merge is not possible. Rebase the source branch onto %{startTag}${this.mr.targetBranch}%{endTag} to allow this merge request to be merged.`,
+ ),
+ {
+ startTag: '<span class="label-branch">',
+ endTag: '</span>',
+ },
+ false,
+ );
+ },
},
methods: {
rebase() {
@@ -54,7 +67,7 @@ export default {
.catch(error => {
this.rebasingError = error.merge_error;
this.isMakingRequest = false;
- Flash('Something went wrong. Please try again.');
+ Flash(__('Something went wrong. Please try again.'));
});
},
checkRebaseStatus(continuePolling, stopPolling) {
@@ -69,7 +82,7 @@ export default {
if (res.merge_error && res.merge_error.length) {
this.rebasingError = res.merge_error;
- Flash('Something went wrong. Please try again.');
+ Flash(__('Something went wrong. Please try again.'));
}
eventHub.$emit('MRWidgetRebaseSuccess');
@@ -78,7 +91,7 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- Flash('Something went wrong. Please try again.');
+ Flash(__('Something went wrong. Please try again.'));
stopPolling();
});
},
@@ -91,19 +104,14 @@ export default {
<div class="rebase-state-find-class-convention media media-body space-children">
<template v-if="mr.rebaseInProgress || isMakingRequest">
- <span class="bold"> Rebase in progress </span>
+ <span class="bold">{{ __('Rebase in progress') }}</span>
</template>
<template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch">
- <span class="bold">
- Fast-forward merge is not possible. Rebase the source branch onto
- <span class="label-branch">{{ mr.targetBranch }}</span> to allow this merge request to be
- merged.
- </span>
+ <span class="bold" v-html="fastForwardMergeText"></span>
</template>
<template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest">
<div
- class="accept-merge-holder clearfix
-js-toggle-container accept-action media space-children"
+ class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
>
<button
:disabled="isMakingRequest"
@@ -111,14 +119,14 @@ js-toggle-container accept-action media space-children"
class="btn btn-sm btn-reopen btn-success qa-mr-rebase-button"
@click="rebase"
>
- <gl-loading-icon v-if="isMakingRequest" />
- Rebase
+ <gl-loading-icon v-if="isMakingRequest" />{{ __('Rebase') }}
</button>
- <span v-if="!rebasingError" class="bold">
- Fast-forward merge is not possible. Rebase the source branch onto the target branch or
- merge target branch into source branch to allow this merge request to be merged.
- </span>
- <span v-else class="bold danger"> {{ rebasingError }} </span>
+ <span v-if="!rebasingError" class="bold">{{
+ __(
+ 'Fast-forward merge is not possible. Rebase the source branch onto the target branch or merge target branch into source branch to allow this merge request to be merged.',
+ )
+ }}</span>
+ <span v-else class="bold danger">{{ rebasingError }}</span>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
index a38495bb4cc..4d7d49398eb 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/nothing_to_merge.vue
@@ -18,23 +18,35 @@ export default {
<template>
<div class="mr-widget-body mr-widget-empty-state">
<div class="row">
- <div class="artwork col-md-5 order-md-last col-12 text-center">
+ <div
+ class="artwork col-md-5 order-md-last col-12 text-center d-flex justify-content-center align-items-center"
+ >
<span v-html="emptyStateSVG"></span>
</div>
<div class="text col-md-7 order-md-first col-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>
+ <span>{{
+ s__(
+ 'mrWidgetNothingToMerge|Merge requests are a place to propose changes you have made to a project and discuss those changes with others.',
+ )
+ }}</span>
<p>
- Currently there are no changes in this merge request's source branch. Please push new
- commits or use a different branch.
+ {{
+ s__(
+ 'mrWidgetNothingToMerge|Interested parties can even contribute by pushing commits if they want to.',
+ )
+ }}
+ </p>
+ <p>
+ {{
+ s__(
+ "mrWidgetNothingToMerge|Currently there are no changes in this merge request's source branch. Please push new commits or use a different branch.",
+ )
+ }}
</p>
<div>
- <a v-if="mr.newBlobPath" :href="mr.newBlobPath" class="btn btn-inverted btn-success">
- Create file
- </a>
+ <a v-if="mr.newBlobPath" :href="mr.newBlobPath" class="btn btn-inverted btn-success">{{
+ __('Create file')
+ }}</a>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index ca1b4a57717..e294e1de976 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -6,6 +6,7 @@ import simplePoll from '~/lib/utils/simple_poll';
import { __ } from '~/locale';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import MergeRequest from '../../../merge_request';
+import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
@@ -93,9 +94,6 @@ export default {
return __('Merge');
},
- shouldShowMergeOptionsDropdown() {
- return this.isAutoMergeAvailable && !this.mr.onlyAllowMergeIfPipelineSucceeds;
- },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -174,6 +172,8 @@ export default {
MergeRequest.decreaseCounter();
stopPolling();
+ refreshUserMergeRequestCounts();
+
// If user checked remove source branch and we didn't remove the branch yet
// we should start another polling for source branch remove process
if (this.removeSourceBranch && data.source_branch_exists) {
@@ -243,17 +243,17 @@ export default {
{{ mergeButtonText }}
</button>
<button
- v-if="isAutoMergeAvailable"
+ v-if="shouldShowMergeImmediatelyDropdown"
:disabled="isMergeButtonDisabled"
type="button"
class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
data-toggle="dropdown"
- aria-label="Select merge moment"
+ :aria-label="__('Select merge moment')"
>
<i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
</button>
<ul
- v-if="shouldShowMergeOptionsDropdown"
+ v-if="shouldShowMergeImmediatelyDropdown"
class="dropdown-menu dropdown-menu-right"
role="menu"
>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index accb9d9fef1..98f682c2e8a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -36,7 +36,7 @@ export default {
:disabled="isDisabled"
type="checkbox"
name="squash"
- class="qa-squash-checkbox"
+ class="qa-squash-checkbox js-squash-checkbox"
@change="$emit('input', $event.target.checked)"
/>
{{ __('Squash commits') }}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index a9fb40a4949..d4a5fdb4b97 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -20,7 +20,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- {{ s__('mrWidget|There are unresolved discussions. Please resolve these discussions') }}
+ {{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }}
</span>
<a
v-if="mr.createIssueToResolveDiscussionsPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 7c322388d30..91c0b40a0b5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -46,14 +46,20 @@ export default {
<status-icon :show-disabled-button="Boolean(mr.removeWIPPath)" status="warning" />
<div class="media-body space-children">
<span class="bold">
- This is a Work in Progress
+ {{ __('This is a Work in Progress') }}
<i
v-tooltip
class="fa fa-question-circle"
- title="When this merge request is ready,
- remove the WIP: prefix from the title to allow it to be merged"
- aria-label="When this merge request is ready,
- remove the WIP: prefix from the title to allow it to be merged"
+ :title="
+ s__(
+ 'mrWidget|When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged',
+ )
+ "
+ :aria-label="
+ s__(
+ 'mrWidget|When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged',
+ )
+ "
>
</i>
</span>
@@ -64,8 +70,8 @@ export default {
class="btn btn-default btn-sm js-remove-wip"
@click="removeWIP"
>
- <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"> </i> Resolve WIP
- status
+ <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
+ {{ s__('mrWidget|Resolve WIP status') }}
</button>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 116d537c463..eef49e20159 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -15,5 +15,8 @@ export default {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
},
+ shouldShowMergeImmediatelyDropdown() {
+ return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
},
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index a79da476890..edd21a81f8b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -40,6 +40,8 @@ import { setFaviconOverlay } from '../lib/utils/common_utils';
export default {
el: '#js-vue-mr-widget',
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'MRWidget',
components: {
'mr-widget-header': WidgetHeader,
@@ -164,6 +166,7 @@ export default {
ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
mergeRequestBasicPath: store.mergeRequestBasicPath,
mergeRequestWidgetPath: store.mergeRequestWidgetPath,
+ mergeRequestCachedWidgetPath: store.mergeRequestCachedWidgetPath,
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
};
@@ -174,8 +177,7 @@ export default {
checkStatus(cb, isRebased) {
return this.service
.checkStatus()
- .then(res => res.data)
- .then(data => {
+ .then(({ data }) => {
this.handleNotification(data);
this.mr.setData(data, isRebased);
this.setFaviconHelper();
@@ -263,8 +265,11 @@ export default {
if (!data.pipeline) return;
const { label } = data.pipeline.details.status;
- const title = `Pipeline ${label}`;
- const message = `Pipeline ${label} for "${data.title}"`;
+ const title = sprintf(__('Pipeline %{label}'), { label });
+ const message = sprintf(__('Pipeline %{label} for "%{dataTitle}"'), {
+ dataTitle: data.title,
+ label,
+ });
notify.notifyMe(title, message, this.mr.gitlabLogo);
},
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
index 1dae53039d5..f637a44bf2d 100644
--- 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
@@ -34,7 +34,16 @@ export default class MRWidgetService {
}
checkStatus() {
- return axios.get(this.endpoints.mergeRequestWidgetPath);
+ // two endpoints are requested in order to get MR info:
+ // one which is etag-cached and invalidated and another one which is not cached
+ // the idea is to move all the fields to etag-cached endpoint and then perform only one request
+ // https://gitlab.com/gitlab-org/gitlab-ce/issues/61559#note_188801390
+ const getData = axios.get(this.endpoints.mergeRequestWidgetPath);
+ const getCachedData = axios.get(this.endpoints.mergeRequestCachedWidgetPath);
+
+ return axios
+ .all([getData, getCachedData])
+ .then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) })));
}
fetchMergeActionsContent() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 581fee7477f..7843409f4a7 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -10,6 +10,8 @@ export default class MergeRequestStore {
this.sha = data.diff_head_sha;
this.gitlabLogo = data.gitlabLogo;
+ this.setPaths(data);
+
this.setData(data);
}
@@ -18,13 +20,9 @@ export default class MergeRequestStore {
this.sha = data.diff_head_sha;
}
- const currentUser = data.current_user;
const pipelineStatus = data.pipeline ? data.pipeline.details.status : null;
this.squash = data.squash;
- this.squashBeforeMergeHelpPath =
- this.squashBeforeMergeHelpPath || data.squash_before_merge_help_path;
- this.troubleshootingDocsPath = this.troubleshootingDocsPath || data.troubleshooting_docs_path;
this.enableSquashBeforeMerge = this.enableSquashBeforeMerge || true;
this.iid = data.iid;
@@ -35,6 +33,7 @@ export default class MergeRequestStore {
this.sourceBranchProtected = data.source_branch_protected;
this.conflictsDocsPath = data.conflicts_docs_path;
this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
+ this.mergeTrainWhenPipelineSucceedsDocsPath = data.merge_train_when_pipeline_succeeds_docs_path;
this.mergeStatus = data.merge_status;
this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merge_commit_sha;
@@ -48,7 +47,7 @@ export default class MergeRequestStore {
this.postMergeDeployments = this.postMergeDeployments || [];
this.commits = data.commits_without_merge_commits || [];
this.squashCommitMessage = data.default_squash_commit_message;
- this.initRebase(data);
+ this.rebaseInProgress = data.rebase_in_progress;
if (data.issues_links) {
const links = data.issues_links;
@@ -66,14 +65,7 @@ export default class MergeRequestStore {
this.setToAutoMergeBy = MergeRequestStore.formatUserObject(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.targetBranchTreePath = data.target_branch_tree_path;
- this.conflictResolutionPath = data.conflict_resolution_path;
- this.cancelAutoMergePath = data.cancel_auto_merge_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;
@@ -83,46 +75,20 @@ export default class MergeRequestStore {
this.preferredAutoMergeStrategy = MergeRequestStore.getPreferredAutoMergeStrategy(
this.availableAutoMergeStrategies,
);
- this.mergePath = data.merge_path;
this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased);
- this.mergeRequestBasicPath = data.merge_request_basic_path;
- this.mergeRequestWidgetPath = data.merge_request_widget_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.mergeCommitPath = data.merge_commit_path;
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.isOpen = data.state === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
- this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
- this.canMerge = Boolean(data.merge_path);
- this.canCreateIssue = currentUser.can_create_issue || false;
- this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.canBeMerged = data.can_be_merged || false;
this.isMergeAllowed = data.mergeable || false;
this.mergeOngoing = data.merge_ongoing;
this.allowCollaboration = data.allow_collaboration;
- this.targetProjectFullPath = data.target_project_full_path;
- this.sourceProjectFullPath = data.source_project_full_path;
this.sourceProjectId = data.source_project_id;
this.targetProjectId = data.target_project_id;
- this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled);
- this.mergeTrainsCount = data.merge_trains_count || 0;
- this.mergeTrainIndex = data.merge_train_index;
-
- // 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 === 'failed' || this.ciStatus === 'canceled';
@@ -133,8 +99,33 @@ export default class MergeRequestStore {
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
this.isPipelineBlocked = pipelineStatus ? pipelineStatus.group === 'manual' : false;
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
-
this.testResultsPath = data.test_reports_path;
+ this.cancelAutoMergePath = data.cancel_auto_merge_path;
+ this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
+
+ this.newBlobPath = data.new_blob_path;
+ this.sourceBranchPath = data.source_branch_path;
+ this.sourceBranchLink = data.source_branch_with_namespace_link;
+ this.rebasePath = data.rebase_path;
+ this.targetBranchPath = data.target_branch_commits_path;
+ this.targetBranchTreePath = data.target_branch_tree_path;
+ this.conflictResolutionPath = data.conflict_resolution_path;
+ this.removeWIPPath = data.remove_wip_path;
+ this.createIssueToResolveDiscussionsPath = data.create_issue_to_resolve_discussions_path;
+ this.mergePath = data.merge_path;
+ this.canMerge = Boolean(data.merge_path);
+ this.mergeCommitPath = data.merge_commit_path;
+ this.canPushToSourceBranch = data.can_push_to_source_branch;
+
+ const currentUser = data.current_user;
+
+ this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
+ this.revertInForkPath = currentUser.revert_in_fork_path;
+
+ this.canRemoveSourceBranch = currentUser.can_remove_source_branch || false;
+ this.canCreateIssue = currentUser.can_create_issue || false;
+ this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
+ this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.setState(data);
}
@@ -161,6 +152,24 @@ export default class MergeRequestStore {
}
}
+ setPaths(data) {
+ // Paths are set on the first load of the page and not auto-refreshed
+ this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
+ this.troubleshootingDocsPath = data.troubleshooting_docs_path;
+ this.mergeRequestBasicPath = data.merge_request_basic_path;
+ this.mergeRequestWidgetPath = data.merge_request_widget_path;
+ this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path;
+ this.emailPatchesPath = data.email_patches_path;
+ this.plainDiffPath = data.plain_diff_path;
+ this.mergeCheckPath = data.merge_check_path;
+ this.mergeActionsContentPath = data.commit_change_content_path;
+ this.targetProjectFullPath = data.target_project_full_path;
+ this.sourceProjectFullPath = data.source_project_full_path;
+ this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
+ this.conflictsDocsPath = data.conflicts_docs_path;
+ this.ciEnvironmentsStatusPath = data.ci_environments_status_path;
+ }
+
get isNothingToMergeState() {
return this.state === stateKey.nothingToMerge;
}
@@ -169,13 +178,6 @@ export default class MergeRequestStore {
return this.state === stateKey.merged;
}
- initRebase(data) {
- this.canPushToSourceBranch = data.can_push_to_source_branch;
- this.rebaseInProgress = data.rebase_in_progress;
- this.approvalsLeft = !data.approved;
- this.rebasePath = data.rebase_path;
- }
-
static buildMetrics(metrics) {
if (!metrics) {
return {};
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index e9ab6f5ba7a..beb2ac09992 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -1,7 +1,6 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import { pluralize } from '~/lib/utils/text_utility';
import { __, sprintf } from '~/locale';
import { getCommitIconMap } from '~/ide/utils';
@@ -27,11 +26,6 @@ export default {
required: false,
default: false,
},
- forceModifiedIcon: {
- type: Boolean,
- required: false,
- default: false,
- },
size: {
type: Number,
required: false,
@@ -45,10 +39,10 @@ export default {
},
computed: {
changedIcon() {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
const suffix = !this.file.changed && this.file.staged && !this.showStagedIcon ? '-solid' : '';
- if (this.forceModifiedIcon) return `file-modified${suffix}`;
-
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
changedIconClass() {
@@ -69,7 +63,7 @@ export default {
});
} else if (this.file.changed && this.file.staged) {
return sprintf(__('Unstaged and staged %{type}'), {
- type: pluralize(type),
+ type,
});
}
@@ -87,7 +81,7 @@ export default {
v-gl-tooltip.right
:title="tooltipTitle"
:class="{ 'ml-auto': isCentered }"
- class="file-changed-icon"
+ class="file-changed-icon d-inline-block"
>
<icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index a1168fa0f1e..f7c508c4e23 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,6 +1,7 @@
<script>
import _ from 'underscore';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
import Icon from '../../vue_shared/components/icon.vue';
@@ -129,7 +130,9 @@ export default {
* @returns {String}
*/
userImageAltDescription() {
- return this.author && this.author.username ? `${this.author.username}'s avatar` : null;
+ return this.author && this.author.username
+ ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
+ : null;
},
},
};
@@ -149,38 +152,36 @@ export default {
:href="mergeRequestRef.path"
:title="mergeRequestRef.title"
class="ref-name"
+ >{{ mergeRequestRef.iid }}</gl-link
>
- {{ mergeRequestRef.iid }}
- </gl-link>
<gl-link
v-else
v-gl-tooltip
:href="commitRef.ref_url"
:title="commitRef.name"
class="ref-name"
+ >{{ commitRef.name }}</gl-link
>
- {{ commitRef.name }}
- </gl-link>
</template>
<icon name="commit" class="commit-icon js-commit-icon" />
- <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link>
+ <gl-link :href="commitUrl" class="commit-sha mr-0">{{ shortSha }}</gl-link>
- <div class="commit-title flex-truncate-parent">
- <tooltip-on-truncate v-if="title" class="flex-truncate-child" :title="title">
+ <div class="commit-title">
+ <span v-if="title" class="flex-truncate-parent">
<user-avatar-link
v-if="hasAuthor"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
- class="avatar-image-container"
+ class="avatar-image-container text-decoration-none"
/>
- <gl-link :href="commitUrl" class="commit-row-message cgray">
- {{ title }}
- </gl-link>
- </tooltip-on-truncate>
- <span v-else> Can't find HEAD commit for this branch </span>
+ <tooltip-on-truncate :title="title" class="flex-truncate-child">
+ <gl-link :href="commitUrl" class="commit-row-message cgray">{{ title }}</gl-link>
+ </tooltip-on-truncate>
+ </span>
+ <span v-else>{{ __("Can't find HEAD commit for this branch") }}</span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
index ba63683f5c0..da0b45110e2 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -3,6 +3,7 @@ import { __ } from '~/locale';
const viewers = {
image: {
id: 'image',
+ binary: true,
},
markdown: {
id: 'markdown',
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
index 2ca933a37d2..6a4a834337a 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/image_viewer.vue
@@ -27,7 +27,6 @@ export default {
return {
width: 0,
height: 0,
- isLoaded: false,
};
},
computed: {
@@ -63,8 +62,6 @@ export default {
this.height = contentImg.naturalHeight;
this.$nextTick(() => {
- this.isLoaded = true;
-
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
@@ -91,7 +88,9 @@ export default {
|
</template>
<template v-if="hasDimensions">
- <strong>W</strong>: {{ width }} | <strong>H</strong>: {{ height }}
+ <strong>{{ s__('ImageViewerDimensions|W') }}</strong
+ >: {{ width }} | <strong>{{ s__('ImageViewerDimensions|H') }}</strong
+ >: {{ height }}
</template>
</p>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index 5fdc915fffb..655f0054887 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -40,7 +40,7 @@ export default {
this.fetchMarkdownPreview();
},
destroyed() {
- if (this.isLoading) axiosSource.cancel('Cancelling Preview');
+ if (this.isLoading) axiosSource.cancel(__('Cancelling Preview'));
},
methods: {
fetchMarkdownPreview() {
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
index 36b3ee05456..d5558d93219 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -1,5 +1,7 @@
<script>
/* eslint-disable vue/require-default-prop */
+import { __ } from '~/locale';
+
export default {
name: 'DeprecatedModal', // use GlModal instead
@@ -39,7 +41,7 @@ export default {
closeButtonLabel: {
type: String,
required: false,
- default: 'Cancel',
+ default: __('Cancel'),
},
primaryButtonLabel: {
type: String,
@@ -94,7 +96,7 @@ export default {
type="button"
class="close float-right"
data-dismiss="modal"
- aria-label="Close"
+ :aria-label="__('Close')"
@click="emitCancel($event)"
>
<span aria-hidden="true">&times;</span>
diff --git a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
index 7d49c87271d..c35fee84771 100644
--- a/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/droplab_dropdown_button.vue
@@ -69,7 +69,7 @@ export default {
data-display="static"
data-toggle="dropdown"
>
- <icon name="arrow-down" aria-label="toggle dropdown" />
+ <icon name="arrow-down" :aria-label="__('toggle dropdown')" />
</button>
<ul :class="dropdownClass" class="dropdown-menu dropdown-open-top">
<template v-for="(action, index) in actions">
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 1bfa91500cb..f49e69c473b 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -131,7 +131,7 @@ export default {
</script>
<template>
- <div>
+ <div v-if="!file.moved">
<file-header v-if="file.isHeader" :path="file.path" />
<div
v-else
@@ -146,6 +146,7 @@ export default {
<span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
<file-icon
v-if="!showChangedIcon || file.type === 'tree'"
+ class="file-row-icon"
:file-name="file.name"
:loading="file.loading"
:folder="isTree"
@@ -223,13 +224,8 @@ export default {
white-space: nowrap;
}
-.file-row-name svg {
+.file-row-name .file-row-icon {
margin-right: 2px;
vertical-align: middle;
}
-
-.file-row-name .loading-container {
- display: inline-block;
- margin-right: 4px;
-}
</style>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
index 4e5dfbf3bf8..20bcceeb477 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
@@ -115,7 +115,7 @@ export default {
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
- aria-label="Expand dropdown"
+ :aria-label="__('Expand dropdown')"
>
<icon name="angle-down" :size="12" />
</button>
@@ -125,7 +125,7 @@ export default {
ref="searchInput"
v-model="filter"
type="search"
- placeholder="Filter"
+ :placeholder="__('Filter')"
class="js-filtered-dropdown-input dropdown-input-field"
/>
<icon class="dropdown-input-search" name="search" />
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index 3f45dc7853b..c652a684d7c 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
import CiIconBadge from './ci_badge_link.vue';
import TimeagoTooltip from './time_ago_tooltip.vue';
import UserAvatarImage from './user_avatar/user_avatar_image.vue';
@@ -65,7 +66,7 @@ export default {
computed: {
userAvatarAltText() {
- return `${this.user.name}'s avatar`;
+ return sprintf(__(`%{username}'s avatar`), { username: this.user.name });
},
},
@@ -87,16 +88,12 @@ export default {
<strong> {{ itemName }} #{{ itemId }} </strong>
- <template v-if="shouldRenderTriggeredLabel">
- triggered
- </template>
- <template v-else>
- created
- </template>
+ <template v-if="shouldRenderTriggeredLabel">{{ __('triggered') }}</template>
+ <template v-else>{{ __('created') }}</template>
<timeago-tooltip :time="time" />
- by
+ {{ __('by') }}
<template v-if="user">
<gl-link
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 41c4c861566..fa89473da62 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -1,4 +1,6 @@
<script>
+import iconsPath from '@gitlab/svgs/dist/icons.svg';
+
// only allow classes in images.scss e.g. s12
const validSizes = [8, 10, 12, 14, 16, 18, 24, 32, 48, 72];
let iconValidator = () => true;
@@ -84,7 +86,7 @@ export default {
computed: {
spriteHref() {
- return `${gon.sprite_icons}#${this.name}`;
+ return `${iconsPath}#${this.name}`;
},
iconTestClass() {
return `ic-${this.name}`;
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
index e438ff16a41..47f0851f650 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -1,7 +1,7 @@
<script>
import { GlLink } from '@gitlab/ui';
import _ from 'underscore';
-import { sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import icon from '../../../vue_shared/components/icon.vue';
function buildDocsLinkStart(path) {
@@ -47,7 +47,9 @@ export default {
},
confidentialAndLockedDiscussionText() {
return sprintf(
- 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
+ __(
+ 'This issue is %{confidentialLinkStart}confidential%{linkEnd} and %{lockedLinkStart}locked%{linkEnd}.',
+ ),
{
confidentialLinkStart: buildDocsLinkStart(this.confidentialIssueDocsPath),
lockedLinkStart: buildDocsLinkStart(this.lockedIssueDocsPath),
@@ -66,7 +68,7 @@ export default {
<span v-if="isLockedAndConfidential">
<span v-html="confidentialAndLockedDiscussionText"></span>
{{
- __(`People without permission will never get a notification and won't be able to comment.`)
+ __("People without permission will never get a notification and won't be able to comment.")
}}
</span>
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index eb0f666422f..b76679960ca 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -160,8 +160,8 @@ export default {
:disabled="removeDisabled"
type="button"
class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
- title="Remove"
- aria-label="Remove"
+ :title="__('Remove')"
+ :aria-label="__('Remove')"
@click="onRemoveRequest"
>
<icon :size="16" class="btn-item-remove-icon" name="close" />
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 3bdc0bb8ebd..b520d302407 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import _ from 'underscore';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
import GLForm from '../../../gl_form';
@@ -118,6 +118,18 @@ export default {
lineType() {
return this.line ? this.line.type : '';
},
+ addMultipleToDiscussionWarning() {
+ return sprintf(
+ __(
+ '%{icon}You are about to add %{usersTag} people to the discussion. Proceed with caution.',
+ ),
+ {
+ icon: '<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>',
+ usersTag: `<strong><span class="js-referenced-users-count">${this.referencedUsers.length}</span></strong>`,
+ },
+ false,
+ );
+ },
},
mounted() {
/*
@@ -172,7 +184,7 @@ export default {
renderMarkdown(data = {}) {
this.markdownPreviewLoading = false;
- this.markdownPreview = data.body || 'Nothing to preview.';
+ this.markdownPreview = data.body || __('Nothing to preview.');
if (data.references) {
this.referencedCommands = data.references.commands;
@@ -207,7 +219,11 @@ export default {
<div v-show="!previewMarkdown" class="md-write-holder">
<div class="zen-backdrop">
<slot name="textarea"></slot>
- <a class="zen-control zen-control-leave js-zen-leave" href="#" aria-label="Enter zen mode">
+ <a
+ class="zen-control zen-control-leave js-zen-leave"
+ href="#"
+ :aria-label="__('Enter zen mode')"
+ >
<icon :size="32" name="screen-normal" />
</a>
<markdown-toolbar
@@ -246,13 +262,7 @@ export default {
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div v-if="referencedCommands" class="referenced-commands" v-html="referencedCommands"></div>
<div v-if="shouldShowReferencedUsers" class="referenced-users">
- <span>
- <i class="fa fa-exclamation-triangle" aria-hidden="true"></i> You are about to add
- <strong>
- <span class="js-referenced-users-count">{{ referencedUsers.length }}</span>
- </strong>
- people to the discussion. Proceed with caution.
- </span>
+ <span v-html="addMultipleToDiscussionWarning"></span>
</div>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 56a16c9e4d6..4d27d1c9179 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -38,10 +38,11 @@ export default {
computed: {
mdTable() {
return [
- '| header | header |',
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ '| header | header |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
'| ------ | ------ |',
- '| cell | cell |',
- '| cell | cell |',
+ '| cell | cell |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
+ '| cell | cell |', // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
].join('\n');
},
mdSuggestion() {
@@ -123,7 +124,7 @@ export default {
:cursor-offset="4"
:tag-content="lineContent"
icon="doc-code"
- class="qa-suggestion-btn"
+ class="qa-suggestion-btn js-suggestion-btn"
@click="handleSuggestDismissed"
/>
<gl-popover
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index 2eb4ec12a4a..a7cd292e01d 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -39,7 +39,7 @@ export default {
<template>
<div class="md-suggestion">
<suggestion-diff-header
- class="qa-suggestion-diff-header"
+ class="qa-suggestion-diff-header js-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
:is-applied="suggestion.applied"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 32783b85df4..12de3671477 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -41,7 +41,7 @@ export default {
<template>
<div class="md-suggestion-header border-bottom-0 mt-2">
- <div class="qa-suggestion-diff-header font-weight-bold">
+ <div class="qa-suggestion-diff-header js-suggestion-diff-header font-weight-bold">
{{ __('Suggested change') }}
<a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<icon name="question-o" css-classes="link-highlight" />
@@ -55,7 +55,7 @@ export default {
<gl-button
v-else-if="canApply"
v-gl-tooltip.viewport="__('This also resolves the discussion')"
- class="btn-inverted qa-apply-btn"
+ class="btn-inverted qa-apply-btn js-apply-btn"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 8d3705e1e4a..7f0fcfac071 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -1,5 +1,6 @@
<script>
import Vue from 'vue';
+import { __ } from '~/locale';
import SuggestionDiff from './suggestion_diff.vue';
import Flash from '~/flash';
@@ -56,7 +57,7 @@ export default {
const suggestionElements = container.querySelectorAll('.js-render-suggestion');
if (this.lineType === 'old') {
- Flash('Unable to apply suggestions to a deleted line.', 'alert', this.$el);
+ Flash(__('Unable to apply suggestions to a deleted line.'), 'alert', this.$el);
}
suggestionElements.forEach((suggestionEl, i) => {
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index d6c398c8946..5140184eb8e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,4 +1,5 @@
<script>
+/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlLink } from '@gitlab/ui';
export default {
@@ -33,13 +34,18 @@ export default {
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
<template v-if="!hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1"
- >Markdown is supported</gl-link
- >
+ <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
+ __('Markdown is supported')
+ }}</gl-link>
</template>
<template v-if="hasQuickActionsDocsPath && markdownDocsPath">
- <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">Markdown</gl-link> and
- <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">quick actions</gl-link>
+ <gl-link :href="markdownDocsPath" target="_blank" tabindex="-1">{{
+ __('Markdown')
+ }}</gl-link>
+ and
+ <gl-link :href="quickActionsDocsPath" target="_blank" tabindex="-1">{{
+ __('quick actions')
+ }}</gl-link>
are supported
</template>
</div>
@@ -57,15 +63,17 @@ export default {
<i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i>
</span>
<span class="uploading-error-message"></span>
- <button class="retry-uploading-link" type="button">Try again</button> or
- <button class="attach-new-file markdown-selector" type="button">attach a new file</button>
+ <button class="retry-uploading-link" type="button">{{ __('Try again') }}</button> or
+ <button class="attach-new-file markdown-selector" type="button">
+ {{ __('attach a new file') }}
+ </button>
</span>
<button class="markdown-selector button-attach-file btn-link" tabindex="-1" type="button">
<i class="fa fa-file-image-o toolbar-button-icon" aria-hidden="true"></i
- ><span class="text-attach-file">Attach a file</span>
+ ><span class="text-attach-file">{{ __('Attach a file') }}</span>
</button>
<button class="btn btn-default btn-sm hide button-cancel-uploading-files" type="button">
- Cancel
+ {{ __('Cancel') }}
</button>
</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/memory_graph.vue b/app/assets/javascripts/vue_shared/components/memory_graph.vue
index 16f4ff068f6..26d7d8e8866 100644
--- a/app/assets/javascripts/vue_shared/components/memory_graph.vue
+++ b/app/assets/javascripts/vue_shared/components/memory_graph.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import { getTimeago } from '../../lib/utils/datetime_utility';
export default {
@@ -20,7 +21,7 @@ export default {
computed: {
getFormattedMedian() {
const deployedSince = getTimeago().format(this.deploymentTime * 1000);
- return `Deployed ${deployedSince}`;
+ return sprintf(__('Deployed %{deployedSince}'), { deployedSince });
},
},
mounted() {
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index baed26a157c..af02b8969ee 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note being-posted fade-in-half">
+ <timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 3c86b7e4c61..d6dfe9eded8 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -103,7 +103,7 @@ export default {
<div v-if="hasMoreCommits" class="flex-list">
<div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
<icon :name="toggleIcon" :size="8" class="append-right-5" />
- <span>Toggle commit list</span>
+ <span>{{ __('Toggle commit list') }}</span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
index b9311d65360..43bbb756805 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/image.vue
@@ -14,7 +14,7 @@
/>
*/
-
+import { __ } from '~/locale';
import defaultAvatarUrl from 'images/no_avatar.png';
import { placeholderImage } from '../../../lazy_loader';
@@ -39,7 +39,7 @@ export default {
imgAlt: {
type: String,
required: false,
- default: 'project avatar',
+ default: __('project avatar'),
},
size: {
type: Number,
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
index df19906309c..f0aae20477b 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -30,9 +30,16 @@ export default {
},
mounted() {
+ if (window.recaptchaDialogCallback) {
+ throw new Error('recaptchaDialogCallback is already defined!');
+ }
window.recaptchaDialogCallback = this.submit.bind(this);
},
+ beforeDestroy() {
+ window.recaptchaDialogCallback = null;
+ },
+
methods: {
appendRecaptchaScript() {
this.removeRecaptchaScript();
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
index 6d2612556ff..eb741d238b5 100644
--- a/app/assets/javascripts/vue_shared/components/select2_select.vue
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -3,6 +3,8 @@ import $ from 'jquery';
import 'select2';
export default {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'Select2Select',
props: {
options: {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
index b5e43da401e..4dcc121496c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue
@@ -85,7 +85,7 @@ export default {
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
- <span v-if="showFromText">From</span> <span>{{ dateText('min') }}</span>
+ <span v-if="showFromText">{{ __('From') }}</span> <span>{{ dateText('min') }}</span>
</span>
</collapsed-calendar-icon>
<div v-if="hasMinAndMaxDates" class="text-center sidebar-collapsed-divider">-</div>
@@ -96,7 +96,7 @@ export default {
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
- <span v-if="!minDate">Until</span> <span>{{ dateText('max') }}</span>
+ <span v-if="!minDate">{{ __('Until') }}</span> <span>{{ dateText('max') }}</span>
</span>
</collapsed-calendar-icon>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
index 45f01a6fced..6caf8bc92c2 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
@@ -74,7 +74,7 @@ export default {
return dateInWords(this.selectedDate, true);
},
collapsedText() {
- return this.selectedDateWords ? this.selectedDateWords : 'None';
+ return this.selectedDateWords ? this.selectedDateWords : __('None');
},
},
methods: {
@@ -112,7 +112,7 @@ export default {
class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
@click="toggleDatePicker"
>
- Edit
+ {{ __('Edit') }}
</button>
<toggle-sidebar v-if="showToggleSidebar" :collapsed="collapsed" @toggle="toggleSidebar" />
</div>
@@ -137,11 +137,11 @@ export default {
class="btn-blank btn-link btn-secondary-hover-link"
@click="newDateSelected(null)"
>
- remove
+ {{ __('remove') }}
</button>
</span>
</template>
- <span v-else class="no-value"> None </span>
+ <span v-else class="no-value">{{ __('None') }}</span>
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
index 3b5ce0e9910..913c971a512 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
@@ -48,7 +48,7 @@ export default {
'fa-angle-double-right': !collapsed,
'fa-angle-double-left': collapsed,
}"
- aria-label="toggle collapse"
+ :aria-label="__('toggle collapse')"
class="fa"
>
</i>
diff --git a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
index 8ba6b73f928..af4eb2de7f8 100644
--- a/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
+++ b/app/assets/javascripts/vue_shared/components/stacked_progress_bar.vue
@@ -78,6 +78,8 @@ export default {
return percent;
},
barStyle(percent) {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `width: ${percent}%;`;
},
getTooltip(label, count) {
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index a6c1737dcab..ea483416c46 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -17,6 +17,7 @@
import { GlTooltip } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
+import { __ } from '~/locale';
import { placeholderImage } from '../../../lazy_loader';
export default {
@@ -43,7 +44,7 @@ export default {
imgAlt: {
type: String,
required: false,
- default: 'user avatar',
+ default: __('user avatar'),
},
size: {
type: Number,
diff --git a/app/assets/javascripts/vue_shared/directives/autofocusonshow.js b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js
new file mode 100644
index 00000000000..4659ec20ceb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js
@@ -0,0 +1,39 @@
+/**
+ * Input/Textarea Autofocus Directive for Vue
+ */
+export default {
+ /**
+ * Set focus when element is rendered, but
+ * is not visible, using IntersectionObserver
+ *
+ * @param {Element} el Target element
+ */
+ inserted(el) {
+ if ('IntersectionObserver' in window) {
+ // Element visibility is dynamic, so we attach observer
+ el.visibilityObserver = new IntersectionObserver(entries => {
+ entries.forEach(entry => {
+ // Combining `intersectionRatio > 0` and
+ // element's `offsetParent` presence will
+ // deteremine if element is truely visible
+ if (entry.intersectionRatio > 0 && entry.target.offsetParent) {
+ entry.target.focus();
+ }
+ });
+ });
+
+ // Bind the observer.
+ el.visibilityObserver.observe(el, { root: document.documentElement });
+ }
+ },
+ /**
+ * Detach observer on unbind hook.
+ *
+ * @param {Element} el Target element
+ */
+ unbind(el) {
+ if (el.visibilityObserver) {
+ el.visibilityObserver.disconnect();
+ }
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index 2d1f7a1cfd0..73e92728cb9 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -3,8 +3,12 @@ import '~/commons/bootstrap';
export default {
bind(el) {
+ const glTooltipDelay = localStorage.getItem('gl-tooltip-delay');
+ const delay = glTooltipDelay ? JSON.parse(glTooltipDelay) : 0;
+
$(el).tooltip({
trigger: 'hover',
+ delay,
});
},
diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js
deleted file mode 100644
index 8e00d93ef18..00000000000
--- a/app/assets/javascripts/vue_shared/mixins/is_ee.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import Vue from 'vue';
-import { isEE } from '~/lib/utils/common_utils';
-
-Vue.mixin({
- computed: {
- isEE() {
- return isEE();
- },
- },
-});
diff --git a/app/assets/stylesheets/_ee/application_ee.scss b/app/assets/stylesheets/_ee/application_ee.scss
new file mode 100644
index 00000000000..0fb2c9b68a9
--- /dev/null
+++ b/app/assets/stylesheets/_ee/application_ee.scss
@@ -0,0 +1,5 @@
+/*
+ This is a noop-file. In EE:
+ ee/app/assets/stylesheets/_ee/application_ee.scss
+ will take precedence over it and import more styles
+ */
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index a2f518cd24e..e98030f1511 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -11,10 +11,9 @@
// like a table or typography then make changes in the framework/ directory.
// If you need to add unique style that should affect only one page - use pages/
// directory.
-@import "../../../node_modules/at.js/dist/css/jquery.atwho";
-@import "../../../node_modules/pikaday/scss/pikaday";
-@import "../../../node_modules/dropzone/dist/basic";
-@import "../../../node_modules/select2/select2";
+@import "at.js/dist/css/jquery.atwho";
+@import "dropzone/dist/basic";
+@import "select2/select2";
// GitLab UI framework
@import "framework";
@@ -34,4 +33,8 @@
// Styles for JS behaviors.
@import "behaviors";
+// EE-only stylesheets
+@import "application_ee";
+
+// CSS util classes
@import "utilities";
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
index 8e9650cdf34..312123aeef9 100644
--- a/app/assets/stylesheets/components/avatar.scss
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -50,6 +50,11 @@ $avatar-sizes: (
line-height: 88px,
border-radius: $border-radius-large
),
+ 96: (
+ font-size: 36px,
+ line-height: 94px,
+ border-radius: $border-radius-large
+ ),
100: (
font-size: 36px,
line-height: 98px,
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
index 8c40c4adb5c..6654553aaa2 100644
--- a/app/assets/stylesheets/components/popover.scss
+++ b/app/assets/stylesheets/components/popover.scss
@@ -102,6 +102,7 @@
.onboarding-popover {
box-shadow: 0 2px 4px $dropdown-shadow-color;
+ max-width: 280px;
.popover-body {
font-size: $gl-font-size;
diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss
index acbd909d595..e27bf282247 100644
--- a/app/assets/stylesheets/components/toast.scss
+++ b/app/assets/stylesheets/components/toast.scss
@@ -15,11 +15,15 @@
.toasted.gl-toast {
border-radius: $border-radius-default;
font-size: $gl-font-size;
- padding: $gl-padding-8 $gl-padding-24;
+ padding: $gl-padding-8 $gl-padding $gl-padding-8 $gl-padding-24;
margin-top: $toast-default-margin;
line-height: $gl-line-height;
background-color: rgba($gray-900, $toast-background-opacity);
+ span {
+ padding-right: $gl-padding-8;
+ }
+
@include media-breakpoint-down(xs) {
.action:first-of-type {
// Ensures actions buttons are right aligned on mobile
@@ -29,19 +33,14 @@
.action {
color: $blue-300;
- margin: 0 0 0 $toast-action-margin-left;
+ margin: 0 0 0 $toast-default-margin;
text-transform: none;
font-size: $gl-font-size;
-
- &:first-of-type {
- padding-right: 0;
- }
}
.toast-close {
font-size: $default-icon-size;
margin-left: $toast-default-margin;
- padding-left: $gl-padding;
}
}
}
diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss
index acaa41e2677..87c59cd42c0 100644
--- a/app/assets/stylesheets/csslab.scss
+++ b/app/assets/stylesheets/csslab.scss
@@ -1 +1 @@
-@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
+@import "@gitlab/csslab/dist/css/csslab-slim";
diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss
index 8c32b6c8985..89029a58d1e 100644
--- a/app/assets/stylesheets/errors.scss
+++ b/app/assets/stylesheets/errors.scss
@@ -2,12 +2,12 @@
* This is a minimal stylesheet, meant to be used for error pages.
*/
@import 'framework/variables';
-@import '../../../node_modules/bootstrap/scss/functions';
-@import '../../../node_modules/bootstrap/scss/variables';
-@import '../../../node_modules/bootstrap/scss/mixins';
-@import '../../../node_modules/bootstrap/scss/reboot';
-@import '../../../node_modules/bootstrap/scss/buttons';
-@import '../../../node_modules/bootstrap/scss/forms';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/reboot';
+@import 'bootstrap/scss/buttons';
+@import 'bootstrap/scss/forms';
$body-color: #666;
$header-color: #456;
@@ -96,7 +96,7 @@ a {
}
.error-nav {
- padding: 0;
+ padding: $gl-padding 0 0;
text-align: center;
li {
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 14f4652e847..82b4ec750ff 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -2,14 +2,13 @@
@import 'framework/variables_overrides';
@import 'framework/mixins';
-@import '../../../node_modules/@gitlab/ui/scss/gitlab_ui';
+@import '@gitlab/ui/scss/gitlab_ui';
@import 'bootstrap_migration';
@import 'framework/layout';
@import 'framework/animations';
@import 'framework/vue_transitions';
-@import 'framework/asciidoctor';
@import 'framework/banner';
@import 'framework/blocks';
@import 'framework/buttons';
diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss
deleted file mode 100644
index 1586265d40e..00000000000
--- a/app/assets/stylesheets/framework/asciidoctor.scss
+++ /dev/null
@@ -1,27 +0,0 @@
-.admonitionblock td.icon {
- width: 1%;
-
- [class^='fa icon-'] {
- @extend .fa-2x;
- }
-
- .icon-note {
- @extend .fa-thumb-tack;
- }
-
- .icon-tip {
- @extend .fa-lightbulb-o;
- }
-
- .icon-warning {
- @extend .fa-exclamation-triangle;
- }
-
- .icon-caution {
- @extend .fa-fire;
- }
-
- .icon-important {
- @extend .fa-exclamation-circle;
- }
-}
diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss
index c6060161dec..c036267a7c8 100644
--- a/app/assets/stylesheets/framework/badges.scss
+++ b/app/assets/stylesheets/framework/badges.scss
@@ -1,6 +1,6 @@
.badge.badge-pill {
font-weight: $gl-font-weight-normal;
background-color: $badge-bg;
- color: $gl-text-color-secondary;
+ color: $gray-800;
vertical-align: baseline;
}
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index 643b20c56bc..c5bb2a1256a 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -9,7 +9,9 @@
.bs-callout {
margin: $gl-padding 0;
padding: $gl-padding;
- border-left: 3px solid $border-color;
+ border-color: $border-color;
+ border-style: solid;
+ border-width: 0 0 0 3px;
color: $text-color;
background: $gray-light;
@@ -48,6 +50,10 @@
background-color: $blue-100;
border-color: $blue-200;
color: $blue-700;
+
+ h4 {
+ color: $blue-700;
+ }
}
.bs-callout-success {
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 1bd5043ed10..e9218dcec67 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -434,12 +434,16 @@ img.emoji {
/** COMMON SIZING CLASSES **/
.w-0 { width: 0; }
+.w-8em { width: 8em; }
+.w-3rem { width: 3rem; }
.h-12em { height: 12em; }
+.h-32-px { height: 32px;}
.mw-460 { max-width: 460px; }
.mw-6em { max-width: 6em; }
.mw-70p { max-width: 70%; }
+.mw-90p { max-width: 90%; }
.min-height-0 { min-height: 0; }
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index cd951f67293..29f63e9578d 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -63,7 +63,8 @@
margin-top: 11px;
}
-.dropdown-toggle {
+.dropdown-toggle,
+.confidential-merge-request-fork-group .dropdown-toggle {
padding: 6px 8px 6px 10px;
background-color: $white-light;
color: $gl-text-color;
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 26cbb7f5c13..5984efd1cf8 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -9,14 +9,14 @@
float: right;
margin-right: 0;
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
float: none;
}
}
}
.filters-section {
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
display: inline-block;
}
}
@@ -37,7 +37,7 @@
}
}
-@include media-breakpoint-down(xs) {
+@include media-breakpoint-down(sm) {
.filter-item {
display: block;
margin: 0 0 10px;
@@ -50,12 +50,6 @@
}
.filtered-search-wrapper {
- display: flex;
-
- @include media-breakpoint-down(xs) {
- flex-direction: column;
- }
-
.tokens-container {
display: flex;
flex: 1;
@@ -186,7 +180,7 @@
border: 1px solid $border-color;
background-color: $white-light;
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
flex: 1 1 auto;
margin-bottom: 10px;
}
@@ -259,7 +253,7 @@
max-width: 280px;
overflow: auto;
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
width: auto;
left: 0;
right: 0;
@@ -311,7 +305,7 @@
.filtered-search-history-dropdown {
width: 40%;
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
left: 0;
right: 0;
max-width: none;
@@ -341,35 +335,46 @@
}
.filter-dropdown-container {
- display: flex;
-
.dropdown-toggle {
line-height: 22px;
}
}
-@include media-breakpoint-down(xs) {
+@include media-breakpoint-down(sm) {
.issues-details-filters {
- padding: 0 0 10px;
+ padding-top: 0;
+ padding-bottom: 0;
background-color: $white-light;
border-top: 0;
}
- .filter-dropdown-container {
+ .boards-switcher {
+ margin: 0 0 10px;
+
+ .boards-selector-wrapper,
.dropdown {
- margin-left: 0;
+ display: block;
}
}
-}
-@include media-breakpoint-down(sm) {
- .filter-dropdown-container {
- .dropdown-toggle,
- .dropdown,
- .dropdown-menu {
+ .filter-dropdown-container > div {
+ margin: 0;
+
+ > .btn {
+ margin: 0 0 10px;
width: 100%;
}
}
+
+ .boards-add-list > .btn {
+ text-align: left;
+
+ > svg {
+ position: absolute;
+ top: 11px;
+ right: 6px;
+ }
+ }
}
.droplab-dropdown .dropdown-menu .filter-dropdown-item {
diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss
index e3dd127366d..96f6d02a68f 100644
--- a/app/assets/stylesheets/framework/flash.scss
+++ b/app/assets/stylesheets/framework/flash.scss
@@ -43,6 +43,7 @@
@extend .alert;
background-color: $orange-100;
color: $orange-900;
+ cursor: default;
margin: 0;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 1bc597bd4ae..ca737c53318 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -131,7 +131,6 @@
> li:not(.d-none) a {
@include media-breakpoint-down(xs) {
margin-left: 0;
- min-width: 100%;
}
}
}
@@ -233,7 +232,6 @@
.impersonation-btn,
.impersonation-btn:hover {
background-color: $white-light;
- margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 1be5ef276fd..7332c4981d2 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -88,8 +88,5 @@
display: flex;
align-items: center;
justify-content: center;
- border: $border-size solid $gray-400;
- border-radius: 50%;
- padding: $gl-padding-8 - $border-size;
color: $gray-700;
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 954551fef97..ecd32dcd0ce 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -265,7 +265,6 @@ ul.controls {
}
.issuable-pipeline-broken a,
- .issuable-pipeline-status a,
.author-link {
display: flex;
}
@@ -286,3 +285,19 @@ ul.indent-list {
max-width: 350px;
}
}
+
+.horizontal-list {
+ padding-left: 0;
+ list-style: none;
+
+ > li {
+ float: left;
+ }
+
+ &.list-items-separated {
+ > li:not(:last-child)::after {
+ content: '\00b7';
+ margin: 0 $gl-padding-4;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index f75e5b55506..fd9a75bc5b6 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -19,23 +19,31 @@
}
}
- // leave enough space for the close icon
.modal-title {
+ line-height: $gl-line-height-24;
+
+ // leave enough space for the close icon
&.mw-100,
&.w-100 {
- // after upgrading to Bootstrap 4.2 we can use $modal-header-padding-x here
- // https://github.com/twbs/bootstrap/pull/26976
- margin-right: -28px;
- padding-right: 28px;
+ margin-right: -$modal-header-padding-x;
+ padding-right: $modal-header-padding-x;
}
}
+
+ .close {
+ font-weight: $gl-font-weight-normal;
+ line-height: $gl-line-height;
+ color: $gray-900;
+ opacity: 1;
+ }
}
.modal-body {
background-color: $modal-body-bg;
line-height: $line-height-base;
position: relative;
- padding: #{3 * $grid-size} #{2 * $grid-size};
+ min-height: $modal-body-height;
+ padding: #{2 * $grid-size} #{6 * $grid-size} #{2 * $grid-size} #{2 * $grid-size};
text-align: left;
white-space: normal;
@@ -52,12 +60,20 @@
display: flex;
flex-direction: row;
+ .btn {
+ margin: 0;
+ }
+
.btn + .btn:not(.dropdown-toggle-split),
.btn + .btn-group,
.btn-group + .btn {
margin-left: $grid-size;
}
+ .btn-group .btn + .btn {
+ margin-left: -1px;
+ }
+
@include media-breakpoint-down(xs) {
flex-direction: column;
@@ -67,6 +83,11 @@
margin-left: 0;
margin-top: $grid-size;
}
+
+ .btn-group .btn + .btn {
+ margin-left: -1px;
+ margin-top: 0;
+ }
}
}
@@ -85,9 +106,23 @@ body.modal-open {
.modal {
background-color: $black-transparent;
- @include media-breakpoint-up(md) {
+ .modal-content {
+ border-radius: $modal-border-radius;
+
+ > :first-child {
+ border-top-left-radius: $modal-border-radius;
+ border-top-right-radius: $modal-border-radius;
+ }
+
+ > :last-child {
+ border-bottom-left-radius: $modal-border-radius;
+ border-bottom-right-radius: $modal-border-radius;
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
.modal-dialog {
- margin: 30px auto;
+ margin: 64px auto;
}
}
}
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index cd3d6f8297e..d9c93fed1c4 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -3,7 +3,6 @@
}
.card-slim {
- @extend .card;
margin-bottom: $gl-vert-padding;
}
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 6bd44ee19bd..fd6f80e26cb 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -155,7 +155,7 @@
text-overflow: ellipsis;
@include media-breakpoint-up(md) {
- flex: 0 0 90%;
+ flex: 0 0 85%;
}
.avatar {
diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss
index 98f28987a82..edc2fb532c8 100644
--- a/app/assets/stylesheets/framework/tooltips.scss
+++ b/app/assets/stylesheets/framework/tooltips.scss
@@ -1,7 +1,6 @@
.tooltip-inner {
- font-size: $tooltip-font-size;
+ font-size: $gl-font-size-small;
border-radius: $border-radius-default;
- line-height: 16px;
+ line-height: $gl-line-height;
font-weight: $gl-font-weight-normal;
- padding: 8px;
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 7baab478034..c201605e83d 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,5 +1,5 @@
/**
- * Apply Markdown typography
+ * Apply Markup (Markdown/AsciiDoc) typography
*
*/
.md:not(.use-csslab) {
@@ -245,6 +245,21 @@
}
}
+ ul.checklist,
+ ul.none,
+ ol.none,
+ ul.no-bullet,
+ ol.no-bullet,
+ ol.unnumbered,
+ ul.unstyled,
+ ol.unstyled {
+ list-style-type: none;
+
+ li {
+ margin-left: 0;
+ }
+ }
+
li {
line-height: 1.6em;
margin-left: 25px;
@@ -321,6 +336,54 @@
visibility: visible;
}
}
+
+ .big {
+ font-size: larger;
+ }
+
+ .small {
+ font-size: smaller;
+ }
+
+ .underline {
+ text-decoration: underline;
+ }
+
+ .overline {
+ text-decoration: overline;
+ }
+
+ .line-through {
+ text-decoration: line-through;
+ }
+
+ .admonitionblock td.icon {
+ width: 1%;
+
+ [class^='fa icon-'] {
+ @extend .fa-2x;
+ }
+
+ .icon-note {
+ @extend .fa-thumb-tack;
+ }
+
+ .icon-tip {
+ @extend .fa-lightbulb-o;
+ }
+
+ .icon-warning {
+ @extend .fa-exclamation-triangle;
+ }
+
+ .icon-caution {
+ @extend .fa-fire;
+ }
+
+ .icon-important {
+ @extend .fa-exclamation-circle;
+ }
+ }
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index b6a24247d40..9871771542d 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -469,6 +469,7 @@ $link-active-background: rgba(0, 0, 0, 0.04);
$link-hover-background: rgba(0, 0, 0, 0.06);
$inactive-badge-background: rgba(0, 0, 0, 0.08);
$sidebar-toggle-height: 60px;
+$sidebar-toggle-width: 40px;
$sidebar-milestone-toggle-bottom-margin: 10px;
/*
@@ -507,7 +508,6 @@ $toast-height: 48px;
$toast-max-width: 586px;
$toast-padding-right: 42px;
$toast-default-margin: 8px;
-$toast-action-margin-left: 16px;
$toast-background-opacity: 0.95;
/*
@@ -604,6 +604,7 @@ $blame-blue: #254e77;
* Builds
*/
$builds-trace-bg: #111;
+$job-log-highlight-height: 18px;
/*
* Commit Page
@@ -805,8 +806,9 @@ $border-color-settings: #e1e1e1;
/*
Modals
*/
-$modal-body-height: 134px;
+$modal-body-height: 80px;
$modal-border-color: #e9ecef;
+$modal-border-radius: 0.25rem;
$priority-label-empty-state-width: 114px;
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index ea96381a098..604b48e11ab 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -48,3 +48,7 @@ $spacers: (
9: ($spacer * 8)
);
$pagination-color: $gl-text-color;
+$tooltip-padding-y: 0.5rem;
+$tooltip-padding-x: 0.75rem;
+$tooltip-arrow-height: 0.5rem;
+$tooltip-arrow-width: 1rem;
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
index ac3214a07d9..bdeac7e97c0 100644
--- a/app/assets/stylesheets/highlight/common.scss
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -16,3 +16,16 @@
color: $dark-diff-match-bg;
background: $dark-diff-match-color;
}
+
+@mixin diff-expansion($background, $border, $link) {
+ background-color: $background;
+
+ td {
+ border-top: 1px solid $border;
+ border-bottom: 1px solid $border;
+ }
+
+ a {
+ color: $link;
+ }
+}
diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 16893dd047e..cbce0ba3f1e 100644
--- a/app/assets/stylesheets/highlight/themes/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -111,6 +111,10 @@ $dark-il: #de935f;
color: $dark-line-color;
}
+ .line_expansion {
+ @include diff-expansion($dark-main-bg, $dark-border, $dark-na);
+ }
+
// Diff line
.line_holder {
&.match .line_content,
diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 37fe61b925c..1b61ffa37e3 100644
--- a/app/assets/stylesheets/highlight/themes/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -111,6 +111,10 @@ $monokai-gi: #a6e22e;
color: $monokai-text-color;
}
+ .line_expansion {
+ @include diff-expansion($monokai-bg, $monokai-border, $monokai-k);
+ }
+
// Diff line
.line_holder {
&.match .line_content,
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
index b4217aac37a..a7ede266fb5 100644
--- a/app/assets/stylesheets/highlight/themes/none.scss
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -34,8 +34,11 @@
color: $gl-text-color;
}
-// Diff line
+ .line_expansion {
+ @include diff-expansion($gray-light, $white-normal, $gl-text-color);
+ }
+ // Diff line
$none-over-bg: #ded7fc;
$none-expanded-border: #e0e0e0;
$none-expanded-bg: #e0e0e0;
diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index a4e9eda22c9..6569f3abc8b 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -115,6 +115,10 @@ $solarized-dark-il: #2aa198;
color: $solarized-dark-pre-color;
}
+ .line_expansion {
+ @include diff-expansion($solarized-dark-line-bg, $solarized-dark-border, $solarized-dark-kd);
+ }
+
// Diff line
.line_holder {
&.match .line_content,
diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index b604d1ccb6c..4e74a9ea50a 100644
--- a/app/assets/stylesheets/highlight/themes/solarized-light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -122,6 +122,10 @@ $solarized-light-il: #2aa198;
color: $solarized-light-pre-color;
}
+ .line_expansion {
+ @include diff-expansion($solarized-light-line-bg, $solarized-light-border, $solarized-light-kd);
+ }
+
// Diff line
.line_holder {
&.match .line_content,
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index ee0ec94c636..973f94c63aa 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -101,6 +101,10 @@ pre.code,
color: $white-code-color;
}
+.line_expansion {
+ @include diff-expansion($gray-light, $border-color, $blue-600);
+}
+
// Diff line
.line_holder {
&.match .line_content,
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index cbcd8a474f1..ba126d59eef 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -1216,11 +1216,10 @@ $ide-commit-header-height: 48px;
}
.ide-search-list-empty {
- height: 230px;
+ height: 69px;
}
.ide-merge-requests-dropdown-content {
- min-height: 230px;
max-height: 470px;
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 5e3652db48f..e77a2d1e333 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -86,15 +86,29 @@
}
.board {
+ // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS
+ // see https://gitlab.com/gitlab-org/gitlab-ce/issues/64828
+ display: inline-block;
width: calc(85vw - 15px);
@include media-breakpoint-up(sm) {
width: 400px;
}
- &.is-expandable {
- .board-header {
- cursor: pointer;
+ .board-title-caret {
+ cursor: pointer;
+ border-radius: $border-radius-default;
+ padding: 4px;
+
+ &:hover {
+ background-color: $gray-dark;
+ transition: background-color 0.1s linear;
+ }
+ }
+
+ &:not(.is-collapsed) {
+ .board-title-caret {
+ margin: 0 $gl-padding-4 0 -10px;
}
}
@@ -102,20 +116,51 @@
width: 50px;
.board-title {
- > span {
- width: 100%;
- margin-top: -12px;
+ flex-direction: column;
+ height: 100%;
+ padding: $gl-padding-8 0;
+ }
+
+ .board-title-caret {
+ margin-top: 1px;
+ }
+
+ .user-avatar-link,
+ .milestone-icon {
+ margin-top: $gl-padding-8;
+ transform: rotate(90deg);
+ }
+
+ .board-title-text {
+ flex-grow: 0;
+ margin: $gl-padding-8 0;
+
+ .board-title-main-text {
display: block;
- transform: rotate(90deg) translate(35px, 0);
- overflow: initial;
+ }
+
+ .board-title-sub-text {
+ display: none;
}
}
- .board-title-expandable-toggle {
- position: absolute;
- top: 50%;
- left: 50%;
- margin-left: -10px;
+ .issue-count-badge {
+ border: 0;
+ white-space: nowrap;
+ }
+
+ .board-title-text > span,
+ .issue-count-badge > span {
+ height: 16px;
+
+ // Force the height to be equal to the parent's width while centering the contents.
+ // The contents *should* be about 16 px.
+ // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a
+ // rotated element has square dimensions so it won't overlap with its siblings.
+ margin: calc(50% - 8px) 0;
+
+ transform: rotate(90deg);
+ transform-origin: center;
}
}
}
@@ -152,12 +197,14 @@
}
.board-title {
+ align-items: center;
font-size: 1em;
border-bottom: 1px solid $border-color;
+ padding: $gl-padding-8 $gl-padding;
}
.board-title-text {
- margin: $gl-vert-padding auto $gl-vert-padding 0;
+ flex-grow: 1;
}
.board-delete {
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 6e98908eeed..73166940146 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -125,8 +125,24 @@
padding-left: $gl-padding-8;
}
- .section-header ~ .section.line {
- margin-left: $gl-padding;
+ .section-start {
+ display: inline;
+ }
+
+ .section-start,
+ .section-header {
+ &:hover {
+ cursor: pointer;
+
+ &::after {
+ content: '';
+ background-color: rgba($white-light, 0.2);
+ left: 0;
+ right: 0;
+ position: absolute;
+ height: $job-log-highlight-height;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index e12ea6fcb99..0b0a4e50146 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -167,7 +167,7 @@
min-width: 0;
.project-namespace {
- color: $gl-text-color-secondary;
+ color: $gl-text-color-tertiary;
}
}
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
index dfff3e15556..b88bd78cf3d 100644
--- a/app/assets/stylesheets/pages/container_registry.scss
+++ b/app/assets/stylesheets/pages/container_registry.scss
@@ -2,6 +2,12 @@
* Container Registry
*/
+.container-message {
+ span .btn {
+ margin: 0;
+ }
+}
+
.container-image {
border-bottom: 1px solid $white-normal;
}
@@ -21,4 +27,21 @@
.table.tags {
margin-bottom: 0;
+
+ .registry-image-row {
+ .check {
+ padding-right: $gl-padding;
+ width: 5%;
+ }
+
+ .action-buttons {
+ opacity: 0;
+ }
+
+ &:hover {
+ .action-buttons {
+ opacity: 1;
+ }
+ }
+ }
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index 2b932d164a5..d80155a416d 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -51,27 +51,19 @@
}
.stage-header {
- width: 26%;
- padding-left: $gl-padding;
+ width: 18.5%;
}
.median-header {
- width: 14%;
+ width: 21.5%;
}
.event-header {
width: 45%;
- padding-left: $gl-padding;
}
.total-time-header {
width: 15%;
- text-align: right;
- padding-right: $gl-padding;
- }
-
- .stage-name {
- font-weight: $gl-font-weight-bold;
}
}
@@ -153,23 +145,13 @@
}
.stage-nav-item {
- display: flex;
line-height: 65px;
- border-top: 1px solid transparent;
- border-bottom: 1px solid transparent;
- border-right: 1px solid $border-color;
- background-color: $gray-light;
+ border: 1px solid $border-color;
&.active {
- background-color: transparent;
- border-right-color: transparent;
- border-top-color: $border-color;
- border-bottom-color: $border-color;
- box-shadow: inset 2px 0 0 0 $blue-500;
-
- .stage-name {
- font-weight: $gl-font-weight-bold;
- }
+ background: $blue-50;
+ border-color: $blue-300;
+ box-shadow: inset 4px 0 0 0 $blue-500;
}
&:hover:not(.active) {
@@ -178,24 +160,12 @@
cursor: pointer;
}
- &:first-child {
- border-top: 0;
- }
-
- &:last-child {
- border-bottom: 0;
- }
-
- .stage-nav-item-cell {
- &.stage-median {
- margin-left: auto;
- margin-right: $gl-padding;
- min-width: calc(35% - #{$gl-padding});
- }
+ .stage-nav-item-cell.stage-name {
+ width: 44.5%;
}
- .stage-name {
- padding-left: 16px;
+ .stage-nav-item-cell.stage-median {
+ min-width: 43%;
}
.stage-empty,
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index d2d35d91e0b..77a2fd6b876 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -14,7 +14,7 @@
position: -webkit-sticky;
position: sticky;
top: $mr-file-header-top;
- z-index: 220;
+ z-index: 120;
&::before {
content: '';
@@ -408,6 +408,14 @@ table.code {
table-layout: fixed;
border-radius: 0 0 $border-radius-default $border-radius-default;
+ tr:first-of-type.line_expansion > td {
+ border-top: 0;
+ }
+
+ tr:nth-last-of-type(2).line_expansion > td {
+ border-bottom: 0;
+ }
+
tr.line_holder td {
line-height: $code-line-height;
font-size: $code-font-size;
@@ -1024,7 +1032,6 @@ table.code {
$top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
max-height: calc(100vh - #{$top-pos});
- padding-right: $gl-padding;
z-index: 202;
.with-performance-bar & {
@@ -1035,7 +1042,7 @@ table.code {
.drag-handle {
bottom: 16px;
- transform: translateX(-6px);
+ transform: translateX(10px);
}
}
@@ -1093,6 +1100,21 @@ table.code {
line-height: 0;
}
+.discussion-collapsible {
+ margin: 0 $gl-padding $gl-padding 71px;
+
+ .notes {
+ border-radius: $border-radius-default;
+ }
+}
+
+.parallel {
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
+}
+
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
@include fixed-width-container;
@@ -1110,6 +1132,11 @@ table.code {
padding-right: 0;
}
}
+
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
}
.image-diff-overlay,
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index cff2e274390..1502cf18440 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -412,10 +412,6 @@ table.pipeline-project-metrics tr td {
font-size: $gl-font-size-large;
}
- .item-visibility {
- color: $gl-text-color-secondary;
- }
-
@include media-breakpoint-down(md) {
.title {
font-size: $gl-font-size;
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index 7610c5cf6f3..ef872e693e0 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -38,3 +38,9 @@
.documentation {
padding: 7px;
}
+
+.card.links-card {
+ a {
+ color: $blue-600;
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 6a0127eb51c..0e844b0e4a5 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -126,6 +126,16 @@
}
}
+.assignee {
+ .merge-icon {
+ color: $orange-500;
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ text-shadow: -1px -1px 0 $white-light, 1px -1px 0 $white-light, -1px 1px 0 $white-light, 1px 1px 0 $white-light;
+ }
+}
+
.right-sidebar {
position: fixed;
top: $header-height;
@@ -202,7 +212,6 @@
&.assignee {
.author-link {
display: block;
- padding-left: 42px;
position: relative;
&:hover {
@@ -210,12 +219,6 @@
text-decoration: underline;
}
}
-
- .avatar {
- left: 0;
- position: absolute;
- top: 0;
- }
}
}
}
@@ -354,13 +357,6 @@
margin-top: 0;
}
- .assignee .avatar {
- float: left;
- margin-right: 10px;
- margin-bottom: 0;
- margin-left: 0;
- }
-
.assignee .user-list .avatar {
margin: 0;
}
@@ -390,7 +386,7 @@
.block {
width: $gutter-collapsed-width - 2px;
- padding: 15px 0 0;
+ padding: 0;
border-bottom: 0;
overflow: hidden;
@@ -427,10 +423,13 @@
}
.sidebar-collapsed-icon {
- display: block;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
width: 100%;
+ height: $sidebar-toggle-height;
text-align: center;
- margin-bottom: 10px;
color: $gl-text-color-secondary;
svg {
@@ -470,6 +469,16 @@
}
.btn-clipboard {
+ /*
+ This change should be temporary, because the DOM currently gets
+ generated from a ruby definition in `app/helpers/button_helper.rb`.
+ As soon as the `copy to clipboard` button will be transfered to
+ Vue this should be adjusted as well.
+ */
+ flex: 1;
+ align-self: stretch;
+ padding: 0;
+
border: 0;
background: transparent;
color: $gl-text-color-secondary;
@@ -493,7 +502,6 @@
.sidebar-collapsed-user {
padding-bottom: 0;
- margin-bottom: 10px;
.author-link {
padding-left: 0;
@@ -509,7 +517,12 @@
display: none;
}
+ .merge-icon {
+ font-size: 10px;
+ }
+
.multiple-users {
+ position: relative;
height: 24px;
margin-bottom: 17px;
margin-top: 4px;
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index 11e8a32389f..7d5e185834b 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -30,6 +30,10 @@
.dropdown-content {
max-height: 135px;
}
+
+ .dropdown-label-box {
+ flex: 0 0 auto;
+ }
}
.dropdown-new-label {
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 68af01f9ccc..ae92a2fbd7b 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -9,10 +9,6 @@
}
}
-.member-sort-dropdown {
- margin-left: $gl-padding-8;
-}
-
.member {
&.is-overridden {
.btn-ldap-override {
@@ -62,43 +58,11 @@
}
}
-.member-search-form {
- position: relative;
-
- @include media-breakpoint-up(sm) {
- float: right;
- }
-
- .dropdown {
- width: 100%;
- margin-top: 5px;
-
- .dropdown-menu-toggle {
- vertical-align: middle;
- width: 100%;
- }
-
- @include media-breakpoint-up(sm) {
- margin-top: 0;
- width: 155px;
- }
- }
-
- .form-control {
- width: 100%;
- padding-right: 35px;
-
- @include media-breakpoint-up(sm) {
- width: 250px;
- }
- }
-}
-
.member-search-btn {
position: absolute;
right: 4px;
top: 0;
- height: 35px;
+ height: $input-height;
padding-left: 10px;
padding-right: 10px;
color: $gray-darkest;
@@ -177,7 +141,7 @@
padding-bottom: 1px;
}
- .flex-project-members-form {
+ .flex-users-form {
flex-wrap: nowrap;
white-space: nowrap;
margin-left: auto;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 3917937f4af..c8d155706a9 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -10,8 +10,8 @@
float: left;
}
- > *:not(:last-child) {
- margin-right: 10px;
+ > *:not(:first-child) {
+ margin-left: 10px;
}
}
@@ -69,7 +69,7 @@
content: '';
border-left: 1px solid $gray-200;
position: absolute;
- left: 32px;
+ left: 28px;
top: -17px;
height: 16px;
}
@@ -88,7 +88,7 @@
}
.mr-widget-info {
- padding-left: $gl-padding-50 - $gl-padding-32;
+ padding-left: $gl-padding;
padding-right: $gl-padding;
}
@@ -114,7 +114,7 @@
padding: $gl-padding;
@include media-breakpoint-up(md) {
- padding-left: $gl-padding-50;
+ padding-left: $gl-padding-8 * 7;
}
}
}
@@ -208,6 +208,10 @@
font-size: 22px;
}
+ .mr-loading-icon {
+ margin: 3px 0;
+ }
+
.ci-status-icon svg {
margin: 3px 0;
position: relative;
@@ -262,19 +266,11 @@
}
}
- .widget-status-icon {
- align-self: flex-start;
- }
-
.mr-widget-body {
line-height: 28px;
@include clearfix;
- &.media > *:first-child {
- margin-right: 10px;
- }
-
.approve-btn {
margin-right: 5px;
}
@@ -312,6 +308,7 @@
.bold {
font-weight: $gl-font-weight-bold;
color: $gl-gray-light;
+ margin-left: 10px;
}
.state-label {
@@ -377,9 +374,13 @@
&.mr-widget-empty-state {
line-height: 20px;
+ padding: $gl-padding;
.artwork {
- margin-bottom: $gl-padding;
+
+ @include media-breakpoint-down(md) {
+ margin-bottom: $gl-padding;
+ }
}
.text {
@@ -395,8 +396,7 @@
}
.mr-widget-help {
- padding: 10px 16px 10px $gl-padding-50;
- font-style: italic;
+ padding: 10px 16px 10px ($gl-padding-8 * 7);
}
.ci-coverage {
@@ -518,7 +518,7 @@
}
.mr-links {
- padding-left: $status-icon-size + $gl-btn-padding;
+ padding-left: $gl-padding-8 + $status-icon-size + $gl-btn-padding;
&:last-child {
padding-bottom: $gl-padding;
@@ -905,7 +905,7 @@
}
.deploy-heading,
-.merge-train-info {
+.merge-train-position-indicator {
@include media-breakpoint-up(md) {
padding: $gl-padding-8 $gl-padding;
}
@@ -913,7 +913,7 @@
.media-body {
min-width: 0;
font-size: 12px;
- margin-left: 48px;
+ margin-left: 40px;
}
&:not(:last-child) {
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index e880b941d67..6c03dbb56a7 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -134,6 +134,15 @@ $note-form-margin-left: 72px;
}
}
+ .discussion-toggle-replies {
+ border-top: 0;
+ border-radius: 4px 4px 0 0;
+
+ &.collapsed {
+ border-radius: 4px;
+ }
+ }
+
.note-created-ago,
.note-updated-at {
white-space: normal;
@@ -396,7 +405,7 @@ $note-form-margin-left: 72px;
border-radius: 0;
@media (min-width: map-get($grid-breakpoints, md)) {
- top: 91px;
+ top: $mr-tabs-height + $header-height;
.with-performance-bar & {
top: 126px;
@@ -462,6 +471,14 @@ $note-form-margin-left: 72px;
position: relative;
}
+ .notes-content .discussion-notes.diff-discussions {
+ border-bottom: 1px solid $border-color;
+
+ &:nth-last-child(1) {
+ border-bottom: 0;
+ }
+ }
+
.notes_holder {
font-family: $regular-font;
@@ -517,6 +534,17 @@ $note-form-margin-left: 72px;
.discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default;
position: relative;
+
+ .discussion-form {
+ width: 100%;
+ background-color: $gray-light;
+ padding: 0;
+ }
+
+ .disabled-comment {
+ padding: $gl-vert-padding 0;
+ width: 100%;
+ }
}
}
@@ -569,7 +597,8 @@ $note-form-margin-left: 72px;
}
.discussion-header {
- min-height: 74px;
+ min-height: $line-height-base * 2em;
+ box-sizing: content-box;
.note-header-info {
padding-bottom: 0;
@@ -579,13 +608,10 @@ $note-form-margin-left: 72px;
overflow-x: auto;
overflow-y: hidden;
}
-}
-.unresolved {
- .discussion-header {
- .note-header-info {
- margin-top: $gl-padding-8;
- }
+ &.note-wrapper {
+ display: flex;
+ align-items: center;
}
}
@@ -780,7 +806,7 @@ $note-form-margin-left: 72px;
border-radius: $border-radius-base;
border: 1px solid $border-gray-normal;
color: $note-disabled-comment-color;
- padding: 90px 0;
+ padding: $gl-padding-8 0;
&.discussion-locked {
border: 0;
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index aa6bbc8e473..5f4db37c317 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -588,8 +588,8 @@
}
.ci-status-icon svg {
- height: 20px;
- width: 20px;
+ height: 24px;
+ width: 24px;
}
.dropdown-menu-toggle {
@@ -695,6 +695,10 @@
top: -1px;
}
+ .spinner {
+ top: 2px;
+ }
+
&.play {
svg {
left: 2px;
@@ -861,6 +865,7 @@ button.mini-pipeline-graph-dropdown-toggle {
}
}
+ .spinner,
svg {
width: $ci-action-dropdown-svg-size;
height: $ci-action-dropdown-svg-size;
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
index 2d600e3aef6..72f1b5307ec 100644
--- a/app/assets/stylesheets/pages/prometheus.scss
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -1,17 +1,17 @@
.prometheus-graphs {
- .dropdowns {
- .dropdown-menu-toggle {
- svg {
- position: absolute;
- right: 5%;
- top: 25%;
- }
+ .dropdown-buttons {
+ > div {
+ margin-left: auto;
}
+ }
- .dropdown-menu-toggle,
- .dropdown-menu {
- width: 240px;
- }
+ .col-form-label {
+ line-height: 1;
+ padding-top: 0;
+ }
+
+ .form-group {
+ margin-bottom: map-get($spacing-scale, 3);
}
}
@@ -29,6 +29,11 @@
padding: $gl-padding / 2;
}
+.prometheus-graph-embed {
+ border: 1px solid $border-color;
+ border-radius: $border-radius-default;
+}
+
.prometheus-graph-header {
display: flex;
align-items: center;
diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss
index 94da72622af..0fbf7033aa5 100644
--- a/app/assets/stylesheets/pages/reports.scss
+++ b/app/assets/stylesheets/pages/reports.scss
@@ -48,16 +48,11 @@
padding: $gl-padding-top $gl-padding;
border-top: 1px solid $border-color;
}
-
- .report-block-list-icon .loading-container {
- position: relative;
- left: -2px;
- }
}
.report-block-container {
border-top: 1px solid $border-color;
- padding: $gl-padding-top;
+ padding: $gl-padding - 2;
background-color: $gray-light;
// Clean MR widget CSS
@@ -96,17 +91,14 @@
.ci-status-icon {
svg {
- width: 16px;
- height: 16px;
- left: -2px;
+ width: 24px;
+ height: 24px;
}
}
}
.report-block-list-issue {
display: flex;
- align-items: flex-start;
- align-content: flex-start;
}
.is-dismissed .report-block-list-issue-description,
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index dbf600df9d6..58e46cfb70f 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -186,15 +186,12 @@ input[type='checkbox']:hover {
}
}
-.search-holder {
- @include media-breakpoint-up(sm) {
- display: flex;
- }
+.search-field-holder,
+.project-filter-form {
+ flex: 1 0 auto;
+ position: relative;
- .search-field-holder,
- .project-filter-form {
- flex: 1 0 auto;
- position: relative;
+ .search-holder & {
margin-right: 0;
@include media-breakpoint-up(sm) {
@@ -202,6 +199,7 @@ input[type='checkbox']:hover {
}
}
+
.search-icon {
position: absolute;
left: 10px;
@@ -215,9 +213,16 @@ input[type='checkbox']:hover {
padding-left: $gl-padding + 15px;
padding-right: $gl-padding + 15px;
}
+}
+
+.search-holder {
+ @include media-breakpoint-up(sm) {
+ display: flex;
+ }
.btn-search,
- .btn-success {
+ .btn-success,
+ .dropdown-menu-toggle {
width: 100%;
margin-top: 5px;
@@ -236,9 +241,6 @@ input[type='checkbox']:hover {
}
.dropdown-menu-toggle {
- width: 100%;
- margin-top: 5px;
-
@include media-breakpoint-up(sm) {
width: 180px;
margin-top: 0;
@@ -262,6 +264,25 @@ input[type='checkbox']:hover {
}
}
+.search-page-form {
+ .dropdown-menu-toggle,
+ .btn-search {
+ width: 100%;
+ }
+
+ .dropdown-menu-toggle {
+ @include media-breakpoint-up(lg) {
+ width: 240px;
+ }
+ }
+
+ .btn-search {
+ @include media-breakpoint-up(lg) {
+ width: auto;
+ }
+ }
+}
+
// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
/* stylelint-disable property-no-vendor-prefix */
input[type='search']::-webkit-search-decoration,
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index 3b62121eb0d..79de1d78a6e 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -272,7 +272,7 @@
}
.custom-monitored-metrics {
- .card-title {
+ .card-header {
display: flex;
align-items: center;
@@ -292,17 +292,6 @@
}
}
- .loading-metrics,
- .empty-metrics {
- padding: 30px 10px;
-
- p,
- .btn {
- margin-top: 10px;
- margin-bottom: 0;
- }
- }
-
.loading-metrics .metrics-load-spinner {
color: $gl-gray-700;
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index 586365eb1ce..7b64c67ae34 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -4,7 +4,7 @@
*/
.todos-list > .todo {
- // workaround because we cannot use border-colapse
+ // workaround because we cannot use border-collapse
border-top: 1px solid transparent;
display: flex;
flex-direction: row;
diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss
new file mode 100644
index 00000000000..3b018c1e087
--- /dev/null
+++ b/app/assets/stylesheets/pages/users.scss
@@ -0,0 +1,105 @@
+.user-sort-dropdown {
+ margin-left: $gl-padding-8;
+}
+
+.user-search-form {
+ position: relative;
+
+ @include media-breakpoint-up(sm) {
+ float: right;
+ }
+
+ .dropdown {
+ width: 100%;
+ margin-top: 5px;
+
+ .dropdown-menu-toggle {
+ vertical-align: middle;
+ width: 100%;
+ }
+
+ @include media-breakpoint-up(sm) {
+ margin-top: 0;
+ width: 155px;
+ }
+ }
+
+ .form-control {
+ width: 100%;
+ padding-right: 35px;
+
+ @include media-breakpoint-up(sm) {
+ width: 250px;
+ }
+ }
+}
+
+.user-search-btn {
+ position: absolute;
+ right: 4px;
+ top: 0;
+ height: 35px;
+ padding-left: 10px;
+ padding-right: 10px;
+ color: $gray-darkest;
+ background: transparent;
+ border: 0;
+ outline: 0;
+}
+
+.flex-users-panel {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+
+ @include media-breakpoint-down(sm) {
+ display: block;
+
+ .flex-project-title {
+ vertical-align: top;
+ display: inline-block;
+ max-width: 90%;
+ }
+ }
+
+ .flex-project-title {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .badge.badge-pill {
+ height: 17px;
+ line-height: 16px;
+ margin-right: 5px;
+ padding-top: 1px;
+ padding-bottom: 1px;
+ }
+
+ .flex-users-form {
+ flex-wrap: nowrap;
+ white-space: nowrap;
+ margin-left: auto;
+ }
+}
+
+.content-list.members-list li {
+ display: flex;
+ justify-content: space-between;
+
+ .list-item-name {
+ float: none;
+ display: flex;
+ flex: 1;
+ }
+}
+
+.card-body .user-info {
+ float: left;
+
+ .user {
+ color: $gl-text-color;
+ font-weight: $gl-font-weight-bold;
+ }
+}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 10120a472d3..0b65b915abf 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -1,19 +1,3 @@
-.new-wiki-page {
- .new-wiki-page-slug-tip {
- display: inline-block;
- max-width: 100%;
- margin-top: 5px;
- }
-}
-
-.wiki-form {
- .edit-wiki-page-slug-tip {
- display: inline-block;
- max-width: 100%;
- margin-top: 5px;
- }
-}
-
.title .edit-wiki-header {
width: 780px;
margin-left: auto;
@@ -22,7 +6,6 @@
}
.wiki-page-header {
- @extend .top-area;
position: relative;
.wiki-breadcrumb {
@@ -49,13 +32,11 @@
color: $gl-text-color-secondary;
}
- .git-access-header {
- padding: $gl-padding 0 $gl-padding-top;
- }
-
.git-clone-holder {
- width: 100%;
- padding-bottom: 40px;
+ .input-group-prepend,
+ .input-group-append {
+ background-color: transparent;
+ }
}
button.sidebar-toggle {
@@ -65,19 +46,8 @@
display: block;
}
- @include media-breakpoint-up(sm) {
- &.has-sidebar-toggle {
- padding-right: 40px;
- }
-
- .git-clone-holder {
- width: 480px;
- padding-bottom: $gl-padding;
- }
-
- .nav-controls {
- width: auto;
- }
+ &.has-sidebar-toggle .git-access-header {
+ padding-right: $sidebar-toggle-width;
}
@include media-breakpoint-up(md) {
@@ -122,10 +92,6 @@
padding: 0 $gl-padding;
}
- .block {
- width: 100%;
- }
-
a {
color: $layout-link-gray;
@@ -168,6 +134,10 @@
}
ul.wiki-pages-list.content-list {
+ a {
+ color: $blue-600;
+ }
+
ul {
list-style: none;
margin-left: 0;
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 5a8940ffd6d..ad7d87f0bf6 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -1,6 +1,5 @@
@import 'framework/variables';
@import 'framework/variables_overrides';
-@import 'peek/views/rblineprof';
#js-peek {
position: fixed;
@@ -128,13 +127,3 @@
#modal-peek-pg-queries-content {
color: $black;
}
-
-.peek-rblineprof-file {
- pre.duration {
- width: 280px;
- }
-
- .data {
- overflow: visible;
- }
-}