summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/access_tokens/components/expires_at_field.vue14
-rw-r--r--app/assets/javascripts/access_tokens/index.js12
-rw-r--r--app/assets/javascripts/actioncable_consumer.js3
-rw-r--r--app/assets/javascripts/alert_management/components/alert_details.vue236
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue303
-rw-r--r--app/assets/javascripts/alert_management/constants.js46
-rw-r--r--app/assets/javascripts/alert_management/details.js47
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql9
-rw-r--r--app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql9
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/details.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql11
-rw-r--r--app/assets/javascripts/alert_management/list.js55
-rw-r--r--app/assets/javascripts/alert_management/services/index.js7
-rw-r--r--app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue4
-rw-r--r--app/assets/javascripts/api.js57
-rw-r--r--app/assets/javascripts/autosave.js13
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js4
-rw-r--r--app/assets/javascripts/behaviors/markdown/paste_markdown_table.js3
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js12
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_metrics.js27
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js2
-rw-r--r--app/assets/javascripts/blob/blob_fork_suggestion.js2
-rw-r--r--app/assets/javascripts/blob/components/blob_content.vue17
-rw-r--r--app/assets/javascripts/blob/components/blob_content_error.vue71
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_header.vue4
-rw-r--r--app/assets/javascripts/blob/components/blob_header.vue2
-rw-r--r--app/assets/javascripts/blob/components/blob_header_filepath.vue14
-rw-r--r--app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue2
-rw-r--r--app/assets/javascripts/blob/components/constants.js56
-rw-r--r--app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue3
-rw-r--r--app/assets/javascripts/blob/utils.js19
-rw-r--r--app/assets/javascripts/boards/components/board.js3
-rw-r--r--app/assets/javascripts/boards/components/board_column.vue6
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js5
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue6
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/constants.js8
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js2
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_collapse.svg1
-rw-r--r--app/assets/javascripts/boards/icons/fullscreen_expand.svg1
-rw-r--r--app/assets/javascripts/boards/index.js116
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js5
-rw-r--r--app/assets/javascripts/boards/models/assignee.js2
-rw-r--r--app/assets/javascripts/boards/models/issue.js28
-rw-r--r--app/assets/javascripts/boards/models/list.js87
-rw-r--r--app/assets/javascripts/boards/queries/board_list.fragment.graphql5
-rw-r--r--app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql15
-rw-r--r--app/assets/javascripts/boards/queries/group_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/queries/project_board.query.graphql13
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js114
-rw-r--r--app/assets/javascripts/boards/stores/state.js4
-rw-r--r--app/assets/javascripts/boards/toggle_focus.js46
-rw-r--r--app/assets/javascripts/broadcast_notification.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/ajax_variable_list.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue8
-rw-r--r--app/assets/javascripts/ci_variable_list/constants.js2
-rw-r--r--app/assets/javascripts/ci_variable_list/index.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/actions.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/ci_variable_list/store/mutations.js4
-rw-r--r--app/assets/javascripts/ci_variable_list/store/state.js1
-rw-r--r--app/assets/javascripts/close_reopen_report_toggle.js2
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js13
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue73
-rw-r--r--app/assets/javascripts/clusters/components/fluentd_output_settings.vue241
-rw-r--r--app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue4
-rw-r--r--app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue4
-rw-r--r--app/assets/javascripts/clusters/constants.js2
-rw-r--r--app/assets/javascripts/clusters/event_hub.js4
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js1
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js19
-rw-r--r--app/assets/javascripts/clusters_list/components/clusters.vue162
-rw-r--r--app/assets/javascripts/clusters_list/constants.js3
-rw-r--r--app/assets/javascripts/clusters_list/store/actions.js33
-rw-r--r--app/assets/javascripts/clusters_list/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/clusters_list/store/mutations.js10
-rw-r--r--app/assets/javascripts/clusters_list/store/state.js6
-rw-r--r--app/assets/javascripts/code_navigation/components/app.vue8
-rw-r--r--app/assets/javascripts/code_navigation/components/popover.vue33
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js9
-rw-r--r--app/assets/javascripts/code_navigation/store/mutations.js3
-rw-r--r--app/assets/javascripts/code_navigation/store/state.js1
-rw-r--r--app/assets/javascripts/comment_type_toggle.js2
-rw-r--r--app/assets/javascripts/commit/image_file.js5
-rw-r--r--app/assets/javascripts/commons/index.js1
-rw-r--r--app/assets/javascripts/compare_autocomplete.js5
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/dropdown.vue2
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue6
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue6
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue4
-rw-r--r--app/assets/javascripts/create_item_dropdown.js8
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js2
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js2
-rw-r--r--app/assets/javascripts/deploy_keys/eventhub.js4
-rw-r--r--app/assets/javascripts/design_management/components/app.vue3
-rw-r--r--app/assets/javascripts/design_management/components/delete_button.vue64
-rw-r--r--app/assets/javascripts/design_management/components/design_destroyer.vue66
-rw-r--r--app/assets/javascripts/design_management/components/design_note_pin.vue61
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue169
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue148
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue137
-rw-r--r--app/assets/javascripts/design_management/components/design_overlay.vue279
-rw-r--r--app/assets/javascripts/design_management/components/design_presentation.vue314
-rw-r--r--app/assets/javascripts/design_management/components/design_scaler.vue65
-rw-r--r--app/assets/javascripts/design_management/components/image.vue110
-rw-r--r--app/assets/javascripts/design_management/components/list/item.vue174
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/index.vue126
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/pagination.vue83
-rw-r--r--app/assets/javascripts/design_management/components/toolbar/pagination_button.vue48
-rw-r--r--app/assets/javascripts/design_management/components/upload/button.vue58
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_dropzone.vue134
-rw-r--r--app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue76
-rw-r--r--app/assets/javascripts/design_management/constants.js14
-rw-r--r--app/assets/javascripts/design_management/graphql.js45
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql22
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql8
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql28
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql5
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql3
-rw-r--r--app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql4
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql3
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql21
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql6
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/appData.query.graphql4
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql10
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql31
-rw-r--r--app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql26
-rw-r--r--app/assets/javascripts/design_management/graphql/typedefs.graphql12
-rw-r--r--app/assets/javascripts/design_management/index.js58
-rw-r--r--app/assets/javascripts/design_management/mixins/all_designs.js49
-rw-r--r--app/assets/javascripts/design_management/mixins/all_versions.js62
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue400
-rw-r--r--app/assets/javascripts/design_management/pages/index.vue323
-rw-r--r--app/assets/javascripts/design_management/router/constants.js3
-rw-r--r--app/assets/javascripts/design_management/router/index.js22
-rw-r--r--app/assets/javascripts/design_management/router/routes.js44
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js272
-rw-r--r--app/assets/javascripts/design_management/utils/design_management_utils.js125
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js95
-rw-r--r--app/assets/javascripts/design_management/utils/tracking.js28
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js21
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js11
-rw-r--r--app/assets/javascripts/diffs/components/app.vue23
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue75
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue4
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue6
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue36
-rw-r--r--app/assets/javascripts/diffs/components/no_changes.vue6
-rw-r--r--app/assets/javascripts/diffs/store/actions.js80
-rw-r--r--app/assets/javascripts/diffs/store/getters_versions_dropdowns.js5
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js5
-rw-r--r--app/assets/javascripts/diffs/store/utils.js53
-rw-r--r--app/assets/javascripts/dropzone_input.js6
-rw-r--r--app/assets/javascripts/editor/editor_lite.js4
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue10
-rw-r--r--app/assets/javascripts/environments/components/container.vue34
-rw-r--r--app/assets/javascripts/environments/components/delete_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue13
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue33
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue33
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/event_hub.js4
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js2
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue28
-rw-r--r--app/assets/javascripts/environments/index.js2
-rw-r--r--app/assets/javascripts/environments/mixins/canary_callout_mixin.js23
-rw-r--r--app/assets/javascripts/environments/mixins/container_mixin.js34
-rw-r--r--app/assets/javascripts/environments/mixins/environment_item_mixin.js13
-rw-r--r--app/assets/javascripts/environments/mixins/environments_app_mixin.js32
-rw-r--r--app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environments_table_mixin.js10
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js20
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue6
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue4
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js5
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js10
-rw-r--r--app/assets/javascripts/filtered_search/event_hub.js4
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js41
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js17
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js14
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js4
-rw-r--r--app/assets/javascripts/flash.js6
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue2
-rw-r--r--app/assets/javascripts/frequent_items/event_hub.js4
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js18
-rw-r--r--app/assets/javascripts/gl_dropdown.js15
-rw-r--r--app/assets/javascripts/gl_form.js8
-rw-r--r--app/assets/javascripts/groups/components/app.vue7
-rw-r--r--app/assets/javascripts/groups/event_hub.js4
-rw-r--r--app/assets/javascripts/groups/new_group_child.js2
-rw-r--r--app/assets/javascripts/header.js23
-rw-r--r--app/assets/javascripts/helpers/avatar_helper.js5
-rw-r--r--app/assets/javascripts/helpers/event_hub_factory.js20
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue37
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue18
-rw-r--r--app/assets/javascripts/ide/components/ide.vue24
-rw-r--r--app/assets/javascripts/ide/components/ide_review.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_side_bar.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue18
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue4
-rw-r--r--app/assets/javascripts/ide/components/nav_form.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue7
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue112
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue3
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue11
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue29
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue32
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue31
-rw-r--r--app/assets/javascripts/ide/constants.js1
-rw-r--r--app/assets/javascripts/ide/eventhub.js4
-rw-r--r--app/assets/javascripts/ide/lib/diff/diff.js13
-rw-r--r--app/assets/javascripts/ide/lib/editor.js3
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/vue.js306
-rw-r--r--app/assets/javascripts/ide/lib/themes/index.js20
-rw-r--r--app/assets/javascripts/ide/lib/themes/monokai.js169
-rw-r--r--app/assets/javascripts/ide/lib/themes/none.js17
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_dark.js1110
-rw-r--r--app/assets/javascripts/ide/lib/themes/solarized_light.js1101
-rw-r--r--app/assets/javascripts/ide/services/index.js8
-rw-r--r--app/assets/javascripts/ide/stores/actions.js20
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js6
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js36
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js19
-rw-r--r--app/assets/javascripts/ide/stores/mutations/project.js4
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js5
-rw-r--r--app/assets/javascripts/ide/stores/state.js2
-rw-r--r--app/assets/javascripts/ide/stores/utils.js43
-rw-r--r--app/assets/javascripts/ide/utils.js16
-rw-r--r--app/assets/javascripts/image_diff/helpers/badge_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js4
-rw-r--r--app/assets/javascripts/image_diff/helpers/dom_helper.js11
-rw-r--r--app/assets/javascripts/image_diff/image_diff.js6
-rw-r--r--app/assets/javascripts/import_projects/event_hub.js4
-rw-r--r--app/assets/javascripts/importer_status.js6
-rw-r--r--app/assets/javascripts/integrations/edit/components/active_toggle.vue11
-rw-r--r--app/assets/javascripts/integrations/edit/components/integration_form.vue50
-rw-r--r--app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue99
-rw-r--r--app/assets/javascripts/integrations/edit/components/trigger_fields.vue73
-rw-r--r--app/assets/javascripts/integrations/edit/event_hub.js4
-rw-r--r--app/assets/javascripts/integrations/edit/index.js40
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js8
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js2
-rw-r--r--app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql15
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue96
-rw-r--r--app/assets/javascripts/issuables_list/eventhub.js6
-rw-r--r--app/assets/javascripts/issuables_list/index.js55
-rw-r--r--app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql22
-rw-r--r--app/assets/javascripts/issue.js58
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue13
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue32
-rw-r--r--app/assets/javascripts/issue_show/event_hub.js4
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_app.vue70
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_form.vue23
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_progress.vue5
-rw-r--r--app/assets/javascripts/jira_import/components/jira_import_setup.vue6
-rw-r--r--app/assets/javascripts/jira_import/index.js1
-rw-r--r--app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql2
-rw-r--r--app/assets/javascripts/jira_import/utils.js49
-rw-r--r--app/assets/javascripts/jobs/components/environments_block.vue8
-rw-r--r--app/assets/javascripts/jobs/store/actions.js4
-rw-r--r--app/assets/javascripts/jobs/store/state.js2
-rw-r--r--app/assets/javascripts/labels_select.js50
-rw-r--r--app/assets/javascripts/lib/graphql.js1
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js13
-rw-r--r--app/assets/javascripts/lib/utils/downloader.js20
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js3
-rw-r--r--app/assets/javascripts/lib/utils/keys.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js7
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js66
-rw-r--r--app/assets/javascripts/line_highlighter.js9
-rw-r--r--app/assets/javascripts/locale/sprintf.js2
-rw-r--r--app/assets/javascripts/main.js5
-rw-r--r--app/assets/javascripts/member_expiration_date.js3
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestone_select.js41
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue228
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js4
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue286
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue307
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue13
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue277
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue (renamed from app/assets/javascripts/monitoring/components/panel_type.vue)187
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue55
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/variables/custom_variable.vue50
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_variable.vue39
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue56
-rw-r--r--app/assets/javascripts/monitoring/constants.js84
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js84
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js28
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js36
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js17
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js167
-rw-r--r--app/assets/javascripts/monitoring/utils.js201
-rw-r--r--app/assets/javascripts/monitoring/validators.js44
-rw-r--r--app/assets/javascripts/namespace_select.js8
-rw-r--r--app/assets/javascripts/new_branch_form.js5
-rw-r--r--app/assets/javascripts/notebook/cells/markdown.vue34
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue3
-rw-r--r--app/assets/javascripts/notes.js19
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue83
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue19
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue6
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue93
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue10
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue5
-rw-r--r--app/assets/javascripts/notes/index.js4
-rw-r--r--app/assets/javascripts/notes/mixins/discussion_navigation.js2
-rw-r--r--app/assets/javascripts/notes/stores/actions.js17
-rw-r--r--app/assets/javascripts/notes/stores/getters.js2
-rw-r--r--app/assets/javascripts/notes/stores/index.js6
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js4
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/general/index.js2
-rw-r--r--app/assets/javascripts/pages/admin/impersonation_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue8
-rw-r--r--app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue8
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue2
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue2
-rw-r--r--app/assets/javascripts/pages/milestones/shared/event_hub.js4
-rw-r--r--app/assets/javascripts/pages/profiles/personal_access_tokens/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/details/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/alert_management/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/blob/new/index.js11
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js10
-rw-r--r--app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue4
-rw-r--r--app/assets/javascripts/pages/projects/labels/event_hub.js4
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue139
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue3
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js4
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/dag/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/settings/access_tokens/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue49
-rw-r--r--app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue5
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js13
-rw-r--r--app/assets/javascripts/pages/search/init_filtered_search.js2
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue4
-rw-r--r--app/assets/javascripts/persistent_user_callout.js5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue44
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue91
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue114
-rw-r--r--app/assets/javascripts/pipelines/constants.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js65
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js14
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js2
-rw-r--r--app/assets/javascripts/profile/account/components/delete_account_modal.vue2
-rw-r--r--app/assets/javascripts/profile/account/components/update_username.vue10
-rw-r--r--app/assets/javascripts/project_select.js23
-rw-r--r--app/assets/javascripts/projects/commits/components/author_select.vue1
-rw-r--r--app/assets/javascripts/projects/commits/store/actions.js6
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue3
-rw-r--r--app/assets/javascripts/registry/explorer/components/image_list.vue124
-rw-r--r--app/assets/javascripts/registry/explorer/constants.js44
-rw-r--r--app/assets/javascripts/registry/explorer/index.js2
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue168
-rw-r--r--app/assets/javascripts/registry/explorer/pages/index.vue39
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue172
-rw-r--r--app/assets/javascripts/registry/explorer/router.js12
-rw-r--r--app/assets/javascripts/registry/explorer/stores/actions.js11
-rw-r--r--app/assets/javascripts/registry/explorer/stores/index.js1
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js1
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js2
-rw-r--r--app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue116
-rw-r--r--app/assets/javascripts/registry/shared/constants.js32
-rw-r--r--app/assets/javascripts/releases/components/app_edit.vue74
-rw-r--r--app/assets/javascripts/releases/components/app_index.vue11
-rw-r--r--app/assets/javascripts/releases/components/asset_links_form.vue2
-rw-r--r--app/assets/javascripts/releases/components/evidence_block.vue8
-rw-r--r--app/assets/javascripts/releases/components/release_block_footer.vue7
-rw-r--r--app/assets/javascripts/releases/components/release_block_header.vue13
-rw-r--r--app/assets/javascripts/releases/components/release_block_metadata.vue15
-rw-r--r--app/assets/javascripts/releases/components/release_block_milestone_info.vue12
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/actions.js11
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutation_types.js1
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/mutations.js4
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js4
-rw-r--r--app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue30
-rw-r--r--app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue64
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/actions.js79
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/getters.js48
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/index.js (renamed from app/assets/javascripts/static_site_editor/store/index.js)17
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/mutations.js20
-rw-r--r--app/assets/javascripts/reports/accessibility_report/store/state.js28
-rw-r--r--app/assets/javascripts/reports/components/grouped_issues_list.vue93
-rw-r--r--app/assets/javascripts/reports/components/grouped_test_reports_app.vue15
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue3
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue2
-rw-r--r--app/assets/javascripts/reports/constants.js3
-rw-r--r--app/assets/javascripts/reports/store/mutations.js3
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue5
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue5
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/repository/router.js21
-rw-r--r--app/assets/javascripts/repository/utils/commit.js1
-rw-r--r--app/assets/javascripts/right_sidebar.js3
-rw-r--r--app/assets/javascripts/search_autocomplete.js4
-rw-r--r--app/assets/javascripts/serverless/components/area.vue4
-rw-r--r--app/assets/javascripts/serverless/event_hub.js4
-rw-r--r--app/assets/javascripts/set_status_modal/event_hub.js4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue75
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue24
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue23
-rw-r--r--app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue7
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue18
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js4
-rw-r--r--app/assets/javascripts/sidebar/mount_sidebar.js21
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/snippet/snippet_bundle.js24
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue18
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue6
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue36
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_edit.vue4
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue21
-rw-r--r--app/assets/javascripts/snippets/components/snippet_header.vue40
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue10
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql2
-rw-r--r--app/assets/javascripts/static_site_editor/components/app.vue3
-rw-r--r--app/assets/javascripts/static_site_editor/components/edit_area.vue51
-rw-r--r--app/assets/javascripts/static_site_editor/components/publish_toolbar.vue15
-rw-r--r--app/assets/javascripts/static_site_editor/components/saved_changes_message.vue7
-rw-r--r--app/assets/javascripts/static_site_editor/components/skeleton_loader.vue19
-rw-r--r--app/assets/javascripts/static_site_editor/components/static_site_editor.vue95
-rw-r--r--app/assets/javascripts/static_site_editor/constants.js7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/index.js39
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql7
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql9
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql3
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql9
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/file.js11
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js24
-rw-r--r--app/assets/javascripts/static_site_editor/graphql/typedefs.graphql43
-rw-r--r--app/assets/javascripts/static_site_editor/index.js33
-rw-r--r--app/assets/javascripts/static_site_editor/pages/home.vue120
-rw-r--r--app/assets/javascripts/static_site_editor/pages/success.vue35
-rw-r--r--app/assets/javascripts/static_site_editor/router/constants.js2
-rw-r--r--app/assets/javascripts/static_site_editor/router/index.js15
-rw-r--r--app/assets/javascripts/static_site_editor/router/routes.js21
-rw-r--r--app/assets/javascripts/static_site_editor/services/submit_content_changes.js15
-rw-r--r--app/assets/javascripts/static_site_editor/store/actions.js37
-rw-r--r--app/assets/javascripts/static_site_editor/store/getters.js2
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutation_types.js8
-rw-r--r--app/assets/javascripts/static_site_editor/store/mutations.js36
-rw-r--r--app/assets/javascripts/static_site_editor/store/state.js23
-rw-r--r--app/assets/javascripts/syntax_highlight.js13
-rw-r--r--app/assets/javascripts/terminal/terminal.js13
-rw-r--r--app/assets/javascripts/tracking.js47
-rw-r--r--app/assets/javascripts/tree.js5
-rw-r--r--app/assets/javascripts/users_select/constants.js18
-rw-r--r--app/assets/javascripts/users_select/index.js (renamed from app/assets/javascripts/users_select.js)70
-rw-r--r--app/assets/javascripts/users_select/utils.js27
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue21
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue25
-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/event_hub.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue19
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js16
-rw-r--r--app/assets/javascripts/vue_shared/components/awards_list.vue17
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/clone_dropdown.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/code_block.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue30
-rw-r--r--app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue13
-rw-r--r--app/assets/javascripts/vue_shared/components/form/title.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/identicon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_warning.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field_view.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js37
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue66
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue52
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue73
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js26
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue27
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue38
-rw-r--r--app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js29
558 files changed, 15706 insertions, 2602 deletions
diff --git a/app/assets/javascripts/access_tokens/components/expires_at_field.vue b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
new file mode 100644
index 00000000000..d0932ad80e1
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/components/expires_at_field.vue
@@ -0,0 +1,14 @@
+<script>
+import { GlDatepicker } from '@gitlab/ui';
+
+export default {
+ name: 'ExpiresAtField',
+ components: { GlDatepicker },
+};
+</script>
+
+<template>
+ <gl-datepicker :target="null" :min-date="new Date()">
+ <slot></slot>
+ </gl-datepicker>
+</template>
diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js
new file mode 100644
index 00000000000..9bdb2940956
--- /dev/null
+++ b/app/assets/javascripts/access_tokens/index.js
@@ -0,0 +1,12 @@
+import Vue from 'vue';
+import ExpiresAtField from './components/expires_at_field.vue';
+
+const initExpiresAtField = () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: document.querySelector('.js-access-tokens-expires-at'),
+ components: { ExpiresAtField },
+ });
+};
+
+export default initExpiresAtField;
diff --git a/app/assets/javascripts/actioncable_consumer.js b/app/assets/javascripts/actioncable_consumer.js
new file mode 100644
index 00000000000..5658ffc1a38
--- /dev/null
+++ b/app/assets/javascripts/actioncable_consumer.js
@@ -0,0 +1,3 @@
+import { createConsumer } from '@rails/actioncable';
+
+export default createConsumer();
diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue
new file mode 100644
index 00000000000..89db7db77d5
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_details.vue
@@ -0,0 +1,236 @@
+<script>
+import * as Sentry from '@sentry/browser';
+import {
+ GlAlert,
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlSprintf,
+ GlTabs,
+ GlTab,
+ GlButton,
+ GlTable,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import query from '../graphql/queries/details.query.graphql';
+import { fetchPolicies } from '~/lib/graphql';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ALERTS_SEVERITY_LABELS } from '../constants';
+import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
+
+export default {
+ statuses: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
+ i18n: {
+ errorMsg: s__(
+ 'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.',
+ ),
+ fullAlertDetailsTitle: s__('AlertManagement|Alert details'),
+ overviewTitle: s__('AlertManagement|Overview'),
+ reportedAt: s__('AlertManagement|Reported %{when}'),
+ reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
+ },
+ severityLabels: ALERTS_SEVERITY_LABELS,
+ components: {
+ GlAlert,
+ GlIcon,
+ GlLoadingIcon,
+ GlSprintf,
+ GlDropdown,
+ GlDropdownItem,
+ GlTab,
+ GlTabs,
+ GlButton,
+ GlTable,
+ TimeAgoTooltip,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ alertId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ newIssuePath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ alert: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ alertId: this.alertId,
+ };
+ },
+ update(data) {
+ return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
+ },
+ error(error) {
+ this.errored = true;
+ Sentry.captureException(error);
+ },
+ },
+ },
+ data() {
+ return { alert: null, errored: false, isErrorDismissed: false };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.alert.loading;
+ },
+ reportedAtMessage() {
+ return this.alert?.monitoringTool
+ ? this.$options.i18n.reportedAtWithTool
+ : this.$options.i18n.reportedAt;
+ },
+ showErrorMsg() {
+ return this.errored && !this.isErrorDismissed;
+ },
+ },
+ methods: {
+ dismissError() {
+ this.isErrorDismissed = true;
+ },
+ updateAlertStatus(status) {
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid: this.alertId,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .catch(() => {
+ createFlash(
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
+ {{ $options.i18n.errorMsg }}
+ </gl-alert>
+ <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div>
+ <div v-if="alert" class="alert-management-details gl-relative">
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-200 gl-border-b-solid flex-column flex-sm-row"
+ >
+ <div
+ data-testid="alert-header"
+ class="gl-display-flex gl-align-items-center gl-justify-content-center"
+ >
+ <div
+ class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between"
+ >
+ <gl-icon
+ class="gl-mr-3 align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
+ <strong>{{ $options.severityLabels[alert.severity] }}</strong>
+ </div>
+ <span class="mx-2">&bull;</span>
+ <gl-sprintf :message="reportedAtMessage">
+ <template #when>
+ <time-ago-tooltip :time="alert.createdAt" class="gl-ml-3" />
+ </template>
+ <template #tool>{{ alert.monitoringTool }}</template>
+ </gl-sprintf>
+ </div>
+ <gl-button
+ v-if="glFeatures.createIssueFromAlertEnabled"
+ class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-create-issue-button"
+ data-testid="createIssueBtn"
+ :href="newIssuePath"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('AlertManagement|Create issue') }}
+ </gl-button>
+ </div>
+ <div
+ v-if="alert"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ >
+ <h2 data-testid="title">{{ alert.title }}</h2>
+ </div>
+ <gl-dropdown :text="$options.statuses[alert.status]" class="gl-absolute gl-right-0" right>
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ data-testid="statusDropdownItem"
+ class="gl-vertical-align-middle"
+ @click="updateAlertStatus(label)"
+ >
+ <span class="d-flex">
+ <gl-icon
+ class="flex-shrink-0 append-right-4"
+ :class="{ invisible: label.toUpperCase() !== alert.status }"
+ name="mobile-issue-close"
+ />
+ {{ label }}
+ </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <gl-tabs v-if="alert" data-testid="alertDetailsTabs">
+ <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle">
+ <ul class="pl-4 mb-n1">
+ <li v-if="alert.startedAt" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Start time') }}:</strong>
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </li>
+ <li v-if="alert.eventCount" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Events') }}:</strong>
+ <span data-testid="eventCount">{{ alert.eventCount }}</span>
+ </li>
+ <li v-if="alert.monitoringTool" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Tool') }}:</strong>
+ <span data-testid="monitoringTool">{{ alert.monitoringTool }}</span>
+ </li>
+ <li v-if="alert.service" class="my-2">
+ <strong class="bold">{{ s__('AlertManagement|Service') }}:</strong>
+ <span data-testid="service">{{ alert.service }}</span>
+ </li>
+ </ul>
+ </gl-tab>
+ <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle">
+ <gl-table
+ class="alert-management-details-table"
+ :items="[{ key: 'Value', ...alert }]"
+ :show-empty="true"
+ :busy="loading"
+ stacked
+ >
+ <template #empty>
+ {{ s__('AlertManagement|No alert data to display.') }}
+ </template>
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+ </gl-table>
+ </gl-tab>
+ </gl-tabs>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
new file mode 100644
index 00000000000..74fc19ff3d4
--- /dev/null
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -0,0 +1,303 @@
+<script>
+import {
+ GlEmptyState,
+ GlDeprecatedButton,
+ GlLoadingIcon,
+ GlTable,
+ GlAlert,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlTabs,
+ GlTab,
+} from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { joinPaths, visitUrl } from '~/lib/utils/url_utility';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import getAlerts from '../graphql/queries/getAlerts.query.graphql';
+import { ALERTS_STATUS, ALERTS_STATUS_TABS, ALERTS_SEVERITY_LABELS } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
+const bodyTrClass =
+ 'gl-border-1 gl-border-t-solid gl-border-gray-100 hover-bg-blue-50 hover-gl-cursor-pointer hover-gl-border-b-solid hover-gl-border-blue-200';
+
+export default {
+ bodyTrClass,
+ i18n: {
+ noAlertsMsg: s__(
+ "AlertManagement|No alerts available to display. If you think you're seeing this message in error, refresh the page.",
+ ),
+ errorMsg: s__(
+ "AlertManagement|There was an error displaying the alerts. Confirm your endpoint's configuration details to ensure alerts appear.",
+ ),
+ },
+ fields: [
+ {
+ key: 'severity',
+ label: s__('AlertManagement|Severity'),
+ tdClass: `${tdClass} rounded-top text-capitalize`,
+ },
+ {
+ key: 'startedAt',
+ label: s__('AlertManagement|Start time'),
+ tdClass,
+ },
+ {
+ key: 'endedAt',
+ label: s__('AlertManagement|End time'),
+ tdClass,
+ },
+ {
+ key: 'title',
+ label: s__('AlertManagement|Alert'),
+ thClass: 'w-30p',
+ tdClass,
+ },
+ {
+ key: 'eventCount',
+ label: s__('AlertManagement|Events'),
+ thClass: 'text-right event-count',
+ tdClass: `${tdClass} text-md-right event-count`,
+ },
+ {
+ key: 'status',
+ thClass: 'w-15p',
+ label: s__('AlertManagement|Status'),
+ tdClass: `${tdClass} rounded-bottom`,
+ },
+ ],
+ statuses: {
+ [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'),
+ [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'),
+ [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'),
+ },
+ severityLabels: ALERTS_SEVERITY_LABELS,
+ statusTabs: ALERTS_STATUS_TABS,
+ components: {
+ GlEmptyState,
+ GlLoadingIcon,
+ GlTable,
+ GlAlert,
+ GlDeprecatedButton,
+ TimeAgo,
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlTabs,
+ GlTab,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alertManagementEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ enableAlertManagementPath: {
+ type: String,
+ required: true,
+ },
+ userCanEnableAlertManagement: {
+ type: Boolean,
+ required: true,
+ },
+ emptyAlertSvgPath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ alerts: {
+ query: getAlerts,
+ variables() {
+ return {
+ projectPath: this.projectPath,
+ statuses: this.statusFilter,
+ };
+ },
+ update(data) {
+ return data.project.alertManagementAlerts.nodes;
+ },
+ error() {
+ this.errored = true;
+ },
+ },
+ },
+ data() {
+ return {
+ alerts: null,
+ errored: false,
+ isAlertDismissed: false,
+ isErrorAlertDismissed: false,
+ statusFilter: this.$options.statusTabs[4].filters,
+ };
+ },
+ computed: {
+ showNoAlertsMsg() {
+ return !this.errored && !this.loading && !this.alerts?.length && !this.isAlertDismissed;
+ },
+ showErrorMsg() {
+ return this.errored && !this.isErrorAlertDismissed;
+ },
+ loading() {
+ return this.$apollo.queries.alerts.loading;
+ },
+ },
+ methods: {
+ filterAlertsByStatus(tabIndex) {
+ this.statusFilter = this.$options.statusTabs[tabIndex].filters;
+ },
+ capitalizeFirstCharacter,
+ updateAlertStatus(status, iid) {
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatus,
+ variables: {
+ iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .then(() => {
+ this.$apollo.queries.alerts.refetch();
+ })
+ .catch(() => {
+ createFlash(
+ s__(
+ 'AlertManagement|There was an error while updating the status of the alert. Please try again.',
+ ),
+ );
+ });
+ },
+ navigateToAlertDetails({ iid }) {
+ return visitUrl(joinPaths(window.location.pathname, iid, 'details'));
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div v-if="alertManagementEnabled" class="alert-management-list">
+ <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true">
+ {{ $options.i18n.noAlertsMsg }}
+ </gl-alert>
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true">
+ {{ $options.i18n.errorMsg }}
+ </gl-alert>
+
+ <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterAlertsByStatus">
+ <gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
+ <template slot="title">
+ <span>{{ tab.title }}</span>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+
+ <h4 class="d-block d-md-none my-3">
+ {{ s__('AlertManagement|Alerts') }}
+ </h4>
+ <gl-table
+ class="alert-management-table mt-3"
+ :items="alerts"
+ :fields="$options.fields"
+ :show-empty="true"
+ :busy="loading"
+ stacked="md"
+ :tbody-tr-class="$options.bodyTrClass"
+ @row-clicked="navigateToAlertDetails"
+ >
+ <template #cell(severity)="{ item }">
+ <div
+ class="d-inline-flex align-items-center justify-content-between"
+ data-testid="severityField"
+ >
+ <gl-icon
+ class="mr-2"
+ :size="12"
+ :name="`severity-${item.severity.toLowerCase()}`"
+ :class="`icon-${item.severity.toLowerCase()}`"
+ />
+ {{ $options.severityLabels[item.severity] }}
+ </div>
+ </template>
+
+ <template #cell(startedAt)="{ item }">
+ <time-ago v-if="item.startedAt" :time="item.startedAt" />
+ </template>
+
+ <template #cell(endedAt)="{ item }">
+ <time-ago v-if="item.endedAt" :time="item.endedAt" />
+ </template>
+
+ <template #cell(title)="{ item }">
+ <div class="gl-max-w-full text-truncate">{{ item.title }}</div>
+ </template>
+
+ <template #cell(status)="{ item }">
+ <gl-dropdown
+ :text="capitalizeFirstCharacter(item.status.toLowerCase())"
+ class="w-100"
+ right
+ >
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ @click="updateAlertStatus(label, item.iid)"
+ >
+ <span class="d-flex">
+ <gl-icon
+ class="flex-shrink-0 append-right-4"
+ :class="{ invisible: label.toUpperCase() !== item.status }"
+ name="mobile-issue-close"
+ />
+ {{ label }}
+ </span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+
+ <template #empty>
+ {{ s__('AlertManagement|No alerts to display.') }}
+ </template>
+
+ <template #table-busy>
+ <gl-loading-icon size="lg" color="dark" class="mt-3" />
+ </template>
+ </gl-table>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="s__('AlertManagement|Surface alerts in GitLab')"
+ :svg-path="emptyAlertSvgPath"
+ >
+ <template #description>
+ <div class="d-block">
+ <span>{{
+ s__(
+ 'AlertManagement|Display alerts from all your monitoring tools directly within GitLab. Streamline the investigation of your alerts and the escalation of alerts to incidents.',
+ )
+ }}</span>
+ <a href="/help/user/project/operations/alert_management.html" target="_blank">
+ {{ s__('AlertManagement|More information') }}
+ </a>
+ </div>
+ <div v-if="userCanEnableAlertManagement" class="d-block center pt-4">
+ <gl-deprecated-button
+ category="primary"
+ variant="success"
+ :href="enableAlertManagementPath"
+ >
+ {{ s__('AlertManagement|Authorize external service') }}
+ </gl-deprecated-button>
+ </div>
+ </template>
+ </gl-empty-state>
+ </div>
+</template>
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
new file mode 100644
index 00000000000..9df01d9d0b5
--- /dev/null
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -0,0 +1,46 @@
+import { s__ } from '~/locale';
+
+export const ALERTS_SEVERITY_LABELS = {
+ CRITICAL: s__('AlertManagement|Critical'),
+ HIGH: s__('AlertManagement|High'),
+ MEDIUM: s__('AlertManagement|Medium'),
+ LOW: s__('AlertManagement|Low'),
+ INFO: s__('AlertManagement|Info'),
+ UNKNOWN: s__('AlertManagement|Unknown'),
+};
+
+export const ALERTS_STATUS = {
+ OPEN: 'OPEN',
+ TRIGGERED: 'TRIGGERED',
+ ACKNOWLEDGED: 'ACKNOWLEDGED',
+ RESOLVED: 'RESOLVED',
+ ALL: 'ALL',
+};
+
+export const ALERTS_STATUS_TABS = [
+ {
+ title: s__('AlertManagement|Open'),
+ status: ALERTS_STATUS.OPEN,
+ filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED],
+ },
+ {
+ title: s__('AlertManagement|Triggered'),
+ status: ALERTS_STATUS.TRIGGERED,
+ filters: [ALERTS_STATUS.TRIGGERED],
+ },
+ {
+ title: s__('AlertManagement|Acknowledged'),
+ status: ALERTS_STATUS.ACKNOWLEDGED,
+ filters: [ALERTS_STATUS.ACKNOWLEDGED],
+ },
+ {
+ title: s__('AlertManagement|Resolved'),
+ status: ALERTS_STATUS.RESOLVED,
+ filters: [ALERTS_STATUS.RESOLVED],
+ },
+ {
+ title: s__('AlertManagement|All alerts'),
+ status: ALERTS_STATUS.ALL,
+ filters: [ALERTS_STATUS.TRIGGERED, ALERTS_STATUS.ACKNOWLEDGED, ALERTS_STATUS.RESOLVED],
+ },
+];
diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js
new file mode 100644
index 00000000000..d3523e0a29d
--- /dev/null
+++ b/app/assets/javascripts/alert_management/details.js
@@ -0,0 +1,47 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import AlertDetails from './components/alert_details.vue';
+
+Vue.use(VueApollo);
+
+export default selector => {
+ const domEl = document.querySelector(selector);
+ const { alertId, projectPath, newIssuePath } = domEl.dataset;
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ },
+ ),
+ });
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: selector,
+ apolloProvider,
+ components: {
+ AlertDetails,
+ },
+ render(createElement) {
+ return createElement('alert-details', {
+ props: {
+ alertId,
+ projectPath,
+ newIssuePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql
new file mode 100644
index 00000000000..df802616e97
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/fragments/detailItem.fragment.graphql
@@ -0,0 +1,11 @@
+#import "./listItem.fragment.graphql"
+
+fragment AlertDetailItem on AlertManagementAlert {
+ ...AlertListItem
+ createdAt
+ monitoringTool
+ service
+ description
+ updatedAt
+ details
+}
diff --git a/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql
new file mode 100644
index 00000000000..fffe07b0cfd
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/fragments/listItem.fragment.graphql
@@ -0,0 +1,9 @@
+fragment AlertListItem on AlertManagementAlert {
+ iid
+ title
+ severity
+ status
+ startedAt
+ endedAt
+ eventCount
+}
diff --git a/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
new file mode 100644
index 00000000000..009ae0b2930
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/mutations/update_alert_status.graphql
@@ -0,0 +1,9 @@
+mutation ($projectPath: ID!, $status: AlertManagementStatus!, $iid: String!) {
+ updateAlertStatus(input: { iid: $iid, status: $status, projectPath: $projectPath }) {
+ errors
+ alert {
+ iid,
+ status,
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
new file mode 100644
index 00000000000..7c77715fad2
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/details.query.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/detailItem.fragment.graphql"
+
+query alertDetails($fullPath: ID!, $alertId: String) {
+ project(fullPath: $fullPath) {
+ alertManagementAlerts(iid: $alertId) {
+ nodes {
+ ...AlertDetailItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
new file mode 100644
index 00000000000..54b66389d5b
--- /dev/null
+++ b/app/assets/javascripts/alert_management/graphql/queries/getAlerts.query.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/listItem.fragment.graphql"
+
+query getAlerts($projectPath: ID!, $statuses: [AlertManagementStatus!]) {
+ project(fullPath: $projectPath) {
+ alertManagementAlerts(statuses: $statuses) {
+ nodes {
+ ...AlertListItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js
new file mode 100644
index 00000000000..cae6a536b56
--- /dev/null
+++ b/app/assets/javascripts/alert_management/list.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import AlertManagementList from './components/alert_management_list.vue';
+
+Vue.use(VueApollo);
+
+export default () => {
+ const selector = '#js-alert_management';
+
+ const domEl = document.querySelector(selector);
+ const { projectPath, enableAlertManagementPath, emptyAlertSvgPath } = domEl.dataset;
+ let { alertManagementEnabled, userCanEnableAlertManagement } = domEl.dataset;
+
+ alertManagementEnabled = parseBoolean(alertManagementEnabled);
+ userCanEnableAlertManagement = parseBoolean(userCanEnableAlertManagement);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ },
+ ),
+ });
+
+ return new Vue({
+ el: selector,
+ apolloProvider,
+ components: {
+ AlertManagementList,
+ },
+ render(createElement) {
+ return createElement('alert-management-list', {
+ props: {
+ projectPath,
+ enableAlertManagementPath,
+ emptyAlertSvgPath,
+ alertManagementEnabled,
+ userCanEnableAlertManagement,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/alert_management/services/index.js b/app/assets/javascripts/alert_management/services/index.js
new file mode 100644
index 00000000000..787603d3e7a
--- /dev/null
+++ b/app/assets/javascripts/alert_management/services/index.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ getAlertManagementList({ endpoint }) {
+ return axios.get(endpoint);
+ },
+};
diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
index 785598142fe..410c5c00e8a 100644
--- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
+++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue
@@ -6,7 +6,7 @@ import {
GlModal,
GlModalDirective,
} from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import axios from '~/lib/utils/axios_utils';
@@ -65,7 +65,7 @@ export default {
'AlertService|%{linkStart}Learn more%{linkEnd} about configuring this endpoint to receive alerts.',
),
{
- linkStart: `<a href="${esc(
+ linkStart: `<a href="${escape(
this.learnMoreUrl,
)}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 6301f6a3910..e527659a939 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -23,6 +23,8 @@ const Api = {
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
projectRunnersPath: '/api/:version/projects/:id/runners',
projectProtectedBranchesPath: '/api/:version/projects/:id/protected_branches',
+ projectSearchPath: '/api/:version/projects/:id/search',
+ projectMilestonesPath: '/api/:version/projects/:id/milestones',
mergeRequestsPath: '/api/:version/merge_requests',
groupLabelsPath: '/groups/:namespace_path/-/labels',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
@@ -46,6 +48,7 @@ const Api = {
mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines',
adminStatisticsPath: '/api/:version/application/statistics',
pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id',
+ pipelinesPath: '/api/:version/projects/:id/pipelines/',
environmentsPath: '/api/:version/projects/:id/environments',
rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw',
@@ -74,13 +77,11 @@ const Api = {
const url = Api.buildUrl(Api.groupsPath);
return axios
.get(url, {
- params: Object.assign(
- {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- },
- options,
- ),
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
})
.then(({ data }) => {
callback(data);
@@ -247,6 +248,23 @@ const Api = {
.then(({ data }) => data);
},
+ projectSearch(id, options = {}) {
+ const url = Api.buildUrl(Api.projectSearchPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: {
+ search: options.search,
+ scope: options.scope,
+ },
+ });
+ },
+
+ projectMilestones(id) {
+ const url = Api.buildUrl(Api.projectMilestonesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
@@ -281,7 +299,7 @@ const Api = {
};
return axios
.get(url, {
- params: Object.assign({}, defaults, options),
+ params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects')));
@@ -364,13 +382,11 @@ const Api = {
users(query, options) {
const url = Api.buildUrl(this.usersPath);
return axios.get(url, {
- params: Object.assign(
- {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- },
- options,
- ),
+ params: {
+ search: query,
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
});
},
@@ -401,7 +417,7 @@ const Api = {
};
return axios
.get(url, {
- params: Object.assign({}, defaults, options),
+ params: { ...defaults, ...options },
})
.then(({ data }) => callback(data))
.catch(() => flash(__('Something went wrong while fetching projects')));
@@ -502,6 +518,15 @@ const Api = {
return axios.get(url);
},
+ // Return all pipelines for a project or filter by query params
+ pipelines(id, options = {}) {
+ const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url, {
+ params: options,
+ });
+ },
+
environments(id) {
const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index 07d79ea1c70..5f50fcc112e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -3,7 +3,7 @@
import AccessorUtilities from './lib/utils/accessor';
export default class Autosave {
- constructor(field, key, fallbackKey) {
+ constructor(field, key, fallbackKey, lockVersion) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
@@ -12,6 +12,8 @@ export default class Autosave {
}
this.key = `autosave/${key}`;
this.fallbackKey = fallbackKey;
+ this.lockVersionKey = `${this.key}/lockVersion`;
+ this.lockVersion = lockVersion;
this.field.data('autosave', this);
this.restore();
this.field.on('input', () => this.save());
@@ -40,6 +42,11 @@ export default class Autosave {
}
}
+ getSavedLockVersion() {
+ if (!this.isLocalStorageAvailable) return;
+ return window.localStorage.getItem(this.lockVersionKey);
+ }
+
save() {
if (!this.field.length) return;
@@ -49,6 +56,9 @@ export default class Autosave {
if (this.fallbackKey) {
window.localStorage.setItem(this.fallbackKey, text);
}
+ if (this.lockVersion !== undefined) {
+ window.localStorage.setItem(this.lockVersionKey, this.lockVersion);
+ }
return window.localStorage.setItem(this.key, text);
}
@@ -58,6 +68,7 @@ export default class Autosave {
reset() {
if (!this.isLocalStorageAvailable) return;
+ window.localStorage.removeItem(this.lockVersionKey);
window.localStorage.removeItem(this.fallbackKey);
return window.localStorage.removeItem(this.key);
}
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 67164997bd8..8381b050900 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, @gitlab/require-i18n-strings */
import $ from 'jquery';
-import _ from 'underscore';
+import { uniq } from 'lodash';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { __ } from './locale';
@@ -513,7 +513,7 @@ export class AwardsHandler {
addEmojiToFrequentlyUsedList(emoji) {
if (this.emoji.isEmojiNameValid(emoji)) {
- this.frequentlyUsedEmojis = _.uniq(this.getFrequentlyUsedEmojis().concat(emoji));
+ this.frequentlyUsedEmojis = uniq(this.getFrequentlyUsedEmojis().concat(emoji));
Cookies.set('frequently_used_emojis', this.frequentlyUsedEmojis.join(','), { expires: 365 });
}
}
@@ -522,9 +522,7 @@ export class AwardsHandler {
return (
this.frequentlyUsedEmojis ||
(() => {
- const frequentlyUsedEmojis = _.uniq(
- (Cookies.get('frequently_used_emojis') || '').split(','),
- );
+ const frequentlyUsedEmojis = uniq((Cookies.get('frequently_used_emojis') || '').split(','));
this.frequentlyUsedEmojis = frequentlyUsedEmojis.filter(inputName =>
this.emoji.isEmojiNameValid(inputName),
);
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index c3541e62568..48bcba7bcca 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -17,10 +17,11 @@ function showTooltip(target, title) {
}
function genericSuccess(e) {
- showTooltip(e.trigger, __('Copied'));
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
+
+ showTooltip(e.trigger, __('Copied'));
}
/**
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
index 7e020139fe7..f8465111959 100644
--- a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this */
import { Mark } from 'tiptap';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
export default class InlineHTML extends Mark {
@@ -35,7 +35,7 @@ export default class InlineHTML extends Mark {
mixable: true,
open(state, mark) {
return `<${mark.attrs.tag}${
- mark.attrs.title ? ` title="${state.esc(esc(mark.attrs.title))}"` : ''
+ mark.attrs.title ? ` title="${state.esc(escape(mark.attrs.title))}"` : ''
}>`;
},
close(state, mark) {
diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
index 665a7216424..278dd857ab8 100644
--- a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
+++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
@@ -47,7 +47,8 @@ export default class PasteMarkdownTable {
const htmlData = this.data.getData('text/html');
this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
- const tables = this.doc.querySelectorAll('table');
+ // Avoid formatting lines that were copied from a diff
+ const tables = this.doc.querySelectorAll('table:not(.diff-wrap-lines)');
// We're only looking for exactly one table. If there happens to be
// multiple tables, it's possible an application copied data into
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index 137cc7b4669..01627b7206d 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -16,7 +16,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
- initUserPopovers(this.find('.gfm-project_member').get());
+ initUserPopovers(this.find('.js-user-link').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 fe63ebd470d..057cdb6cc4c 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -24,13 +24,23 @@ let mermaidModule = {};
function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
+ let theme = 'neutral';
+
+ if (
+ window.gon?.user_color_scheme === 'dark' &&
+ // if on the Web IDE page
+ document.querySelector('.ide')
+ ) {
+ theme = 'dark';
+ }
+
mermaid.initialize({
// mermaid core options
mermaid: {
startOnLoad: false,
},
// mermaidAPI options
- theme: 'neutral',
+ theme,
flowchart: {
useMaxWidth: true,
htmlLabels: false,
diff --git a/app/assets/javascripts/behaviors/markdown/render_metrics.js b/app/assets/javascripts/behaviors/markdown/render_metrics.js
index 9260a89bd52..37cbce46b6f 100644
--- a/app/assets/javascripts/behaviors/markdown/render_metrics.js
+++ b/app/assets/javascripts/behaviors/markdown/render_metrics.js
@@ -1,15 +1,12 @@
import Vue from 'vue';
-import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue';
import { createStore } from '~/monitoring/stores/embed_group/';
// TODO: Handle copy-pasting - https://gitlab.com/gitlab-org/gitlab-foss/issues/64369.
export default function renderMetrics(elements) {
if (!elements.length) {
- return;
+ return Promise.resolve();
}
- const EmbedGroupComponent = Vue.extend(EmbedGroup);
-
const wrapperList = [];
elements.forEach(element => {
@@ -31,14 +28,20 @@ export default function renderMetrics(elements) {
element.parentNode.removeChild(element);
});
- wrapperList.forEach(wrapper => {
- // eslint-disable-next-line no-new
- new EmbedGroupComponent({
- el: wrapper,
- store: createStore(),
- propsData: {
- urls: wrapper.urls,
- },
+ return import(
+ /* webpackChunkName: 'gfm_metrics' */ '~/monitoring/components/embeds/embed_group.vue'
+ ).then(({ default: EmbedGroup }) => {
+ const EmbedGroupComponent = Vue.extend(EmbedGroup);
+
+ wrapperList.forEach(wrapper => {
+ // eslint-disable-next-line no-new
+ new EmbedGroupComponent({
+ el: wrapper,
+ store: createStore(),
+ propsData: {
+ urls: wrapper.urls,
+ },
+ });
});
});
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
index d5d8edd5ac0..c35a073b291 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_blob.js
@@ -22,7 +22,7 @@ function eventHasModifierKeys(event) {
export default class ShortcutsBlob extends Shortcuts {
constructor(opts) {
- const options = Object.assign({}, defaults, opts);
+ const options = { ...defaults, ...opts };
super(options.skipResetBindings);
this.options = options;
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js
index 476b9405a9e..44dfbfcfe1c 100644
--- a/app/assets/javascripts/blob/blob_fork_suggestion.js
+++ b/app/assets/javascripts/blob/blob_fork_suggestion.js
@@ -17,7 +17,7 @@ const defaults = {
class BlobForkSuggestion {
constructor(options) {
- this.elementMap = Object.assign({}, defaults, options);
+ this.elementMap = { ...defaults, ...options };
this.onOpenButtonClick = this.onOpenButtonClick.bind(this);
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
}
diff --git a/app/assets/javascripts/blob/components/blob_content.vue b/app/assets/javascripts/blob/components/blob_content.vue
index 7d5d48cfc31..4f433bd8dfd 100644
--- a/app/assets/javascripts/blob/components/blob_content.vue
+++ b/app/assets/javascripts/blob/components/blob_content.vue
@@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue';
+import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
+
export default {
components: {
GlLoadingIcon,
BlobContentError,
},
props: {
+ blob: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
content: {
type: String,
default: '',
@@ -37,6 +44,8 @@ export default {
return this.activeViewer.renderError;
},
},
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
@@ -44,7 +53,13 @@ export default {
<gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
<template v-else>
- <blob-content-error v-if="viewerError" :viewer-error="viewerError" />
+ <blob-content-error
+ v-if="viewerError"
+ :viewer-error="viewerError"
+ :blob="blob"
+ @[$options.BLOB_RENDER_EVENT_LOAD]="$emit($options.BLOB_RENDER_EVENT_LOAD)"
+ @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="$emit($options.BLOB_RENDER_EVENT_SHOW_SOURCE)"
+ />
<component
:is="viewer"
v-else
diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue
index 0f1af0a962d..44dc4a6c727 100644
--- a/app/assets/javascripts/blob/components/blob_content_error.vue
+++ b/app/assets/javascripts/blob/components/blob_content_error.vue
@@ -1,15 +1,84 @@
<script>
+import { __ } from '~/locale';
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { BLOB_RENDER_ERRORS } from './constants';
+
export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
props: {
viewerError: {
type: String,
required: true,
},
+ blob: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ notStoredExternally() {
+ return this.viewerError !== BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id;
+ },
+ renderErrorReason() {
+ const defaultReasonPath = Object.keys(BLOB_RENDER_ERRORS.REASONS).find(
+ reason => BLOB_RENDER_ERRORS.REASONS[reason].id === this.viewerError,
+ );
+ const defaultReason = BLOB_RENDER_ERRORS.REASONS[defaultReasonPath].text;
+ return this.notStoredExternally
+ ? defaultReason
+ : defaultReason[this.blob.externalStorage || 'default'];
+ },
+ renderErrorOptions() {
+ const load = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.LOAD,
+ condition: this.shouldShowLoadBtn,
+ };
+ const showSource = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.SHOW_SOURCE,
+ condition: this.shouldShowSourceBtn,
+ };
+ const download = {
+ ...BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD,
+ href: this.blob.rawPath,
+ };
+ return [load, showSource, download];
+ },
+ shouldShowLoadBtn() {
+ return this.viewerError === BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
+ },
+ shouldShowSourceBtn() {
+ return this.blob.richViewer && this.blob.renderedAsText && this.notStoredExternally;
+ },
},
+ errorMessage: __(
+ 'This content could not be displayed because %{reason}. You can %{options} instead.',
+ ),
};
</script>
<template>
<div class="file-content code">
- <div class="text-center py-4" v-html="viewerError"></div>
+ <div class="text-center py-4">
+ <gl-sprintf :message="$options.errorMessage">
+ <template #reason>{{ renderErrorReason }}</template>
+ <template #options>
+ <template v-for="option in renderErrorOptions">
+ <span v-if="option.condition" :key="option.text">
+ <gl-link
+ :href="option.href"
+ :target="option.target"
+ :data-test-id="`option-${option.id}`"
+ @click="option.event && $emit(option.event)"
+ >{{ option.text }}</gl-link
+ >
+ {{ option.conjunction }}
+ </span>
+ </template>
+ </template>
+ </gl-sprintf>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index e9b5ceda479..e1e1d76f721 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -5,6 +5,7 @@ export default {
components: {
GlFormInput,
},
+ inheritAttrs: false,
props: {
value: {
type: String,
@@ -27,8 +28,9 @@ export default {
s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby')
"
name="snippet_file_name"
- class="form-control js-snippet-file-name qa-snippet-file-name"
+ class="form-control js-snippet-file-name"
type="text"
+ v-bind="$attrs"
@change="$emit('input', name)"
/>
</div>
diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue
index b7d9600ec40..e5e01caa9a5 100644
--- a/app/assets/javascripts/blob/components/blob_header.vue
+++ b/app/assets/javascripts/blob/components/blob_header.vue
@@ -66,7 +66,7 @@ export default {
</template>
</blob-filepath>
- <div class="file-actions d-none d-sm-block">
+ <div class="file-actions d-none d-sm-flex">
<viewer-switcher v-if="showViewerSwitcher" v-model="viewer" />
<slot name="actions"></slot>
diff --git a/app/assets/javascripts/blob/components/blob_header_filepath.vue b/app/assets/javascripts/blob/components/blob_header_filepath.vue
index 6c6a22e2b36..e9be7fbcf9b 100644
--- a/app/assets/javascripts/blob/components/blob_header_filepath.vue
+++ b/app/assets/javascripts/blob/components/blob_header_filepath.vue
@@ -28,12 +28,14 @@ export default {
<div class="file-header-content d-flex align-items-center lh-100">
<slot name="filepathPrepend"></slot>
- <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
- <strong
- v-if="blob.name"
- class="file-title-name qa-file-title-name mr-1 js-blob-header-filepath"
- >{{ blob.name }}</strong
- >
+ <template v-if="blob.path">
+ <file-icon :file-name="blob.path" :size="18" aria-hidden="true" css-classes="mr-2" />
+ <strong
+ class="file-title-name mr-1 js-blob-header-filepath"
+ data-qa-selector="file_title_name"
+ >{{ blob.path }}</strong
+ >
+ </template>
<small class="mr-2">{{ blobSize }}</small>
diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
index 7155a1d35b1..ed03213d7cf 100644
--- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
+++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue
@@ -45,7 +45,7 @@ export default {
};
</script>
<template>
- <gl-button-group class="js-blob-viewer-switcher ml-2">
+ <gl-button-group class="js-blob-viewer-switcher mx-2">
<gl-deprecated-button
v-gl-tooltip.hover
:aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE"
diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js
index d3fed9e51e9..93dceacabdd 100644
--- a/app/assets/javascripts/blob/components/constants.js
+++ b/app/assets/javascripts/blob/components/constants.js
@@ -1,4 +1,5 @@
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents');
export const BTN_RAW_TITLE = __('Open raw');
@@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source');
export const RICH_BLOB_VIEWER = 'rich';
export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file');
+
+export const BLOB_RENDER_EVENT_LOAD = 'force-content-fetch';
+export const BLOB_RENDER_EVENT_SHOW_SOURCE = 'force-switch-viewer';
+
+export const BLOB_RENDER_ERRORS = {
+ REASONS: {
+ COLLAPSED: {
+ id: 'collapsed',
+ text: sprintf(__('it is larger than %{limit}'), {
+ limit: numberToHumanSize(1048576), // 1MB in bytes
+ }),
+ },
+ TOO_LARGE: {
+ id: 'too_large',
+ text: sprintf(__('it is larger than %{limit}'), {
+ limit: numberToHumanSize(104857600), // 100MB in bytes
+ }),
+ },
+ EXTERNAL: {
+ id: 'server_side_but_stored_externally',
+ text: {
+ lfs: __('it is stored in LFS'),
+ build_artifact: __('it is stored as a job artifact'),
+ default: __('it is stored externally'),
+ },
+ },
+ },
+ OPTIONS: {
+ LOAD: {
+ id: 'load',
+ text: __('load it anyway'),
+ conjunction: __('or'),
+ href: '#',
+ target: '',
+ event: BLOB_RENDER_EVENT_LOAD,
+ },
+ SHOW_SOURCE: {
+ id: 'show_source',
+ text: __('view the source'),
+ conjunction: __('or'),
+ href: '#',
+ target: '',
+ event: BLOB_RENDER_EVENT_SHOW_SOURCE,
+ },
+ DOWNLOAD: {
+ id: 'download',
+ text: __('download it'),
+ conjunction: '',
+ target: '_blank',
+ condition: true,
+ },
+ },
+};
diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
index 5023496e2c3..1e9e36feecc 100644
--- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
+++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue
@@ -72,9 +72,6 @@ export default {
dismissCookieName() {
return `${this.trackLabel}_${this.dismissKey}`;
},
- commitCookieName() {
- return `suggest_gitlab_ci_yml_commit_${this.dismissKey}`;
- },
},
mounted() {
if (
diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js
index dc2ec642e59..840a3dbe450 100644
--- a/app/assets/javascripts/blob/utils.js
+++ b/app/assets/javascripts/blob/utils.js
@@ -1,22 +1,15 @@
-/* global ace */
import Editor from '~/editor/editor_lite';
export function initEditorLite({ el, blobPath, blobContent }) {
if (!el) {
throw new Error(`"el" parameter is required to initialize Editor`);
}
- let editor;
-
- if (window?.gon?.features?.monacoSnippets) {
- editor = new Editor();
- editor.createInstance({
- el,
- blobPath,
- blobContent,
- });
- } else {
- editor = ace.edit(el);
- }
+ const editor = new Editor();
+ editor.createInstance({
+ el,
+ blobPath,
+ blobContent,
+ });
return editor;
}
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index ac02c229a07..517a13ceb27 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -113,9 +113,6 @@ export default Vue.extend({
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
filter: {
diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue
index 10c855675db..fb854616a04 100644
--- a/app/assets/javascripts/boards/components/board_column.vue
+++ b/app/assets/javascripts/boards/components/board_column.vue
@@ -113,9 +113,6 @@ export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
filter: {
@@ -286,7 +283,6 @@ export default {
:background-color="list.label.color"
:description="list.label.description"
:scoped="showScopedLabels(list.label)"
- :scoped-labels-documentation-link="helpLink"
:size="!list.isExpanded ? 'sm' : ''"
:title="list.label.title"
tooltip-placement="bottom"
@@ -353,7 +349,7 @@ export default {
v-if="isSettingsShown"
ref="settingsBtn"
:aria-label="__(`List settings`)"
- class="no-drag rounded-right"
+ class="no-drag rounded-right js-board-settings-button"
title="List settings"
type="button"
@click="openSidebarSettings"
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index c0df8b72095..fbe221041c1 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -58,11 +58,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
data() {
return {
@@ -182,7 +177,7 @@ export default {
@cancel="cancel"
@submit="submit"
>
- <template slot="body">
+ <template #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">
@@ -208,7 +203,6 @@ export default {
: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"
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 66a5e134205..c8953158811 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -51,7 +51,7 @@ export default Vue.extend({
return Object.keys(this.issue).length;
},
milestoneTitle() {
- return this.issue.milestone ? this.issue.milestone.title : __('No Milestone');
+ return this.issue.milestone ? this.issue.milestone.title : __('No milestone');
},
canRemove() {
return !this.list.preset;
@@ -70,9 +70,6 @@ export default Vue.extend({
selectedLabels() {
return this.hasLabels ? this.issue.labels.map(l => l.title).join(',') : '';
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
watch: {
detail: {
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index f2c976be7ae..80db9930259 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -86,11 +86,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
data() {
return {
@@ -348,7 +343,6 @@ export default {
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights"
:enable-scoped-labels="enabledScopedLabels"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
</span>
</div>
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index daaa12c096b..a589fb325b2 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -102,9 +102,6 @@ export default {
orderedLabels() {
return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title');
},
- helpLink() {
- return boardsStore.scopedLabels.helpLink;
- },
},
methods: {
isIndexLessThanlimit(index) {
@@ -181,7 +178,6 @@ export default {
:description="label.description"
size="sm"
:scoped="showScopedLabel(label)"
- :scoped-labels-documentation-link="helpLink"
@click="filterByLabel(label)"
/>
</template>
diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js
index dcecfe5e1bb..f577a168e75 100644
--- a/app/assets/javascripts/boards/constants.js
+++ b/app/assets/javascripts/boards/constants.js
@@ -1,3 +1,8 @@
+export const BoardType = {
+ project: 'project',
+ group: 'group',
+};
+
export const ListType = {
assignee: 'assignee',
milestone: 'milestone',
@@ -8,6 +13,9 @@ export const ListType = {
blank: 'blank',
};
+export const inactiveListId = 0;
+
export default {
+ BoardType,
ListType,
};
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index b1b4b1c5508..ca85e54eb89 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,6 +1,6 @@
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container';
-import FilteredSearchManager from '../filtered_search/filtered_search_manager';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store';
export default class FilteredSearchBoards extends FilteredSearchManager {
diff --git a/app/assets/javascripts/boards/icons/fullscreen_collapse.svg b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
new file mode 100644
index 00000000000..6bd773dc4c5
--- /dev/null
+++ b/app/assets/javascripts/boards/icons/fullscreen_collapse.svg
@@ -0,0 +1 @@
+<svg width="17" height="17" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="M.147 15.496l2.146-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.146-1.428-1.428zM14.996.646l1.428 1.43-2.146 2.145 1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125l1.286 1.286L14.996.647zm-13.42 0L3.72 2.794l1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286L.147 2.075 1.575.647zm14.848 14.85l-1.428 1.428-2.146-2.146-1.286 1.286c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616l-1.286 1.286 2.146 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/icons/fullscreen_expand.svg b/app/assets/javascripts/boards/icons/fullscreen_expand.svg
new file mode 100644
index 00000000000..306073b8af2
--- /dev/null
+++ b/app/assets/javascripts/boards/icons/fullscreen_expand.svg
@@ -0,0 +1 @@
+<svg width="15" height="15" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg"><path d="M8.591 5.056l2.147-2.146-1.286-1.286a.55.55 0 0 1-.125-.616c.101-.238.277-.357.527-.357h4a.55.55 0 0 1 .402.17.55.55 0 0 1 .17.401v4c0 .25-.12.426-.358.527-.232.101-.437.06-.616-.125l-1.286-1.286-2.146 2.147-1.429-1.43zM5.018 8.553l1.429 1.43L4.3 12.127l1.286 1.286c.185.179.226.384.125.616-.101.238-.277.357-.527.357h-4a.55.55 0 0 1-.402-.17.55.55 0 0 1-.17-.401v-4c0-.25.12-.426.358-.527a.553.553 0 0 1 .616.125L2.872 10.7l2.146-2.147zm4.964 0l2.146 2.147 1.286-1.286a.55.55 0 0 1 .616-.125c.238.101.357.277.357.527v4a.55.55 0 0 1-.17.402.55.55 0 0 1-.401.17h-4c-.25 0-.426-.12-.527-.358-.101-.232-.06-.437.125-.616l1.286-1.286-2.147-2.146 1.43-1.429zM6.447 5.018l-1.43 1.429L2.873 4.3 1.586 5.586c-.179.185-.384.226-.616.125-.238-.101-.357-.277-.357-.527v-4a.55.55 0 0 1 .17-.402.55.55 0 0 1 .401-.17h4c.25 0 .426.12.527.358a.553.553 0 0 1-.125.616L4.3 2.872l2.147 2.146z" fill-rule="evenodd"/></svg>
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index a12db7a5f1a..9ff7575ae09 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -6,7 +6,6 @@ import 'ee_else_ce/boards/models/list';
import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar';
import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown';
import boardConfigToggle from 'ee_else_ce/boards/config_toggle';
-import toggleFocusMode from 'ee_else_ce/boards/toggle_focus';
import toggleLabels from 'ee_else_ce/boards/toggle_labels';
import {
setPromotionState,
@@ -16,11 +15,15 @@ import {
getBoardsModalData,
} from 'ee_else_ce/boards/ee_functions';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import Flash from '~/flash';
import { __ } from '~/locale';
import './models/label';
import './models/assignee';
+import { BoardType } from './constants';
+import toggleFocusMode from '~/boards/toggle_focus';
import FilteredSearchBoards from '~/boards/filtered_search_boards';
import eventHub from '~/boards/eventhub';
import sidebarEventHub from '~/sidebar/event_hub';
@@ -37,7 +40,16 @@ import {
convertObjectPropsToCamelCase,
parseBoolean,
} from '~/lib/utils/common_utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher';
+import projectBoardQuery from './queries/project_board.query.graphql';
+import groupQuery from './queries/group_board.query.graphql';
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
let issueBoardsApp;
@@ -79,18 +91,22 @@ export default () => {
import('ee_component/boards/components/board_settings_sidebar.vue'),
},
store,
- data: {
- state: boardsStore.state,
- loading: true,
- boardsEndpoint: $boardApp.dataset.boardsEndpoint,
- recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
- listsEndpoint: $boardApp.dataset.listsEndpoint,
- boardId: $boardApp.dataset.boardId,
- disabled: parseBoolean($boardApp.dataset.disabled),
- issueLinkBase: $boardApp.dataset.issueLinkBase,
- rootPath: $boardApp.dataset.rootPath,
- bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
- detailIssue: boardsStore.detail,
+ apolloProvider,
+ data() {
+ return {
+ state: boardsStore.state,
+ loading: 0,
+ boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
+ listsEndpoint: $boardApp.dataset.listsEndpoint,
+ boardId: $boardApp.dataset.boardId,
+ disabled: parseBoolean($boardApp.dataset.disabled),
+ issueLinkBase: $boardApp.dataset.issueLinkBase,
+ rootPath: $boardApp.dataset.rootPath,
+ bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
+ detailIssue: boardsStore.detail,
+ parent: $boardApp.dataset.parent,
+ };
},
computed: {
detailIssueVisible() {
@@ -124,31 +140,56 @@ export default () => {
this.filterManager.setup();
boardsStore.disabled = this.disabled;
- boardsStore
- .all()
- .then(res => res.data)
- .then(lists => {
- lists.forEach(listObj => {
- let { position } = listObj;
- if (listObj.list_type === 'closed') {
- position = Infinity;
- } else if (listObj.list_type === 'backlog') {
- position = -1;
+
+ if (gon.features.graphqlBoardLists) {
+ this.$apollo.addSmartQuery('lists', {
+ query() {
+ return this.parent === BoardType.group ? groupQuery : projectBoardQuery;
+ },
+ variables() {
+ return {
+ fullPath: this.state.endpoints.fullPath,
+ boardId: `gid://gitlab/Board/${this.boardId}`,
+ };
+ },
+ update(data) {
+ return this.getNodes(data);
+ },
+ result({ data, error }) {
+ if (error) {
+ throw error;
}
- boardsStore.addList({
- ...listObj,
- position,
- });
- });
+ const lists = this.getNodes(data);
+
+ lists.forEach(list =>
+ boardsStore.addList({
+ ...list,
+ id: getIdFromGraphQLId(list.id),
+ }),
+ );
- boardsStore.addBlankState();
- setPromotionState(boardsStore);
- this.loading = false;
- })
- .catch(() => {
- Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ boardsStore.addBlankState();
+ setPromotionState(boardsStore);
+ },
+ error() {
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ },
});
+ } else {
+ boardsStore
+ .all()
+ .then(res => res.data)
+ .then(lists => {
+ lists.forEach(list => boardsStore.addList(list));
+ boardsStore.addBlankState();
+ setPromotionState(boardsStore);
+ this.loading = false;
+ })
+ .catch(() => {
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
+ });
+ }
},
methods: {
updateTokens() {
@@ -233,6 +274,9 @@ export default () => {
});
}
},
+ getNodes(data) {
+ return data[this.parent]?.board?.lists.nodes;
+ },
},
});
@@ -261,7 +305,7 @@ export default () => {
return {
modal: ModalStore.store,
store: boardsStore.state,
- ...getBoardsModalData($boardApp),
+ ...getBoardsModalData(),
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
@@ -325,7 +369,7 @@ export default () => {
});
}
- toggleFocusMode(ModalStore, boardsStore, $boardApp);
+ toggleFocusMode(ModalStore, boardsStore);
toggleLabels();
mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 68ea28e68d9..fceb8c9d48e 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -19,14 +19,15 @@ export function getBoardSortableDefaultOptions(obj) {
const touchEnabled =
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
- const defaultSortOptions = Object.assign({}, sortableConfig, {
+ const defaultSortOptions = {
+ ...sortableConfig,
filter: '.no-drag',
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: sortableStart,
onEnd: sortableEnd,
- });
+ };
Object.keys(obj).forEach(key => {
defaultSortOptions[key] = obj[key];
diff --git a/app/assets/javascripts/boards/models/assignee.js b/app/assets/javascripts/boards/models/assignee.js
index 5f5758583bb..1e822d06bfd 100644
--- a/app/assets/javascripts/boards/models/assignee.js
+++ b/app/assets/javascripts/boards/models/assignee.js
@@ -3,7 +3,7 @@ export default class ListAssignee {
this.id = obj.id;
this.name = obj.name;
this.username = obj.username;
- this.avatar = obj.avatar_url || obj.avatar || gon.default_avatar_url;
+ this.avatar = obj.avatarUrl || obj.avatar_url || obj.avatar || gon.default_avatar_url;
this.path = obj.path;
this.state = obj.state;
this.webUrl = obj.web_url || obj.webUrl;
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index d099c4b930c..878f49cc6be 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -15,7 +15,7 @@ class ListIssue {
this.labels = [];
this.assignees = [];
this.selected = false;
- this.position = obj.relative_position || Infinity;
+ this.position = obj.position || obj.relative_position || Infinity;
this.isFetching = {
subscriptions: true,
};
@@ -99,31 +99,7 @@ class ListIssue {
}
update() {
- const data = {
- issue: {
- milestone_id: this.milestone ? this.milestone.id : null,
- due_date: this.dueDate,
- assignee_ids: this.assignees.length > 0 ? this.assignees.map(u => u.id) : [0],
- label_ids: this.labels.map(label => label.id),
- },
- };
-
- if (!data.issue.label_ids.length) {
- data.issue.label_ids = [''];
- }
-
- const projectPath = this.project ? this.project.path : '';
- return axios.patch(`${this.path}.json`, data).then(({ data: body = {} } = {}) => {
- /**
- * Since post implementation of Scoped labels, server can reject
- * same key-ed labels. To keep the UI and server Model consistent,
- * we're just assigning labels that server echo's back to us when we
- * PATCH the said object.
- */
- if (body) {
- this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
- }
- });
+ return boardsStore.updateIssue(this);
}
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 990b648190a..31c372b7a75 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -1,10 +1,9 @@
-/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow */
+/* eslint-disable no-underscore-dangle, class-methods-use-this, consistent-return */
import ListIssue from 'ee_else_ce/boards/models/issue';
import { __ } from '~/locale';
import ListLabel from './label';
import ListAssignee from './assignee';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
import flash from '~/flash';
import boardsStore from '../stores/boards_store';
import ListMilestone from './milestone';
@@ -40,8 +39,8 @@ class List {
this.id = obj.id;
this._uid = this.guid();
this.position = obj.position;
- this.title = obj.list_type === 'backlog' ? __('Open') : obj.title;
- this.type = obj.list_type;
+ this.title = (obj.list_type || obj.listType) === 'backlog' ? __('Open') : obj.title;
+ this.type = obj.list_type || obj.listType;
const typeInfo = this.getTypeInfo(this.type);
this.preset = Boolean(typeInfo.isPreset);
@@ -52,14 +51,12 @@ class List {
this.loadingMore = false;
this.issues = obj.issues || [];
this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
- this.maxIssueCount = Object.hasOwnProperty.call(obj, 'max_issue_count')
- ? obj.max_issue_count
- : 0;
+ this.maxIssueCount = obj.maxIssueCount || obj.max_issue_count || 0;
if (obj.label) {
this.label = new ListLabel(obj.label);
- } else if (obj.user) {
- this.assignee = new ListAssignee(obj.user);
+ } else if (obj.user || obj.assignee) {
+ this.assignee = new ListAssignee(obj.user || obj.assignee);
this.title = this.assignee.name;
} else if (IS_EE && obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
@@ -113,34 +110,7 @@ class List {
}
getIssues(emptyIssues = true) {
- const data = {
- ...urlParamsToObject(boardsStore.filter.path),
- page: this.page,
- };
-
- if (this.label && data.label_name) {
- data.label_name = data.label_name.filter(label => label !== this.label.title);
- }
-
- if (emptyIssues) {
- this.loading = true;
- }
-
- return boardsStore
- .getIssuesForList(this.id, data)
- .then(res => res.data)
- .then(data => {
- this.loading = false;
- this.issuesSize = data.size;
-
- if (emptyIssues) {
- this.issues = [];
- }
-
- this.createIssues(data.issues);
-
- return data;
- });
+ return boardsStore.getListIssues(this, emptyIssues);
}
newIssue(issue) {
@@ -164,48 +134,7 @@ class List {
}
addIssue(issue, listFrom, newIndex) {
- let moveBeforeId = null;
- let moveAfterId = null;
-
- if (!this.findIssue(issue.id)) {
- if (newIndex !== undefined) {
- this.issues.splice(newIndex, 0, issue);
-
- if (this.issues[newIndex - 1]) {
- moveBeforeId = this.issues[newIndex - 1].id;
- }
-
- if (this.issues[newIndex + 1]) {
- moveAfterId = this.issues[newIndex + 1].id;
- }
- } else {
- this.issues.push(issue);
- }
-
- if (this.label) {
- issue.addLabel(this.label);
- }
-
- if (this.assignee) {
- if (listFrom && listFrom.type === 'assignee') {
- issue.removeAssignee(listFrom.assignee);
- }
- issue.addAssignee(this.assignee);
- }
-
- if (IS_EE && this.milestone) {
- if (listFrom && listFrom.type === 'milestone') {
- issue.removeMilestone(listFrom.milestone);
- }
- issue.addMilestone(this.milestone);
- }
-
- if (listFrom) {
- this.issuesSize += 1;
-
- this.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
- }
- }
+ boardsStore.addListIssue(this, issue, listFrom, newIndex);
}
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
diff --git a/app/assets/javascripts/boards/queries/board_list.fragment.graphql b/app/assets/javascripts/boards/queries/board_list.fragment.graphql
new file mode 100644
index 00000000000..bbf3314377e
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list.fragment.graphql
@@ -0,0 +1,5 @@
+#import "./board_list_shared.fragment.graphql"
+
+fragment BoardListFragment on BoardList {
+ ...BoardListShared
+}
diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
new file mode 100644
index 00000000000..6ba6c05d6d9
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql
@@ -0,0 +1,15 @@
+fragment BoardListShared on BoardList {
+ id,
+ title,
+ position,
+ listType,
+ collapsed,
+ label {
+ id,
+ title,
+ color,
+ textColor,
+ description,
+ descriptionHtml
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/group_board.query.graphql b/app/assets/javascripts/boards/queries/group_board.query.graphql
new file mode 100644
index 00000000000..cb42cb3f73d
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/group_board.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+
+query GroupBoard($fullPath: ID!, $boardId: ID!) {
+ group(fullPath: $fullPath) {
+ board(id: $boardId) {
+ lists {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/queries/project_board.query.graphql b/app/assets/javascripts/boards/queries/project_board.query.graphql
new file mode 100644
index 00000000000..4620a7e0fd5
--- /dev/null
+++ b/app/assets/javascripts/boards/queries/project_board.query.graphql
@@ -0,0 +1,13 @@
+#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
+
+query ProjectBoard($fullPath: ID!, $boardId: ID!) {
+ project(fullPath: $fullPath) {
+ board(id: $boardId) {
+ lists {
+ nodes {
+ ...BoardListFragment
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index e5447080e37..fdbd7e89bfb 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -6,7 +6,12 @@ import { sortBy } from 'lodash';
import Vue from 'vue';
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 {
+ urlParamsToObject,
+ getUrlParamsArray,
+ parseBoolean,
+ convertObjectPropsToCamelCase,
+} from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
@@ -23,7 +28,6 @@ const boardsStore = {
limitToHours: false,
},
scopedLabels: {
- helpLink: '',
enabled: false,
},
filter: {
@@ -75,7 +79,15 @@ const boardsStore = {
this.state.currentPage = page;
},
addList(listObj) {
- const list = new List(listObj);
+ const listType = listObj.listType || listObj.list_type;
+ let { position } = listObj;
+ if (listType === ListType.closed) {
+ position = Infinity;
+ } else if (listType === ListType.backlog) {
+ position = -1;
+ }
+
+ const list = new List({ ...listObj, position });
this.state.lists = sortBy([...this.state.lists, list], 'position');
return list;
},
@@ -121,6 +133,50 @@ const boardsStore = {
path: '',
});
},
+ addListIssue(list, issue, listFrom, newIndex) {
+ let moveBeforeId = null;
+ let moveAfterId = null;
+
+ if (!list.findIssue(issue.id)) {
+ if (newIndex !== undefined) {
+ list.issues.splice(newIndex, 0, issue);
+
+ if (list.issues[newIndex - 1]) {
+ moveBeforeId = list.issues[newIndex - 1].id;
+ }
+
+ if (list.issues[newIndex + 1]) {
+ moveAfterId = list.issues[newIndex + 1].id;
+ }
+ } else {
+ list.issues.push(issue);
+ }
+
+ if (list.label) {
+ issue.addLabel(list.label);
+ }
+
+ if (list.assignee) {
+ if (listFrom && listFrom.type === 'assignee') {
+ issue.removeAssignee(listFrom.assignee);
+ }
+ issue.addAssignee(list.assignee);
+ }
+
+ if (IS_EE && list.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issue.removeMilestone(listFrom.milestone);
+ }
+ issue.addMilestone(list.milestone);
+ }
+
+ if (listFrom) {
+ list.issuesSize += 1;
+
+ list.updateIssueLabel(issue, listFrom, moveBeforeId, moveAfterId);
+ }
+ }
+ },
welcomeIsHidden() {
return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
},
@@ -487,6 +543,36 @@ const boardsStore = {
});
},
+ getListIssues(list, emptyIssues = true) {
+ const data = {
+ ...urlParamsToObject(this.filter.path),
+ page: list.page,
+ };
+
+ if (list.label && data.label_name) {
+ data.label_name = data.label_name.filter(label => label !== list.label.title);
+ }
+
+ if (emptyIssues) {
+ list.loading = true;
+ }
+
+ return this.getIssuesForList(list.id, data)
+ .then(res => res.data)
+ .then(data => {
+ list.loading = false;
+ list.issuesSize = data.size;
+
+ if (emptyIssues) {
+ list.issues = [];
+ }
+
+ list.createIssues(data.issues);
+
+ return data;
+ });
+ },
+
getIssuesForList(id, filter = {}) {
const data = { id };
Object.keys(filter).forEach(key => {
@@ -632,6 +718,28 @@ const boardsStore = {
issue.assignees = obj.assignees.map(a => new ListAssignee(a));
}
},
+ updateIssue(issue) {
+ const data = {
+ issue: {
+ milestone_id: issue.milestone ? issue.milestone.id : null,
+ due_date: issue.dueDate,
+ assignee_ids: issue.assignees.length > 0 ? issue.assignees.map(({ id }) => id) : [0],
+ label_ids: issue.labels.length > 0 ? issue.labels.map(({ id }) => id) : [''],
+ },
+ };
+
+ return axios.patch(`${issue.path}.json`, data).then(({ data: body = {} } = {}) => {
+ /**
+ * Since post implementation of Scoped labels, server can reject
+ * same key-ed labels. To keep the UI and server Model consistent,
+ * we're just assigning labels that server echo's back to us when we
+ * PATCH the said object.
+ */
+ if (body) {
+ issue.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
+ }
+ });
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index 731aea996fb..10aac2f649e 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,4 +1,6 @@
+import { inactiveListId } from '~/boards/constants';
+
export default () => ({
isShowingLabels: true,
- activeListId: 0,
+ activeListId: inactiveListId,
});
diff --git a/app/assets/javascripts/boards/toggle_focus.js b/app/assets/javascripts/boards/toggle_focus.js
index 2d1ec238274..a437a34c948 100644
--- a/app/assets/javascripts/boards/toggle_focus.js
+++ b/app/assets/javascripts/boards/toggle_focus.js
@@ -1 +1,45 @@
-export default () => {};
+import $ from 'jquery';
+import Vue from 'vue';
+import collapseIcon from './icons/fullscreen_collapse.svg';
+import expandIcon from './icons/fullscreen_expand.svg';
+
+export default (ModalStore, boardsStore) => {
+ const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
+
+ return new Vue({
+ el: document.getElementById('js-toggle-focus-btn'),
+ data: {
+ modal: ModalStore.store,
+ store: boardsStore.state,
+ isFullscreen: false,
+ },
+ methods: {
+ toggleFocusMode() {
+ $(this.$refs.toggleFocusModeButton).tooltip('hide');
+ issueBoardsContent.classList.toggle('is-focused');
+
+ this.isFullscreen = !this.isFullscreen;
+ },
+ },
+ template: `
+ <div class="board-extra-actions">
+ <a
+ href="#"
+ class="btn btn-default has-tooltip prepend-left-10 js-focus-mode-btn"
+ data-qa-selector="focus_mode_button"
+ role="button"
+ aria-label="Toggle focus mode"
+ title="Toggle focus mode"
+ ref="toggleFocusModeButton"
+ @click="toggleFocusMode">
+ <span v-show="isFullscreen">
+ ${collapseIcon}
+ </span>
+ <span v-show="!isFullscreen">
+ ${expandIcon}
+ </span>
+ </a>
+ </div>
+ `,
+ });
+};
diff --git a/app/assets/javascripts/broadcast_notification.js b/app/assets/javascripts/broadcast_notification.js
index dc5401199dc..97da6fa34da 100644
--- a/app/assets/javascripts/broadcast_notification.js
+++ b/app/assets/javascripts/broadcast_notification.js
@@ -3,10 +3,10 @@ import Cookies from 'js-cookie';
const handleOnDismiss = ({ currentTarget }) => {
currentTarget.removeEventListener('click', handleOnDismiss);
const {
- dataset: { id },
+ dataset: { id, expireDate },
} = currentTarget;
- Cookies.set(`hide_broadcast_message_${id}`, true);
+ Cookies.set(`hide_broadcast_message_${id}`, true, { expires: new Date(expireDate) });
const notification = document.querySelector(`.js-broadcast-notification-${id}`);
notification.parentNode.removeChild(notification);
diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
index da33e092086..470649e63fb 100644
--- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '../lib/utils/axios_utils';
import { s__ } from '../locale';
import Flash from '../flash';
@@ -10,7 +10,7 @@ function generateErrorBoxContent(errors) {
const errorList = [].concat(errors).map(
errorString => `
<li>
- ${esc(errorString)}
+ ${escape(errorString)}
</li>
`,
);
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
index 8f5acd4a0a0..f6ade0867cd 100644
--- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
+++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue
@@ -46,6 +46,7 @@ export default {
'isGroup',
'maskableRegex',
'selectedEnvironment',
+ 'isProtectedByDefault',
]),
canSubmit() {
return (
@@ -123,6 +124,7 @@ export default {
'addWildCardScope',
'resetSelectedEnvironment',
'setSelectedEnvironment',
+ 'setVariableProtected',
]),
deleteVarAndClose() {
this.deleteVariable(this.variableBeingEdited);
@@ -147,6 +149,11 @@ export default {
}
this.hideModal();
},
+ setVariableProtectedByDefault() {
+ if (this.isProtectedByDefault && !this.variableBeingEdited) {
+ this.setVariableProtected();
+ }
+ },
},
};
</script>
@@ -159,6 +166,7 @@ export default {
static
lazy
@hidden="resetModalHandler"
+ @shown="setVariableProtectedByDefault"
>
<form>
<ci-key-field
diff --git a/app/assets/javascripts/ci_variable_list/constants.js b/app/assets/javascripts/ci_variable_list/constants.js
index 5fe1e32e37e..a4db6481720 100644
--- a/app/assets/javascripts/ci_variable_list/constants.js
+++ b/app/assets/javascripts/ci_variable_list/constants.js
@@ -4,7 +4,7 @@ import { __ } from '~/locale';
export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable';
export const displayText = {
- variableText: __('Var'),
+ variableText: __('Variable'),
fileText: __('File'),
allEnvironmentsText: __('All (default)'),
};
diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js
index 58501b216c1..2b4a56a4e6d 100644
--- a/app/assets/javascripts/ci_variable_list/index.js
+++ b/app/assets/javascripts/ci_variable_list/index.js
@@ -5,14 +5,16 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default () => {
const el = document.getElementById('js-ci-project-variables');
- const { endpoint, projectId, group, maskableRegex } = el.dataset;
+ const { endpoint, projectId, group, maskableRegex, protectedByDefault } = el.dataset;
const isGroup = parseBoolean(group);
+ const isProtectedByDefault = parseBoolean(protectedByDefault);
const store = createStore({
endpoint,
projectId,
isGroup,
maskableRegex,
+ isProtectedByDefault,
});
return new Vue({
diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js
index a22fa03e16d..d9129c919f8 100644
--- a/app/assets/javascripts/ci_variable_list/store/actions.js
+++ b/app/assets/javascripts/ci_variable_list/store/actions.js
@@ -20,6 +20,10 @@ export const resetEditing = ({ commit, dispatch }) => {
commit(types.RESET_EDITING);
};
+export const setVariableProtected = ({ commit }) => {
+ commit(types.SET_VARIABLE_PROTECTED);
+};
+
export const requestAddVariable = ({ commit }) => {
commit(types.REQUEST_ADD_VARIABLE);
};
diff --git a/app/assets/javascripts/ci_variable_list/store/mutation_types.js b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
index 0b41c20bce7..ccf8fbd3cb5 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutation_types.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutation_types.js
@@ -2,6 +2,7 @@ export const TOGGLE_VALUES = 'TOGGLE_VALUES';
export const VARIABLE_BEING_EDITED = 'VARIABLE_BEING_EDITED';
export const RESET_EDITING = 'RESET_EDITING';
export const CLEAR_MODAL = 'CLEAR_MODAL';
+export const SET_VARIABLE_PROTECTED = 'SET_VARIABLE_PROTECTED';
export const REQUEST_VARIABLES = 'REQUEST_VARIABLES';
export const RECEIVE_VARIABLES_SUCCESS = 'RECEIVE_VARIABLES_SUCCESS';
diff --git a/app/assets/javascripts/ci_variable_list/store/mutations.js b/app/assets/javascripts/ci_variable_list/store/mutations.js
index 7ee7d7bdc26..7d9cd0dd727 100644
--- a/app/assets/javascripts/ci_variable_list/store/mutations.js
+++ b/app/assets/javascripts/ci_variable_list/store/mutations.js
@@ -104,4 +104,8 @@ export default {
[types.SET_SELECTED_ENVIRONMENT](state, environment) {
state.selectedEnvironment = environment;
},
+
+ [types.SET_VARIABLE_PROTECTED](state) {
+ state.variable.protected = true;
+ },
};
diff --git a/app/assets/javascripts/ci_variable_list/store/state.js b/app/assets/javascripts/ci_variable_list/store/state.js
index 8c0b9c6966f..2fffd115589 100644
--- a/app/assets/javascripts/ci_variable_list/store/state.js
+++ b/app/assets/javascripts/ci_variable_list/store/state.js
@@ -5,6 +5,7 @@ export default () => ({
projectId: null,
isGroup: null,
maskableRegex: null,
+ isProtectedByDefault: null,
isLoading: false,
isDeleting: false,
variable: {
diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js
index 882d20671cc..bcddce6e727 100644
--- a/app/assets/javascripts/close_reopen_report_toggle.js
+++ b/app/assets/javascripts/close_reopen_report_toggle.js
@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
class CloseReopenReportToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 1b11ec355bb..3699a3b8b2b 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -14,6 +14,7 @@ import {
INGRESS_DOMAIN_SUFFIX,
CROSSPLANE,
KNATIVE,
+ FLUENTD,
} from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
@@ -49,6 +50,7 @@ export default class Clusters {
installElasticStackPath,
installCrossplanePath,
installPrometheusPath,
+ installFluentdPath,
managePrometheusPath,
clusterEnvironmentsPath,
hasRbac,
@@ -102,6 +104,7 @@ export default class Clusters {
updateKnativeEndpoint: updateKnativePath,
installElasticStackEndpoint: installElasticStackPath,
clusterEnvironmentsEndpoint: clusterEnvironmentsPath,
+ installFluentdEndpoint: installFluentdPath,
});
this.installApplication = this.installApplication.bind(this);
@@ -265,6 +268,7 @@ export default class Clusters {
eventHub.$on('setIngressModSecurityEnabled', data => this.setIngressModSecurityEnabled(data));
eventHub.$on('setIngressModSecurityMode', data => this.setIngressModSecurityMode(data));
eventHub.$on('resetIngressModSecurityChanges', id => this.resetIngressModSecurityChanges(id));
+ eventHub.$on('setFluentdSettings', data => this.setFluentdSettings(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
@@ -281,6 +285,7 @@ export default class Clusters {
eventHub.$off('setIngressModSecurityEnabled');
eventHub.$off('setIngressModSecurityMode');
eventHub.$off('resetIngressModSecurityChanges');
+ eventHub.$off('setFluentdSettings');
}
initPolling(method, successCallback, errorCallback) {
@@ -320,7 +325,7 @@ export default class Clusters {
handleClusterStatusSuccess(data) {
const prevStatus = this.store.state.status;
- const prevApplicationMap = Object.assign({}, this.store.state.applications);
+ const prevApplicationMap = { ...this.store.state.applications };
this.store.updateStateFromServer(data.data);
@@ -506,6 +511,12 @@ export default class Clusters {
});
}
+ setFluentdSettings(settings = {}) {
+ Object.entries(settings).forEach(([key, value]) => {
+ this.store.updateAppProperty(FLUENTD, key, value);
+ });
+ }
+
toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
if (externalIp !== newExternalIp) {
this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 723030c5b8b..f11502a7dde 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg';
import { GlLoadingIcon } from '@gitlab/ui';
import elasticsearchLogo from 'images/cluster_app_logos/elasticsearch.png';
@@ -14,6 +14,7 @@ import knativeLogo from 'images/cluster_app_logos/knative.png';
import meltanoLogo from 'images/cluster_app_logos/meltano.png';
import prometheusLogo from 'images/cluster_app_logos/prometheus.png';
import elasticStackLogo from 'images/cluster_app_logos/elastic_stack.png';
+import fluentdLogo from 'images/cluster_app_logos/fluentd.png';
import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
@@ -22,6 +23,7 @@ import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../con
import eventHub from '~/clusters/event_hub';
import CrossplaneProviderStack from './crossplane_provider_stack.vue';
import IngressModsecuritySettings from './ingress_modsecurity_settings.vue';
+import FluentdOutputSettings from './fluentd_output_settings.vue';
export default {
components: {
@@ -31,6 +33,7 @@ export default {
KnativeDomainEditor,
CrossplaneProviderStack,
IngressModsecuritySettings,
+ FluentdOutputSettings,
},
props: {
type: {
@@ -102,6 +105,7 @@ export default {
meltanoLogo,
prometheusLogo,
elasticStackLogo,
+ fluentdLogo,
}),
computed: {
isProjectCluster() {
@@ -134,7 +138,7 @@ export default {
},
ingressDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`,
),
@@ -142,14 +146,14 @@ export default {
{
pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|pricing'))}</a>`,
+ ${escape(s__('ClusterIntegration|pricing'))}</a>`,
},
false,
);
},
certManagerDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Cert-Manager is a native Kubernetes certificate management controller that helps with issuing certificates.
Installing Cert-Manager on your cluster will issue a certificate by %{letsEncrypt} and ensure that certificates
@@ -159,14 +163,14 @@ export default {
{
letsEncrypt: `<a href="https://letsencrypt.org/"
target="_blank" rel="noopener noreferrer">
- ${esc(s__("ClusterIntegration|Let's Encrypt"))}</a>`,
+ ${escape(s__("ClusterIntegration|Let's Encrypt"))}</a>`,
},
false,
);
},
crossplaneDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}.
Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`,
@@ -175,7 +179,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
{
gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ee/user/clusters/applications.html#crossplane"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
+ ${escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
kubectl: `<code>kubectl</code>`,
},
false,
@@ -184,7 +188,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
prometheusDescription() {
return sprintf(
- esc(
+ escape(
s__(
`ClusterIntegration|Prometheus is an open-source monitoring system
with %{gitlabIntegrationLink} to monitor deployed applications.`,
@@ -193,7 +197,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
{
gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/prometheus.html"
target="_blank" rel="noopener noreferrer">
- ${esc(s__('ClusterIntegration|GitLab Integration'))}</a>`,
+ ${escape(s__('ClusterIntegration|GitLab Integration'))}</a>`,
},
false,
);
@@ -219,11 +223,11 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
installedVia() {
if (this.cloudRun) {
return sprintf(
- esc(s__(`ClusterIntegration|installed via %{installed_via}`)),
+ escape(s__(`ClusterIntegration|installed via %{installed_via}`)),
{
installed_via: `<a href="${
this.cloudRunHelpPath
- }" target="_blank" rel="noopener noreferrer">${esc(
+ }" target="_blank" rel="noopener noreferrer">${escape(
s__('ClusterIntegration|Cloud Run'),
)}</a>`,
},
@@ -658,7 +662,7 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:uninstall-successful="applications.elastic_stack.uninstallSuccessful"
:uninstall-failed="applications.elastic_stack.uninstallFailed"
:disabled="!helmInstalled"
- title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack"
+ title-link="https://gitlab.com/gitlab-org/charts/elastic-stack"
>
<div slot="description">
<p>
@@ -670,6 +674,51 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</p>
</div>
</application-row>
+
+ <application-row
+ id="fluentd"
+ :logo-url="fluentdLogo"
+ :title="applications.fluentd.title"
+ :status="applications.fluentd.status"
+ :status-reason="applications.fluentd.statusReason"
+ :request-status="applications.fluentd.requestStatus"
+ :request-reason="applications.fluentd.requestReason"
+ :installed="applications.fluentd.installed"
+ :install-failed="applications.fluentd.installFailed"
+ :install-application-request-params="{
+ host: applications.fluentd.host,
+ port: applications.fluentd.port,
+ protocol: applications.fluentd.protocol,
+ waf_log_enabled: applications.fluentd.wafLogEnabled,
+ cilium_log_enabled: applications.fluentd.ciliumLogEnabled,
+ }"
+ :uninstallable="applications.fluentd.uninstallable"
+ :uninstall-successful="applications.fluentd.uninstallSuccessful"
+ :uninstall-failed="applications.fluentd.uninstallFailed"
+ :disabled="!helmInstalled"
+ :updateable="false"
+ title-link="https://github.com/helm/charts/tree/master/stable/fluentd"
+ >
+ <div slot="description">
+ <p>
+ {{
+ s__(
+ `ClusterIntegration|Fluentd is an open source data collector, which lets you unify the data collection and consumption for a better use and understanding of data. It requires at least one of the following logs to be successfully installed.`,
+ )
+ }}
+ </p>
+
+ <fluentd-output-settings
+ :port="applications.fluentd.port"
+ :protocol="applications.fluentd.protocol"
+ :host="applications.fluentd.host"
+ :waf-log-enabled="applications.fluentd.wafLogEnabled"
+ :cilium-log-enabled="applications.fluentd.ciliumLogEnabled"
+ :status="applications.fluentd.status"
+ :update-failed="applications.fluentd.updateFailed"
+ />
+ </div>
+ </application-row>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
new file mode 100644
index 00000000000..1884b501a20
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue
@@ -0,0 +1,241 @@
+<script>
+import { __ } from '~/locale';
+import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants';
+import {
+ GlAlert,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+} from '@gitlab/ui';
+import eventHub from '~/clusters/event_hub';
+import { mapValues } from 'lodash';
+
+const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ GlAlert,
+ GlDeprecatedButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+ },
+ props: {
+ protocols: {
+ type: Array,
+ required: false,
+ default: () => ['TCP', 'UDP'],
+ },
+ status: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updateFailed: {
+ type: Boolean,
+ required: false,
+ },
+ protocol: {
+ type: String,
+ required: false,
+ default: () => __('Protocol'),
+ },
+ port: {
+ type: Number,
+ required: false,
+ default: 514,
+ },
+ host: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ wafLogEnabled: {
+ type: Boolean,
+ required: false,
+ },
+ ciliumLogEnabled: {
+ type: Boolean,
+ required: false,
+ },
+ },
+ data: () => ({
+ currentServerSideSettings: {
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ },
+ }),
+ computed: {
+ isSaving() {
+ return [UPDATING].includes(this.status);
+ },
+ saveButtonDisabled() {
+ return [UNINSTALLING, UPDATING, INSTALLING].includes(this.status);
+ },
+ saveButtonLabel() {
+ return this.isSaving ? __('Saving') : __('Save changes');
+ },
+ /**
+ * Returns true either when:
+ * - The application is getting updated.
+ * - The user has changed some of the settings for an application which is
+ * neither getting installed nor updated.
+ */
+ showButtons() {
+ return this.isSaving || (this.changedByUser && [INSTALLED, UPDATED].includes(this.status));
+ },
+ protocolName() {
+ if (this.protocol) {
+ return this.protocol.toUpperCase();
+ }
+ return __('Protocol');
+ },
+ changedByUser() {
+ return Object.entries(this.currentServerSideSettings).some(([key, value]) => {
+ return value !== null && value !== this[key];
+ });
+ },
+ },
+ watch: {
+ status() {
+ this.resetCurrentServerSideSettings();
+ },
+ },
+ methods: {
+ updateApplication() {
+ eventHub.$emit('updateApplication', {
+ id: FLUENTD,
+ params: {
+ port: this.port,
+ protocol: this.protocol,
+ host: this.host,
+ waf_log_enabled: this.wafLogEnabled,
+ cilium_log_enabled: this.ciliumLogEnabled,
+ },
+ });
+ },
+ resetCurrentServerSideSettings() {
+ this.currentServerSideSettings = mapValues(this.currentServerSideSettings, () => {
+ return null;
+ });
+ },
+ resetStatus() {
+ const newSettings = mapValues(this.currentServerSideSettings, (value, key) => {
+ return value === null ? this[key] : value;
+ });
+ eventHub.$emit('setFluentdSettings', {
+ ...newSettings,
+ isEditingSettings: false,
+ });
+ },
+ updateCurrentServerSideSettings(settings) {
+ Object.keys(settings).forEach(key => {
+ if (this.currentServerSideSettings[key] === null) {
+ this.currentServerSideSettings[key] = this[key];
+ }
+ });
+ },
+ setFluentdSettings(settings) {
+ this.updateCurrentServerSideSettings(settings);
+ eventHub.$emit('setFluentdSettings', {
+ ...settings,
+ isEditingSettings: true,
+ });
+ },
+ selectProtocol(protocol) {
+ this.setFluentdSettings({ protocol });
+ },
+ hostChanged(host) {
+ this.setFluentdSettings({ host });
+ },
+ portChanged(port) {
+ this.setFluentdSettings({ port: Number(port) });
+ },
+ wafLogChanged(wafLogEnabled) {
+ this.setFluentdSettings({ wafLogEnabled });
+ },
+ ciliumLogChanged(ciliumLogEnabled) {
+ this.setFluentdSettings({ ciliumLogEnabled });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="updateFailed" class="mb-3" variant="danger" :dismissible="false">
+ {{
+ s__(
+ 'ClusterIntegration|Something went wrong while trying to save your settings. Please try again.',
+ )
+ }}
+ </gl-alert>
+ <div class="form-horizontal">
+ <div class="form-group">
+ <label for="fluentd-host">
+ <strong>{{ s__('ClusterIntegration|SIEM Hostname') }}</strong>
+ </label>
+ <input
+ id="fluentd-host"
+ :value="host"
+ type="text"
+ class="form-control"
+ @input="hostChanged($event.target.value)"
+ />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-port">
+ <strong>{{ s__('ClusterIntegration|SIEM Port') }}</strong>
+ </label>
+ <input
+ id="fluentd-port"
+ :value="port"
+ type="number"
+ class="form-control"
+ @input="portChanged($event.target.value)"
+ />
+ </div>
+ <div class="form-group">
+ <label for="fluentd-protocol">
+ <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong>
+ </label>
+ <gl-dropdown :text="protocolName" class="w-100">
+ <gl-dropdown-item
+ v-for="(value, index) in protocols"
+ :key="index"
+ @click="selectProtocol(value.toLowerCase())"
+ >
+ {{ value }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+ <div class="form-group flex flex-wrap">
+ <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged">
+ <strong>{{ s__('ClusterIntegration|Send ModSecurity Logs') }}</strong>
+ </gl-form-checkbox>
+ <gl-form-checkbox :checked="ciliumLogEnabled" @input="ciliumLogChanged">
+ <strong>{{ s__('ClusterIntegration|Send Cilium Logs') }}</strong>
+ </gl-form-checkbox>
+ </div>
+ <div v-if="showButtons" class="mt-3">
+ <gl-deprecated-button
+ ref="saveBtn"
+ class="mr-1"
+ variant="success"
+ :loading="isSaving"
+ :disabled="saveButtonDisabled"
+ @click="updateApplication"
+ >
+ {{ saveButtonLabel }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelBtn" :disabled="saveButtonDisabled" @click="resetStatus">
+ {{ __('Cancel') }}
+ </gl-deprecated-button>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
index 95eb427a49c..c2f963f0b34 100644
--- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
+++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { s__, __ } from '../../locale';
import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants';
import {
@@ -87,7 +87,7 @@ export default {
);
},
ingressModSecurityDescription() {
- return esc(this.ingressModSecurityHelpPath);
+ return escape(this.ingressModSecurityHelpPath);
},
saving() {
return [UPDATING].includes(this.ingress.status);
diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
index b35adae3352..271f9f74838 100644
--- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
+++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import SplitButton from '~/vue_shared/components/split_button.vue';
import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -82,7 +82,7 @@ export default {
)
: s__('ClusterIntegration|To remove your integration, type %{clusterName} to confirm:'),
{
- clusterName: `<code>${esc(this.clusterName)}</code>`,
+ clusterName: `<code>${escape(this.clusterName)}</code>`,
},
false,
);
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index 6c3046fc56b..60e179c54eb 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -53,6 +53,7 @@ export const CERT_MANAGER = 'cert_manager';
export const CROSSPLANE = 'crossplane';
export const PROMETHEUS = 'prometheus';
export const ELASTIC_STACK = 'elastic_stack';
+export const FLUENTD = 'fluentd';
export const APPLICATIONS = [
HELM,
@@ -63,6 +64,7 @@ export const APPLICATIONS = [
CERT_MANAGER,
PROMETHEUS,
ELASTIC_STACK,
+ FLUENTD,
];
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/clusters/event_hub.js
+++ b/app/assets/javascripts/clusters/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 333fb293a15..2a6c6965dab 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -13,6 +13,7 @@ export default class ClusterService {
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
elastic_stack: this.options.installElasticStackEndpoint,
+ fluentd: this.options.installFluentdEndpoint,
};
this.appUpdateEndpointMap = {
knative: this.options.updateKnativeEndpoint,
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index b09fd6800b6..9d354e66661 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -13,6 +13,7 @@ import {
UPDATE_EVENT,
UNINSTALL_EVENT,
ELASTIC_STACK,
+ FLUENTD,
} from '../constants';
import transitionApplicationState from '../services/application_state_machine';
@@ -103,6 +104,16 @@ export default class ClusterStore {
...applicationInitialState,
title: s__('ClusterIntegration|Elastic Stack'),
},
+ fluentd: {
+ ...applicationInitialState,
+ title: s__('ClusterIntegration|Fluentd'),
+ host: null,
+ port: null,
+ protocol: null,
+ wafLogEnabled: null,
+ ciliumLogEnabled: null,
+ isEditingSettings: false,
+ },
},
environments: [],
fetchingEnvironments: false,
@@ -253,6 +264,14 @@ export default class ClusterStore {
} else if (appId === ELASTIC_STACK) {
this.state.applications.elastic_stack.version = version;
this.state.applications.elastic_stack.updateAvailable = updateAvailable;
+ } else if (appId === FLUENTD) {
+ if (!this.state.applications.fluentd.isEditingSettings) {
+ this.state.applications.fluentd.port = serverAppEntry.port;
+ this.state.applications.fluentd.host = serverAppEntry.host;
+ this.state.applications.fluentd.protocol = serverAppEntry.protocol;
+ this.state.applications.fluentd.wafLogEnabled = serverAppEntry.waf_log_enabled;
+ this.state.applications.fluentd.ciliumLogEnabled = serverAppEntry.cilium_log_enabled;
+ }
}
});
}
diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue
index 46dacf30f39..af3f1437c64 100644
--- a/app/assets/javascripts/clusters_list/components/clusters.vue
+++ b/app/assets/javascripts/clusters_list/components/clusters.vue
@@ -1,61 +1,78 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlTable, GlLoadingIcon, GlBadge } from '@gitlab/ui';
+import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
export default {
components: {
- GlTable,
- GlLoadingIcon,
GlBadge,
+ GlLink,
+ GlLoadingIcon,
+ GlPagination,
+ GlTable,
},
directives: {
tooltip,
},
- fields: [
- {
- key: 'name',
- label: __('Kubernetes cluster'),
- },
- {
- key: 'environmentScope',
- label: __('Environment scope'),
- },
- {
- key: 'size',
- label: __('Size'),
- },
- {
- key: 'cpu',
- label: __('Total cores (vCPUs)'),
+ computed: {
+ ...mapState(['clusters', 'clustersPerPage', 'loading', 'page', 'totalCulsters']),
+ currentPage: {
+ get() {
+ return this.page;
+ },
+ set(newVal) {
+ this.setPage(newVal);
+ this.fetchClusters();
+ },
},
- {
- key: 'memory',
- label: __('Total memory (GB)'),
+ fields() {
+ return [
+ {
+ key: 'name',
+ label: __('Kubernetes cluster'),
+ },
+ {
+ key: 'environment_scope',
+ label: __('Environment scope'),
+ },
+ // Wait for backend to send these fields
+ // {
+ // key: 'size',
+ // label: __('Size'),
+ // },
+ // {
+ // key: 'cpu',
+ // label: __('Total cores (vCPUs)'),
+ // },
+ // {
+ // key: 'memory',
+ // label: __('Total memory (GB)'),
+ // },
+ {
+ key: 'cluster_type',
+ label: __('Cluster level'),
+ formatter: value => CLUSTER_TYPES[value],
+ },
+ ];
},
- {
- key: 'clusterType',
- label: __('Cluster level'),
- formatter: value => CLUSTER_TYPES[value],
+ hasClusters() {
+ return this.clustersPerPage > 0;
},
- ],
- computed: {
- ...mapState(['clusters', 'loading']),
},
mounted() {
- // TODO - uncomment this once integrated with BE
- // this.fetchClusters();
+ this.fetchClusters();
},
methods: {
- ...mapActions(['fetchClusters']),
+ ...mapActions(['fetchClusters', 'setPage']),
statusClass(status) {
- return STATUSES[status].className;
+ const iconClass = STATUSES[status] || STATUSES.default;
+ return iconClass.className;
},
statusTitle(status) {
- const { title } = STATUSES[status];
- return sprintf(__('Status: %{title}'), { title }, false);
+ const iconTitle = STATUSES[status] || STATUSES.default;
+ return sprintf(__('Status: %{title}'), { title: iconTitle.title }, false);
},
},
};
@@ -63,37 +80,46 @@ export default {
<template>
<gl-loading-icon v-if="loading" size="md" class="mt-3" />
- <gl-table
- v-else
- :items="clusters"
- :fields="$options.fields"
- stacked="md"
- variant="light"
- class="qa-clusters-table"
- >
- <template #cell(name)="{ item }">
- <div class="d-flex flex-row-reverse flex-md-row js-status">
- {{ item.name }}
- <gl-loading-icon
- v-if="item.status === 'deleting'"
- v-tooltip
- :title="statusTitle(item.status)"
- size="sm"
- class="mr-2 ml-md-2"
- />
- <div
- v-else
- v-tooltip
- class="cluster-status-indicator rounded-circle align-self-center gl-w-8 gl-h-8 mr-2 ml-md-2"
- :class="statusClass(item.status)"
- :title="statusTitle(item.status)"
- ></div>
- </div>
- </template>
- <template #cell(clusterType)="{value}">
- <gl-badge variant="light">
- {{ value }}
- </gl-badge>
- </template>
- </gl-table>
+
+ <section v-else>
+ <gl-table :items="clusters" :fields="fields" stacked="md" class="qa-clusters-table">
+ <template #cell(name)="{ item }">
+ <div class="d-flex flex-row-reverse flex-md-row js-status">
+ <gl-link data-qa-selector="cluster" :data-qa-cluster-name="item.name" :href="item.path">
+ {{ item.name }}
+ </gl-link>
+
+ <gl-loading-icon
+ v-if="item.status === 'deleting'"
+ v-tooltip
+ :title="statusTitle(item.status)"
+ size="sm"
+ class="mr-2 ml-md-2"
+ />
+ <div
+ v-else
+ v-tooltip
+ class="cluster-status-indicator rounded-circle align-self-center gl-w-4 gl-h-4 mr-2 ml-md-2"
+ :class="statusClass(item.status)"
+ :title="statusTitle(item.status)"
+ ></div>
+ </div>
+ </template>
+ <template #cell(cluster_type)="{value}">
+ <gl-badge variant="light">
+ {{ value }}
+ </gl-badge>
+ </template>
+ </gl-table>
+
+ <gl-pagination
+ v-if="hasClusters"
+ v-model="currentPage"
+ :per-page="clustersPerPage"
+ :total-items="totalCulsters"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ align="center"
+ />
+ </section>
</template>
diff --git a/app/assets/javascripts/clusters_list/constants.js b/app/assets/javascripts/clusters_list/constants.js
index 9428f08176c..eebcaa086f9 100644
--- a/app/assets/javascripts/clusters_list/constants.js
+++ b/app/assets/javascripts/clusters_list/constants.js
@@ -7,8 +7,9 @@ export const CLUSTER_TYPES = {
};
export const STATUSES = {
+ default: { className: 'bg-white', title: __('Unknown') },
disabled: { className: 'disabled', title: __('Disabled') },
- connected: { className: 'bg-success', title: __('Connected') },
+ created: { className: 'bg-success', title: __('Connected') },
unreachable: { className: 'bg-danger', title: __('Unreachable') },
authentication_failure: { className: 'bg-warning', title: __('Authentication Failure') },
deleting: { title: __('Deleting') },
diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js
index 79bc9932438..919625f69b4 100644
--- a/app/assets/javascripts/clusters_list/store/actions.js
+++ b/app/assets/javascripts/clusters_list/store/actions.js
@@ -1,36 +1,35 @@
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
-import Visibility from 'visibilityjs';
import flash from '~/flash';
import { __ } from '~/locale';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
export const fetchClusters = ({ state, commit }) => {
const poll = new Poll({
resource: {
- fetchClusters: endpoint => axios.get(endpoint),
+ fetchClusters: paginatedEndPoint => axios.get(paginatedEndPoint),
},
- data: state.endpoint,
+ data: `${state.endpoint}?page=${state.page}`,
method: 'fetchClusters',
- successCallback: ({ data }) => {
- commit(types.SET_CLUSTERS_DATA, convertObjectPropsToCamelCase(data, { deep: true }));
- commit(types.SET_LOADING_STATE, false);
+ successCallback: ({ data, headers }) => {
+ if (data.clusters) {
+ const normalizedHeaders = normalizeHeaders(headers);
+ const paginationInformation = parseIntPagination(normalizedHeaders);
+
+ commit(types.SET_CLUSTERS_DATA, { data, paginationInformation });
+ commit(types.SET_LOADING_STATE, false);
+ poll.stop();
+ }
},
errorCallback: () => flash(__('An error occurred while loading clusters')),
});
- if (!Visibility.hidden()) {
- poll.makeRequest();
- }
+ poll.makeRequest();
+};
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
+export const setPage = ({ commit }, page) => {
+ commit(types.SET_PAGE, page);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/clusters_list/store/mutation_types.js b/app/assets/javascripts/clusters_list/store/mutation_types.js
index f056f3ab7d9..a5275f28c13 100644
--- a/app/assets/javascripts/clusters_list/store/mutation_types.js
+++ b/app/assets/javascripts/clusters_list/store/mutation_types.js
@@ -1,2 +1,3 @@
export const SET_CLUSTERS_DATA = 'SET_CLUSTERS_DATA';
export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_PAGE = 'SET_PAGE';
diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js
index ffd3c4601bf..2a9df9f38f0 100644
--- a/app/assets/javascripts/clusters_list/store/mutations.js
+++ b/app/assets/javascripts/clusters_list/store/mutations.js
@@ -4,9 +4,15 @@ export default {
[types.SET_LOADING_STATE](state, value) {
state.loading = value;
},
- [types.SET_CLUSTERS_DATA](state, clusters) {
+ [types.SET_CLUSTERS_DATA](state, { data, paginationInformation }) {
Object.assign(state, {
- clusters,
+ clusters: data.clusters,
+ clustersPerPage: paginationInformation.perPage,
+ hasAncestorClusters: data.has_ancestor_clusters,
+ totalCulsters: paginationInformation.total,
});
},
+ [types.SET_PAGE](state, value) {
+ state.page = Number(value) || 1;
+ },
};
diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js
index ed032ed8435..d590ea09e66 100644
--- a/app/assets/javascripts/clusters_list/store/state.js
+++ b/app/assets/javascripts/clusters_list/store/state.js
@@ -1,5 +1,9 @@
export default (initialState = {}) => ({
endpoint: initialState.endpoint,
- loading: false, // TODO - set this to true once integrated with BE
+ hasAncestorClusters: false,
+ loading: true,
clusters: [],
+ clustersPerPage: 0,
+ page: 1,
+ totalCulsters: 0,
});
diff --git a/app/assets/javascripts/code_navigation/components/app.vue b/app/assets/javascripts/code_navigation/components/app.vue
index d738c914125..85ec0a60ec5 100644
--- a/app/assets/javascripts/code_navigation/components/app.vue
+++ b/app/assets/javascripts/code_navigation/components/app.vue
@@ -8,7 +8,12 @@ export default {
Popover,
},
computed: {
- ...mapState(['currentDefinition', 'currentDefinitionPosition', 'definitionPathPrefix']),
+ ...mapState([
+ 'currentDefinition',
+ 'currentDefinitionPosition',
+ 'currentBlobPath',
+ 'definitionPathPrefix',
+ ]),
},
mounted() {
this.body = document.body;
@@ -44,5 +49,6 @@ export default {
:position="currentDefinitionPosition"
:data="currentDefinition"
:definition-path-prefix="definitionPathPrefix"
+ :blob-path="currentBlobPath"
/>
</template>
diff --git a/app/assets/javascripts/code_navigation/components/popover.vue b/app/assets/javascripts/code_navigation/components/popover.vue
index b4d9bc7b181..7147ce227e8 100644
--- a/app/assets/javascripts/code_navigation/components/popover.vue
+++ b/app/assets/javascripts/code_navigation/components/popover.vue
@@ -1,9 +1,9 @@
<script>
-import { GlDeprecatedButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
},
props: {
position: {
@@ -18,6 +18,10 @@ export default {
type: String,
required: true,
},
+ blobPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -32,9 +36,18 @@ export default {
};
},
definitionPath() {
- return (
- this.data.definition_path && `${this.definitionPathPrefix}/${this.data.definition_path}`
- );
+ if (!this.data.definition_path) {
+ return null;
+ }
+
+ if (this.isDefinitionCurrentBlob) {
+ return `#${this.data.definition_path.split('#').pop()}`;
+ }
+
+ return `${this.definitionPathPrefix}/${this.data.definition_path}`;
+ },
+ isDefinitionCurrentBlob() {
+ return this.data.definition_path.indexOf(this.blobPath) === 0;
},
},
watch: {
@@ -77,9 +90,15 @@ export default {
</p>
</div>
<div v-if="definitionPath" class="popover-body">
- <gl-deprecated-button :href="definitionPath" target="_blank" class="w-100" variant="default">
+ <gl-button
+ :href="definitionPath"
+ :target="isDefinitionCurrentBlob ? null : '_blank'"
+ class="w-100"
+ variant="default"
+ data-testid="go-to-definition-btn"
+ >
{{ __('Go to definition') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
index 6ecede32944..7b2669691bd 100644
--- a/app/assets/javascripts/code_navigation/store/actions.js
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -30,7 +30,9 @@ export default {
});
},
showBlobInteractionZones({ state }, path) {
- Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ if (state.data && state.data[path]) {
+ Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ }
},
showDefinition({ commit, state }, { target: el }) {
let definition;
@@ -52,7 +54,8 @@ export default {
return;
}
- const data = state.data[blobEl.dataset.path];
+ const blobPath = blobEl.dataset.path;
+ const data = state.data[blobPath];
if (!data) return;
@@ -72,6 +75,6 @@ export default {
setCurrentHoverElement(el);
}
- commit(types.SET_CURRENT_DEFINITION, { definition, position });
+ commit(types.SET_CURRENT_DEFINITION, { definition, position, blobPath });
},
};
diff --git a/app/assets/javascripts/code_navigation/store/mutations.js b/app/assets/javascripts/code_navigation/store/mutations.js
index 84b1c264418..07b190c7476 100644
--- a/app/assets/javascripts/code_navigation/store/mutations.js
+++ b/app/assets/javascripts/code_navigation/store/mutations.js
@@ -15,8 +15,9 @@ export default {
[types.REQUEST_DATA_ERROR](state) {
state.loading = false;
},
- [types.SET_CURRENT_DEFINITION](state, { definition, position }) {
+ [types.SET_CURRENT_DEFINITION](state, { definition, position, blobPath }) {
state.currentDefinition = definition;
state.currentDefinitionPosition = position;
+ state.currentBlobPath = blobPath;
},
};
diff --git a/app/assets/javascripts/code_navigation/store/state.js b/app/assets/javascripts/code_navigation/store/state.js
index ffe44ec5381..569d2f7b319 100644
--- a/app/assets/javascripts/code_navigation/store/state.js
+++ b/app/assets/javascripts/code_navigation/store/state.js
@@ -4,4 +4,5 @@ export default () => ({
data: null,
currentDefinition: null,
currentDefinitionPosition: null,
+ currentBlobPath: null,
});
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
index a259667bb75..2fcd40a901d 100644
--- a/app/assets/javascripts/comment_type_toggle.js
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -2,7 +2,7 @@ import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
class CommentTypeToggle {
constructor(opts = {}) {
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index fb8b1c17407..ddb129f36f4 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return, consistent-return, one-var, no-return-assign */
+/* eslint-disable func-names, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
@@ -201,9 +201,8 @@ export default class ImageFile {
if (domImg) {
if (domImg.complete) {
return callback.call(this, domImg.naturalWidth, domImg.naturalHeight);
- } else {
- return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight));
}
+ return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight));
}
}
}
diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js
index ad0f6cc1496..e0d012cef23 100644
--- a/app/assets/javascripts/commons/index.js
+++ b/app/assets/javascripts/commons/index.js
@@ -1,4 +1,3 @@
-import 'underscore';
import './polyfills';
import './jquery';
import './bootstrap';
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index a23707209dc..4539b9a39ef 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return */
+/* eslint-disable func-names */
import $ from 'jquery';
import { __ } from './locale';
@@ -52,9 +52,8 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
return $('<li />')
.addClass('dropdown-header')
.text(ref.header);
- } else {
- return $('<li />').append(link);
}
+ return $('<li />').append(link);
},
id(obj, $el) {
return $el.attr('data-ref');
diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
index 444640980af..92a5423d5ea 100644
--- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue
@@ -39,7 +39,7 @@ export default {
<template>
<gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100">
- <template slot="button-content">
+ <template #button-content>
<span class="str-truncated-100 mr-2">
<icon name="lock" />
{{ dropdownText }}
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 74b5a62f754..d6c402fcb5d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
@@ -1,6 +1,6 @@
<script>
import { createNamespacedHelpers, mapState, mapActions, mapGetters } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
@@ -137,7 +137,7 @@ export default {
: s__('ClusterIntegration|Create Kubernetes cluster');
},
kubernetesIntegrationHelpText() {
- const escapedUrl = esc(this.kubernetesIntegrationHelpPath);
+ const escapedUrl = escape(this.kubernetesIntegrationHelpPath);
return sprintf(
s__(
@@ -256,7 +256,7 @@ export default {
);
},
gitlabManagedHelpText() {
- const escapedUrl = esc(this.gitlabManagedClusterHelpPath);
+ const escapedUrl = escape(this.gitlabManagedClusterHelpPath);
return sprintf(
s__(
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 47cc4e4ce67..e063f9edfd9 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
@@ -1,6 +1,6 @@
<script>
import { GlFormInput } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { sprintf, s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -42,7 +42,7 @@ export default {
: s__('ClusterIntegration|Authenticate with AWS');
},
accountAndExternalIdsHelpText() {
- const escapedUrl = esc(this.accountAndExternalIdsHelpPath);
+ const escapedUrl = escape(this.accountAndExternalIdsHelpPath);
return sprintf(
s__(
@@ -59,7 +59,7 @@ export default {
);
},
provisionRoleArnHelpText() {
- const escapedUrl = esc(this.createRoleArnHelpPath);
+ const escapedUrl = escape(this.createRoleArnHelpPath);
return sprintf(
s__(
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
index 6d8e6bbac11..b0bec10f64d 100644
--- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__, sprintf } from '~/locale';
@@ -65,7 +65,7 @@ export default {
s__(message),
{
docsLinkEnd: '&nbsp;<i class="fa fa-external-link" aria-hidden="true"></i></a>',
- docsLinkStart: `<a href="${esc(
+ docsLinkStart: `<a href="${escape(
this.docsUrl,
)}" target="_blank" rel="noopener noreferrer">`,
},
diff --git a/app/assets/javascripts/create_item_dropdown.js b/app/assets/javascripts/create_item_dropdown.js
index 523e5592fd0..ec09dafebcb 100644
--- a/app/assets/javascripts/create_item_dropdown.js
+++ b/app/assets/javascripts/create_item_dropdown.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import '~/gl_dropdown';
export default class CreateItemDropdown {
@@ -37,14 +37,14 @@ export default class CreateItemDropdown {
},
selectable: true,
toggleLabel(selected) {
- return selected && 'id' in selected ? esc(selected.title) : this.defaultToggleLabel;
+ return selected && 'id' in selected ? escape(selected.title) : this.defaultToggleLabel;
},
fieldName: this.fieldName,
text(item) {
- return esc(item.text);
+ return escape(item.text);
},
id(item) {
- return esc(item.id);
+ return escape(item.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
clicked: options => {
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index ba585444ba5..801566d2f2f 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -13,7 +13,7 @@ import {
import confidentialMergeRequestState from './confidential_merge_request/state';
// Todo: Remove this when fixing issue in input_setter plugin
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
const CREATE_MERGE_REQUEST = 'create-mr';
const CREATE_BRANCH = 'create-branch';
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 304a0726597..4f9069f61a5 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -84,7 +84,7 @@ export default {
events.forEach(item => {
if (!item) return;
- const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item);
+ const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item };
eventItem.totalTime = eventItem.total_time;
diff --git a/app/assets/javascripts/deploy_keys/eventhub.js b/app/assets/javascripts/deploy_keys/eventhub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/deploy_keys/eventhub.js
+++ b/app/assets/javascripts/deploy_keys/eventhub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/design_management/components/app.vue b/app/assets/javascripts/design_management/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue
new file mode 100644
index 00000000000..1fd902c9ed7
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/delete_button.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+
+export default {
+ name: 'DeleteButton',
+ components: {
+ GlDeprecatedButton,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
+ },
+ props: {
+ isDeleting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ buttonClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ buttonVariant: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ hasSelectedDesigns: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ modalId: uniqueId('design-deletion-confirmation-'),
+ };
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-modal
+ :modal-id="modalId"
+ :title="s__('DesignManagement|Delete designs confirmation')"
+ :ok-title="s__('DesignManagement|Delete')"
+ ok-variant="danger"
+ @ok="$emit('deleteSelectedDesigns')"
+ >
+ <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p>
+ </gl-modal>
+ <gl-deprecated-button
+ v-gl-modal-directive="modalId"
+ :variant="buttonVariant"
+ :disabled="isDeleting || !hasSelectedDesigns"
+ :class="buttonClass"
+ >
+ <slot></slot>
+ </gl-deprecated-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue
new file mode 100644
index 00000000000..ad3f2736c4a
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_destroyer.vue
@@ -0,0 +1,66 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import destroyDesignMutation from '../graphql/mutations/destroyDesign.mutation.graphql';
+import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
+
+export default {
+ components: {
+ ApolloMutation,
+ },
+ props: {
+ filenames: {
+ type: Array,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ iid: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.iid, atVersion: null },
+ };
+ },
+ },
+ methods: {
+ updateStoreAfterDelete(
+ store,
+ {
+ data: { designManagementDelete },
+ },
+ ) {
+ updateStoreAfterDesignsDelete(
+ store,
+ designManagementDelete,
+ this.projectQueryBody,
+ this.filenames,
+ );
+ },
+ },
+ destroyDesignMutation,
+};
+</script>
+
+<template>
+ <apollo-mutation
+ #default="{ mutate, loading, error }"
+ :mutation="$options.destroyDesignMutation"
+ :variables="{
+ filenames,
+ projectPath,
+ iid,
+ }"
+ :update="updateStoreAfterDelete"
+ v-on="$listeners"
+ >
+ <slot v-bind="{ mutate, loading, error }"></slot>
+ </apollo-mutation>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue
new file mode 100644
index 00000000000..50ea69d52ce
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_note_pin.vue
@@ -0,0 +1,61 @@
+<script>
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'DesignNotePin',
+ components: {
+ Icon,
+ },
+ props: {
+ position: {
+ type: Object,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ repositioning: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ isNewNote() {
+ return this.label === null;
+ },
+ pinStyle() {
+ return this.repositioning ? { ...this.position, cursor: 'move' } : this.position;
+ },
+ pinLabel() {
+ return this.isNewNote
+ ? __('Comment form position')
+ : sprintf(__("Comment '%{label}' position"), { label: this.label });
+ },
+ },
+};
+</script>
+
+<template>
+ <button
+ :style="pinStyle"
+ :aria-label="pinLabel"
+ :class="{
+ 'btn-transparent comment-indicator': isNewNote,
+ 'js-image-badge badge badge-pill': !isNewNote,
+ }"
+ class="position-absolute"
+ type="button"
+ @mousedown="$emit('mousedown', $event)"
+ @mouseup="$emit('mouseup', $event)"
+ @click="$emit('click', $event)"
+ >
+ <icon v-if="isNewNote" name="image-comment-dark" />
+ <template v-else>
+ {{ label }}
+ </template>
+ </button>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
new file mode 100644
index 00000000000..c6c5ee88a93
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -0,0 +1,169 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import allVersionsMixin from '../../mixins/all_versions';
+import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql';
+import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
+import activeDiscussionQuery from '../../graphql/queries/active_discussion.query.graphql';
+import DesignNote from './design_note.vue';
+import DesignReplyForm from './design_reply_form.vue';
+import { updateStoreAfterAddDiscussionComment } from '../../utils/cache_update';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignNote,
+ ReplyPlaceholder,
+ DesignReplyForm,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ noteableId: {
+ type: String,
+ required: true,
+ },
+ designId: {
+ type: String,
+ required: true,
+ },
+ discussionIndex: {
+ type: Number,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ result({ data }) {
+ const discussionId = data.activeDiscussion.id;
+ // We watch any changes to the active discussion from the design pins and scroll to this discussion if it exists
+ // We don't want scrollIntoView to be triggered from the discussion click itself
+ if (
+ discussionId &&
+ data.activeDiscussion.source === ACTIVE_DISCUSSION_SOURCE_TYPES.pin &&
+ discussionId === this.discussion.notes[0].id
+ ) {
+ this.$el.scrollIntoView({
+ behavior: 'smooth',
+ inline: 'start',
+ });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ discussionComment: '',
+ isFormRendered: false,
+ activeDiscussion: {},
+ };
+ },
+ computed: {
+ mutationPayload() {
+ return {
+ noteableId: this.noteableId,
+ body: this.discussionComment,
+ discussionId: this.discussion.id,
+ };
+ },
+ designVariables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ isDiscussionHighlighted() {
+ return this.discussion.notes[0].id === this.activeDiscussion.id;
+ },
+ },
+ methods: {
+ addDiscussionComment(
+ store,
+ {
+ data: { createNote },
+ },
+ ) {
+ updateStoreAfterAddDiscussionComment(
+ store,
+ createNote,
+ getDesignQuery,
+ this.designVariables,
+ this.discussion.id,
+ );
+ },
+ onDone() {
+ this.discussionComment = '';
+ this.hideForm();
+ },
+ onError(err) {
+ this.$emit('error', err);
+ },
+ hideForm() {
+ this.isFormRendered = false;
+ this.discussionComment = '';
+ },
+ showForm() {
+ this.isFormRendered = true;
+ },
+ },
+ createNoteMutation,
+};
+</script>
+
+<template>
+ <div class="design-discussion-wrapper">
+ <div class="badge badge-pill" type="button">{{ discussionIndex }}</div>
+ <div
+ class="design-discussion bordered-box position-relative"
+ data-qa-selector="design_discussion_content"
+ >
+ <design-note
+ v-for="note in discussion.notes"
+ :key="note.id"
+ :note="note"
+ :markdown-preview-path="markdownPreviewPath"
+ :class="{ 'gl-bg-blue-50': isDiscussionHighlighted }"
+ @error="$emit('updateNoteError', $event)"
+ />
+ <div class="reply-wrapper">
+ <reply-placeholder
+ v-if="!isFormRendered"
+ class="qa-discussion-reply"
+ :button-text="__('Reply...')"
+ @onClick="showForm"
+ />
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.createNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addDiscussionComment"
+ @done="onDone"
+ @error="onError"
+ >
+ <design-reply-form
+ v-model="discussionComment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
new file mode 100644
index 00000000000..c1c19c0a597
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -0,0 +1,148 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from './design_reply_form.vue';
+import { findNoteId } from '../../utils/design_management_utils';
+import { hasErrors } from '../../utils/cache_update';
+
+export default {
+ components: {
+ UserAvatarLink,
+ TimelineEntryItem,
+ TimeAgoTooltip,
+ DesignReplyForm,
+ ApolloMutation,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ noteText: this.note.body,
+ isEditing: false,
+ };
+ },
+ computed: {
+ author() {
+ return this.note.author;
+ },
+ noteAnchorId() {
+ return findNoteId(this.note.id);
+ },
+ isNoteLinked() {
+ return this.$route.hash === `#note_${this.noteAnchorId}`;
+ },
+ mutationPayload() {
+ return {
+ id: this.note.id,
+ body: this.noteText,
+ };
+ },
+ },
+ mounted() {
+ if (this.isNoteLinked) {
+ this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
+ }
+ },
+ methods: {
+ hideForm() {
+ this.isEditing = false;
+ this.noteText = this.note.body;
+ },
+ onDone({ data }) {
+ this.hideForm();
+ if (hasErrors(data.updateNote)) {
+ this.$emit('error', data.errors[0]);
+ }
+ },
+ },
+ updateNoteMutation,
+};
+</script>
+
+<template>
+ <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form">
+ <user-avatar-link
+ :link-href="author.webUrl"
+ :img-src="author.avatarUrl"
+ :img-alt="author.username"
+ :img-size="40"
+ />
+ <div class="d-flex justify-content-between">
+ <div>
+ <a
+ v-once
+ :href="author.webUrl"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
+ <span class="note-header-author-name bold">{{ author.name }}</span>
+ <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
+ <span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="note.createdAt">
+ <span class="system-note-separator"></span>
+ <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
+ <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
+ </a>
+ </template>
+ </span>
+ </div>
+ <button
+ v-if="!isEditing && note.userPermissions.adminNote"
+ v-gl-tooltip
+ type="button"
+ title="Edit comment"
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ @click="isEditing = true"
+ >
+ <gl-icon name="pencil" class="link-highlight" />
+ </button>
+ </div>
+ <div
+ v-if="!isEditing"
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ v-html="note.bodyHtml"
+ ></div>
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.updateNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ @error="$emit('error', $event)"
+ @done="onDone"
+ >
+ <design-reply-form
+ v-model="noteText"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-new-comment="false"
+ class="mt-5"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
+ </timeline-entry-item>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
new file mode 100644
index 00000000000..40be9867fee
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -0,0 +1,137 @@
+<script>
+import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'DesignReplyForm',
+ components: {
+ MarkdownField,
+ GlDeprecatedButton,
+ GlModal,
+ },
+ props: {
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ isNewComment: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ formText: this.value,
+ };
+ },
+ computed: {
+ hasValue() {
+ return this.value.trim().length > 0;
+ },
+ modalSettings() {
+ if (this.isNewComment) {
+ return {
+ title: s__('DesignManagement|Cancel comment confirmation'),
+ okTitle: s__('DesignManagement|Discard comment'),
+ cancelTitle: s__('DesignManagement|Keep comment'),
+ content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
+ };
+ }
+ return {
+ title: s__('DesignManagement|Cancel comment update confirmation'),
+ okTitle: s__('DesignManagement|Cancel changes'),
+ cancelTitle: s__('DesignManagement|Keep changes'),
+ content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
+ };
+ },
+ buttonText() {
+ return this.isNewComment
+ ? s__('DesignManagement|Comment')
+ : s__('DesignManagement|Save comment');
+ },
+ },
+ mounted() {
+ this.$refs.textarea.focus();
+ },
+ methods: {
+ submitForm() {
+ if (this.hasValue) this.$emit('submitForm');
+ },
+ cancelComment() {
+ if (this.hasValue && this.formText !== this.value) {
+ this.$refs.cancelCommentModal.show();
+ } else {
+ this.$emit('cancelForm');
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <form class="new-note common-note-form" @submit.prevent>
+ <markdown-field
+ :markdown-preview-path="markdownPreviewPath"
+ :can-attach-file="false"
+ :enable-autocomplete="true"
+ :textarea-value="value"
+ markdown-docs-path="/help/user/markdown"
+ class="bordered-box"
+ >
+ <template #textarea>
+ <textarea
+ ref="textarea"
+ :value="value"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ data-qa-selector="note_textarea"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment…')"
+ @input="$emit('input', $event.target.value)"
+ @keydown.meta.enter="submitForm"
+ @keydown.ctrl.enter="submitForm"
+ @keyup.esc.stop="cancelComment"
+ >
+ </textarea>
+ </template>
+ </markdown-field>
+ <div class="note-form-actions d-flex justify-content-between">
+ <gl-deprecated-button
+ ref="submitButton"
+ :disabled="!hasValue || isSaving"
+ variant="success"
+ type="submit"
+ data-track-event="click_button"
+ data-qa-selector="save_comment_button"
+ @click="$emit('submitForm')"
+ >
+ {{ buttonText }}
+ </gl-deprecated-button>
+ <gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
+ __('Cancel')
+ }}</gl-deprecated-button>
+ </div>
+ <gl-modal
+ ref="cancelCommentModal"
+ ok-variant="danger"
+ :title="modalSettings.title"
+ :ok-title="modalSettings.okTitle"
+ :cancel-title="modalSettings.cancelTitle"
+ modal-id="cancel-comment-modal"
+ @ok="$emit('cancelForm')"
+ >{{ modalSettings.content }}
+ </gl-modal>
+ </form>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue
new file mode 100644
index 00000000000..beb51647821
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_overlay.vue
@@ -0,0 +1,279 @@
+<script>
+import activeDiscussionQuery from '../graphql/queries/active_discussion.query.graphql';
+import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql';
+import DesignNotePin from './design_note_pin.vue';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants';
+
+export default {
+ name: 'DesignOverlay',
+ components: {
+ DesignNotePin,
+ },
+ props: {
+ dimensions: {
+ type: Object,
+ required: true,
+ },
+ position: {
+ type: Object,
+ required: true,
+ },
+ notes: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ currentCommentForm: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ disableCommenting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ apollo: {
+ activeDiscussion: {
+ query: activeDiscussionQuery,
+ },
+ },
+ data() {
+ return {
+ movingNoteNewPosition: null,
+ movingNoteStartPosition: null,
+ activeDiscussion: {},
+ };
+ },
+ computed: {
+ overlayStyle() {
+ const cursor = this.disableCommenting ? 'unset' : undefined;
+
+ return {
+ cursor,
+ width: `${this.dimensions.width}px`,
+ height: `${this.dimensions.height}px`,
+ ...this.position,
+ };
+ },
+ isMovingCurrentComment() {
+ return Boolean(this.movingNoteStartPosition && !this.movingNoteStartPosition.noteId);
+ },
+ currentCommentPositionStyle() {
+ return this.isMovingCurrentComment && this.movingNoteNewPosition
+ ? this.getNotePositionStyle(this.movingNoteNewPosition)
+ : this.getNotePositionStyle(this.currentCommentForm);
+ },
+ },
+ methods: {
+ setNewNoteCoordinates({ x, y }) {
+ this.$emit('openCommentForm', { x, y });
+ },
+ getNoteRelativePosition(position) {
+ const { x, y, width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+ return {
+ left: Math.round(x * widthRatio),
+ top: Math.round(y * heightRatio),
+ };
+ },
+ getNotePositionStyle(position) {
+ const { left, top } = this.getNoteRelativePosition(position);
+ return {
+ left: `${left}px`,
+ top: `${top}px`,
+ };
+ },
+ getMovingNotePositionDelta(e) {
+ let deltaX = 0;
+ let deltaY = 0;
+
+ if (this.movingNoteStartPosition) {
+ const { clientX, clientY } = this.movingNoteStartPosition;
+ deltaX = e.clientX - clientX;
+ deltaY = e.clientY - clientY;
+ }
+
+ return {
+ deltaX,
+ deltaY,
+ };
+ },
+ isMovingNote(noteId) {
+ const movingNoteId = this.movingNoteStartPosition?.noteId;
+ return Boolean(movingNoteId && movingNoteId === noteId);
+ },
+ canMoveNote(note) {
+ const { userPermissions } = note;
+ const { adminNote } = userPermissions || {};
+
+ return Boolean(adminNote);
+ },
+ isPositionInOverlay(position) {
+ const { top, left } = this.getNoteRelativePosition(position);
+ const { height, width } = this.dimensions;
+
+ return top >= 0 && top <= height && left >= 0 && left <= width;
+ },
+ onNewNoteMove(e) {
+ if (!this.isMovingCurrentComment) return;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = this.currentCommentForm.x + deltaX;
+ const y = this.currentCommentForm.y + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onNewNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onExistingNoteMove(e) {
+ const note = this.notes.find(({ id }) => id === this.movingNoteStartPosition.noteId);
+ if (!note) return;
+
+ const { position } = note;
+ const { width, height } = position;
+ const widthRatio = this.dimensions.width / width;
+ const heightRatio = this.dimensions.height / height;
+
+ const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
+ const x = position.x * widthRatio + deltaX;
+ const y = position.y * heightRatio + deltaY;
+
+ const movingNoteNewPosition = {
+ x,
+ y,
+ width: this.dimensions.width,
+ height: this.dimensions.height,
+ };
+
+ if (!this.isPositionInOverlay(movingNoteNewPosition)) {
+ this.onExistingNoteMouseup();
+ return;
+ }
+
+ this.movingNoteNewPosition = movingNoteNewPosition;
+ },
+ onNewNoteMouseup() {
+ if (!this.movingNoteNewPosition) return;
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.setNewNoteCoordinates({ x, y });
+ },
+ onExistingNoteMouseup(note) {
+ if (!this.movingNoteStartPosition || !this.movingNoteNewPosition) {
+ this.updateActiveDiscussion(note.id);
+ this.$emit('closeCommentForm');
+ return;
+ }
+
+ const { x, y } = this.movingNoteNewPosition;
+ this.$emit('moveNote', {
+ noteId: this.movingNoteStartPosition.noteId,
+ discussionId: this.movingNoteStartPosition.discussionId,
+ coordinates: { x, y },
+ });
+ },
+ onNoteMousedown({ clientX, clientY }, note) {
+ if (note && !this.canMoveNote(note)) return;
+
+ this.movingNoteStartPosition = {
+ noteId: note?.id,
+ discussionId: note?.discussion.id,
+ clientX,
+ clientY,
+ };
+ },
+ onOverlayMousemove(e) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMove(e);
+ } else {
+ this.onExistingNoteMove(e);
+ }
+ },
+ onNoteMouseup(note) {
+ if (!this.movingNoteStartPosition) return;
+
+ if (this.isMovingCurrentComment) {
+ this.onNewNoteMouseup();
+ } else {
+ this.onExistingNoteMouseup(note);
+ }
+
+ this.movingNoteStartPosition = null;
+ this.movingNoteNewPosition = null;
+ },
+ onAddCommentMouseup({ offsetX, offsetY }) {
+ if (this.disableCommenting) return;
+ if (this.activeDiscussion.id) {
+ this.updateActiveDiscussion();
+ }
+
+ this.setNewNoteCoordinates({ x: offsetX, y: offsetY });
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin,
+ },
+ });
+ },
+ isNoteInactive(note) {
+ return this.activeDiscussion.id && this.activeDiscussion.id !== note.id;
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="position-absolute image-diff-overlay frame"
+ :style="overlayStyle"
+ @mousemove="onOverlayMousemove"
+ @mouseleave="onNoteMouseup"
+ >
+ <button
+ v-show="!disableCommenting"
+ type="button"
+ class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
+ data-qa-selector="design_image_button"
+ @mouseup="onAddCommentMouseup"
+ ></button>
+ <design-note-pin
+ v-for="(note, index) in notes"
+ :key="note.id"
+ :label="`${index + 1}`"
+ :repositioning="isMovingNote(note.id)"
+ :position="
+ isMovingNote(note.id) && movingNoteNewPosition
+ ? getNotePositionStyle(movingNoteNewPosition)
+ : getNotePositionStyle(note.position)
+ "
+ :class="{ inactive: isNoteInactive(note) }"
+ @mousedown.stop="onNoteMousedown($event, note)"
+ @mouseup.stop="onNoteMouseup(note)"
+ />
+ <design-note-pin
+ v-if="currentCommentForm"
+ :position="currentCommentPositionStyle"
+ :repositioning="isMovingCurrentComment"
+ @mousedown.stop="onNoteMousedown"
+ @mouseup.stop="onNoteMouseup"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_presentation.vue b/app/assets/javascripts/design_management/components/design_presentation.vue
new file mode 100644
index 00000000000..5c113b3dbed
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_presentation.vue
@@ -0,0 +1,314 @@
+<script>
+import { throttle } from 'lodash';
+import DesignImage from './image.vue';
+import DesignOverlay from './design_overlay.vue';
+
+const CLICK_DRAG_BUFFER_PX = 2;
+
+export default {
+ components: {
+ DesignImage,
+ DesignOverlay,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ imageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ discussions: {
+ type: Array,
+ required: true,
+ },
+ isAnnotating: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ overlayDimensions: null,
+ overlayPosition: null,
+ currentAnnotationPosition: null,
+ zoomFocalPoint: {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ },
+ initialLoad: true,
+ lastDragPosition: null,
+ isDraggingDesign: false,
+ };
+ },
+ computed: {
+ discussionStartingNotes() {
+ return this.discussions.map(discussion => discussion.notes[0]);
+ },
+ currentCommentForm() {
+ return (this.isAnnotating && this.currentAnnotationPosition) || null;
+ },
+ presentationStyle() {
+ return {
+ cursor: this.isDraggingDesign ? 'grabbing' : undefined,
+ };
+ },
+ },
+ beforeDestroy() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
+ },
+ mounted() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ this.scrollThrottled = throttle(() => {
+ this.shiftZoomFocalPoint();
+ }, 400);
+
+ presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
+ },
+ methods: {
+ syncCurrentAnnotationPosition() {
+ if (!this.currentAnnotationPosition) return;
+
+ const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
+ const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
+ const x = this.currentAnnotationPosition.x * widthRatio;
+ const y = this.currentAnnotationPosition.y * heightRatio;
+
+ this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
+ },
+ setOverlayDimensions(overlayDimensions) {
+ this.overlayDimensions = overlayDimensions;
+
+ // every time we set overlay dimensions, we need to
+ // update the current annotation as well
+ this.syncCurrentAnnotationPosition();
+ },
+ setOverlayPosition() {
+ if (!this.overlayDimensions) {
+ this.overlayPosition = {};
+ }
+
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ // default to center
+ this.overlayPosition = {
+ left: `calc(50% - ${this.overlayDimensions.width / 2}px)`,
+ top: `calc(50% - ${this.overlayDimensions.height / 2}px)`,
+ };
+
+ // if the overlay overflows, then don't center
+ if (this.overlayDimensions.width > presentationViewport.offsetWidth) {
+ this.overlayPosition.left = '0';
+ }
+ if (this.overlayDimensions.height > presentationViewport.offsetHeight) {
+ this.overlayPosition.top = '0';
+ }
+ },
+ /**
+ * Return a point that represents the center of an
+ * overflowing child element w.r.t it's parent
+ */
+ getViewportCenter() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return {};
+
+ // get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
+ const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
+ const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
+
+ // determine how many child pixels have been scrolled
+ const xScrollRatio =
+ presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
+ const yScrollRatio =
+ presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
+ const xScrollOffset =
+ (presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
+ const yScrollOffset =
+ (presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
+
+ const viewportCenterX = presentationViewport.offsetWidth / 2;
+ const viewportCenterY = presentationViewport.offsetHeight / 2;
+ const focalPointX = viewportCenterX + xScrollOffset;
+ const focalPointY = viewportCenterY + yScrollOffset;
+
+ return {
+ x: focalPointX,
+ y: focalPointY,
+ };
+ },
+ /**
+ * Scroll the viewport such that the focal point is positioned centrally
+ */
+ scrollToFocalPoint() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return;
+
+ const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
+ const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
+
+ presentationViewport.scrollTo(scrollX, scrollY);
+ },
+ scaleZoomFocalPoint() {
+ const { x, y, width, height } = this.zoomFocalPoint;
+ const widthRatio = this.overlayDimensions.width / width;
+ const heightRatio = this.overlayDimensions.height / height;
+
+ this.zoomFocalPoint = {
+ x: Math.round(x * widthRatio * 100) / 100,
+ y: Math.round(y * heightRatio * 100) / 100,
+ ...this.overlayDimensions,
+ };
+ },
+ shiftZoomFocalPoint() {
+ this.zoomFocalPoint = {
+ ...this.getViewportCenter(),
+ ...this.overlayDimensions,
+ };
+ },
+ onImageResize(imageDimensions) {
+ this.setOverlayDimensions(imageDimensions);
+ this.setOverlayPosition();
+
+ this.$nextTick(() => {
+ if (this.initialLoad) {
+ // set focal point on initial load
+ this.shiftZoomFocalPoint();
+ this.initialLoad = false;
+ } else {
+ this.scaleZoomFocalPoint();
+ this.scrollToFocalPoint();
+ }
+ });
+ },
+ getAnnotationPositon(coordinates) {
+ const { x, y } = coordinates;
+ const { width, height } = this.overlayDimensions;
+ return {
+ x: Math.round(x),
+ y: Math.round(y),
+ width: Math.round(width),
+ height: Math.round(height),
+ };
+ },
+ openCommentForm(coordinates) {
+ this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
+ this.$emit('openCommentForm', this.currentAnnotationPosition);
+ },
+ closeCommentForm() {
+ this.currentAnnotationPosition = null;
+ this.$emit('closeCommentForm');
+ },
+ moveNote({ noteId, discussionId, coordinates }) {
+ const position = this.getAnnotationPositon(coordinates);
+ this.$emit('moveNote', { noteId, discussionId, position });
+ },
+ onPresentationMousedown({ clientX, clientY }) {
+ if (!this.isDesignOverflowing()) return;
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ getDragDelta(clientX, clientY) {
+ return {
+ deltaX: this.lastDragPosition.x - clientX,
+ deltaY: this.lastDragPosition.y - clientY,
+ };
+ },
+ exceedsDragThreshold(clientX, clientY) {
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+
+ return Math.abs(deltaX) > CLICK_DRAG_BUFFER_PX || Math.abs(deltaY) > CLICK_DRAG_BUFFER_PX;
+ },
+ shouldDragDesign(clientX, clientY) {
+ return (
+ this.lastDragPosition &&
+ (this.isDraggingDesign || this.exceedsDragThreshold(clientX, clientY))
+ );
+ },
+ onPresentationMousemove({ clientX, clientY }) {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport || !this.shouldDragDesign(clientX, clientY)) return;
+
+ this.isDraggingDesign = true;
+
+ const { scrollLeft, scrollTop } = presentationViewport;
+ const { deltaX, deltaY } = this.getDragDelta(clientX, clientY);
+ presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
+
+ this.lastDragPosition = {
+ x: clientX,
+ y: clientY,
+ };
+ },
+ onPresentationMouseup() {
+ this.lastDragPosition = null;
+ this.isDraggingDesign = false;
+ },
+ isDesignOverflowing() {
+ const { presentationViewport } = this.$refs;
+ if (!presentationViewport) return false;
+
+ return (
+ presentationViewport.scrollWidth > presentationViewport.offsetWidth ||
+ presentationViewport.scrollHeight > presentationViewport.offsetHeight
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ ref="presentationViewport"
+ class="h-100 w-100 p-3 overflow-auto position-relative"
+ :style="presentationStyle"
+ @mousedown="onPresentationMousedown"
+ @mousemove="onPresentationMousemove"
+ @mouseup="onPresentationMouseup"
+ @mouseleave="onPresentationMouseup"
+ @touchstart="onPresentationMousedown"
+ @touchmove="onPresentationMousemove"
+ @touchend="onPresentationMouseup"
+ @touchcancel="onPresentationMouseup"
+ >
+ <div class="h-100 w-100 d-flex align-items-center position-relative">
+ <design-image
+ v-if="image"
+ :image="image"
+ :name="imageName"
+ :scale="scale"
+ @resize="onImageResize"
+ />
+ <design-overlay
+ v-if="overlayDimensions && overlayPosition"
+ :dimensions="overlayDimensions"
+ :position="overlayPosition"
+ :notes="discussionStartingNotes"
+ :current-comment-form="currentCommentForm"
+ :disable-commenting="isDraggingDesign"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="moveNote"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/design_scaler.vue b/app/assets/javascripts/design_management/components/design_scaler.vue
new file mode 100644
index 00000000000..55dee74bef5
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/design_scaler.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+const SCALE_STEP_SIZE = 0.2;
+const DEFAULT_SCALE = 1;
+const MIN_SCALE = 1;
+const MAX_SCALE = 2;
+
+export default {
+ components: {
+ GlIcon,
+ },
+ data() {
+ return {
+ scale: DEFAULT_SCALE,
+ };
+ },
+ computed: {
+ disableReset() {
+ return this.scale <= MIN_SCALE;
+ },
+ disableDecrease() {
+ return this.scale === DEFAULT_SCALE;
+ },
+ disableIncrease() {
+ return this.scale >= MAX_SCALE;
+ },
+ },
+ methods: {
+ setScale(scale) {
+ if (scale < MIN_SCALE) {
+ return;
+ }
+
+ this.scale = Math.round(scale * 100) / 100;
+ this.$emit('scale', this.scale);
+ },
+ incrementScale() {
+ this.setScale(this.scale + SCALE_STEP_SIZE);
+ },
+ decrementScale() {
+ this.setScale(this.scale - SCALE_STEP_SIZE);
+ },
+ resetScale() {
+ this.setScale(DEFAULT_SCALE);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="design-scaler btn-group" role="group">
+ <button class="btn" :disabled="disableDecrease" @click="decrementScale">
+ <span class="d-flex-center gl-icon s16">
+ –
+ </span>
+ </button>
+ <button class="btn" :disabled="disableReset" @click="resetScale">
+ <gl-icon name="redo" />
+ </button>
+ <button class="btn" :disabled="disableIncrease" @click="incrementScale">
+ <gl-icon name="plus" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/image.vue b/app/assets/javascripts/design_management/components/image.vue
new file mode 100644
index 00000000000..91b7b576e0c
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/image.vue
@@ -0,0 +1,110 @@
+<script>
+import { throttle } from 'lodash';
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ image: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ name: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ scale: {
+ type: Number,
+ required: false,
+ default: 1,
+ },
+ },
+ data() {
+ return {
+ baseImageSize: null,
+ imageStyle: null,
+ imageError: false,
+ };
+ },
+ watch: {
+ scale(val) {
+ this.zoom(val);
+ },
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.resizeThrottled, false);
+ },
+ mounted() {
+ this.onImgLoad();
+
+ this.resizeThrottled = throttle(() => {
+ // NOTE: if imageStyle is set, then baseImageSize
+ // won't change due to resize. We must still emit a
+ // `resize` event so that the parent can handle
+ // resizes appropriately (e.g. for design_overlay)
+ this.setBaseImageSize();
+ }, 400);
+ window.addEventListener('resize', this.resizeThrottled, false);
+ },
+ methods: {
+ onImgLoad() {
+ requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
+ },
+ onImgError() {
+ this.imageError = true;
+ },
+ setBaseImageSize() {
+ const { contentImg } = this.$refs;
+ if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
+
+ this.baseImageSize = {
+ height: contentImg.offsetHeight,
+ width: contentImg.offsetWidth,
+ };
+ this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
+ },
+ onResize({ width, height }) {
+ this.$emit('resize', { width, height });
+ },
+ zoom(amount) {
+ if (amount === 1) {
+ this.imageStyle = null;
+ this.$nextTick(() => {
+ this.setBaseImageSize();
+ });
+ return;
+ }
+ const width = this.baseImageSize.width * amount;
+ const height = this.baseImageSize.height * amount;
+
+ this.imageStyle = {
+ width: `${width}px`,
+ height: `${height}px`,
+ };
+
+ this.onResize({ width, height });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="m-auto js-design-image">
+ <gl-icon v-if="imageError" class="text-secondary-100" name="media-broken" :size="48" />
+ <img
+ v-show="!imageError"
+ ref="contentImg"
+ class="mh-100"
+ :src="image"
+ :alt="name"
+ :style="imageStyle"
+ :class="{ 'img-fluid': !imageStyle }"
+ @error="onImgError"
+ @load="onImgLoad"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue
new file mode 100644
index 00000000000..eaa641d85d6
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/list/item.vue
@@ -0,0 +1,174 @@
+<script>
+import { GlLoadingIcon, GlIcon, GlIntersectionObserver } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import { n__, __ } from '~/locale';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlIntersectionObserver,
+ GlIcon,
+ Icon,
+ Timeago,
+ },
+ props: {
+ id: {
+ type: [Number, String],
+ required: true,
+ },
+ event: {
+ type: String,
+ required: true,
+ },
+ notesCount: {
+ type: Number,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: true,
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ isUploading: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ imageV432x230: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ data() {
+ return {
+ imageLoading: true,
+ imageError: false,
+ wasInView: false,
+ };
+ },
+ computed: {
+ icon() {
+ const normalizedEvent = this.event.toLowerCase();
+ const icons = {
+ creation: {
+ name: 'file-addition-solid',
+ classes: 'text-success-500',
+ tooltip: __('Added in this version'),
+ },
+ modification: {
+ name: 'file-modified-solid',
+ classes: 'text-primary-500',
+ tooltip: __('Modified in this version'),
+ },
+ deletion: {
+ name: 'file-deletion-solid',
+ classes: 'text-danger-500',
+ tooltip: __('Deleted in this version'),
+ },
+ };
+
+ return icons[normalizedEvent] ? icons[normalizedEvent] : {};
+ },
+ notesLabel() {
+ return n__('%d comment', '%d comments', this.notesCount);
+ },
+ imageLink() {
+ return this.wasInView ? this.imageV432x230 || this.image : '';
+ },
+ showLoadingSpinner() {
+ return this.imageLoading || this.isUploading;
+ },
+ showImageErrorIcon() {
+ return this.wasInView && this.imageError;
+ },
+ showImage() {
+ return !this.showLoadingSpinner && !this.showImageErrorIcon;
+ },
+ },
+ methods: {
+ onImageLoad() {
+ this.imageLoading = false;
+ this.imageError = false;
+ },
+ onImageError() {
+ this.imageLoading = false;
+ this.imageError = true;
+ },
+ onAppear() {
+ // do nothing if image has previously
+ // been in view
+ if (this.wasInView) {
+ return;
+ }
+
+ this.wasInView = true;
+ this.imageLoading = true;
+ },
+ },
+ DESIGN_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <router-link
+ :to="{
+ name: $options.DESIGN_ROUTE_NAME,
+ params: { id: filename },
+ query: $route.query,
+ }"
+ class="card cursor-pointer text-plain js-design-list-item design-list-item"
+ >
+ <div class="card-body p-0 d-flex-center overflow-hidden position-relative">
+ <div v-if="icon.name" class="design-event position-absolute">
+ <span :title="icon.tooltip" :aria-label="icon.tooltip">
+ <icon :name="icon.name" :size="18" :class="icon.classes" />
+ </span>
+ </div>
+ <gl-intersection-observer @appear="onAppear">
+ <gl-loading-icon v-if="showLoadingSpinner" size="md" />
+ <gl-icon
+ v-else-if="showImageErrorIcon"
+ name="media-broken"
+ class="text-secondary"
+ :size="32"
+ />
+ <img
+ v-show="showImage"
+ :src="imageLink"
+ :alt="filename"
+ class="block mx-auto mw-100 mh-100 design-img"
+ data-qa-selector="design_image"
+ @load="onImageLoad"
+ @error="onImageError"
+ />
+ </gl-intersection-observer>
+ </div>
+ <div class="card-footer d-flex w-100">
+ <div class="d-flex flex-column str-truncated-100">
+ <span class="bold str-truncated-100" data-qa-selector="design_file_name">{{
+ filename
+ }}</span>
+ <span v-if="updatedAt" class="str-truncated-100">
+ {{ __('Updated') }} <timeago :time="updatedAt" tooltip-placement="bottom" />
+ </span>
+ </div>
+ <div v-if="notesCount" class="ml-auto d-flex align-items-center text-secondary">
+ <icon name="comments" class="ml-1" />
+ <span :aria-label="notesLabel" class="ml-1">
+ {{ notesCount }}
+ </span>
+ </div>
+ </div>
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue
new file mode 100644
index 00000000000..ea9f7300981
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/index.vue
@@ -0,0 +1,126 @@
+<script>
+import { GlDeprecatedButton } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import Pagination from './pagination.vue';
+import DeleteButton from '../delete_button.vue';
+import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
+import appDataQuery from '../../graphql/queries/appData.query.graphql';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ Pagination,
+ DeleteButton,
+ GlDeprecatedButton,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ isDeleting: {
+ type: Boolean,
+ required: true,
+ },
+ filename: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ updatedAt: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ updatedBy: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLatestVersion: {
+ type: Boolean,
+ required: true,
+ },
+ image: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ projectPath: '',
+ issueIid: null,
+ };
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ computed: {
+ updatedText() {
+ return sprintf(__('Updated %{updated_at} by %{updated_by}'), {
+ updated_at: this.timeFormatted(this.updatedAt),
+ updated_by: this.updatedBy.name,
+ });
+ },
+ canDeleteDesign() {
+ return this.permissions.createDesign;
+ },
+ },
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <header class="d-flex p-2 bg-white align-items-center js-design-header">
+ <router-link
+ :to="{
+ name: $options.DESIGNS_ROUTE_NAME,
+ query: $route.query,
+ }"
+ :aria-label="s__('DesignManagement|Go back to designs')"
+ class="mr-3 text-plain d-flex justify-content-center align-items-center"
+ >
+ <icon :size="18" name="close" />
+ </router-link>
+ <div class="overflow-hidden d-flex align-items-center">
+ <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2>
+ <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
+ </div>
+ <pagination :id="id" class="ml-auto flex-shrink-0" />
+ <gl-deprecated-button :href="image" class="mr-2">
+ <icon :size="18" name="download" />
+ </gl-deprecated-button>
+ <delete-button
+ v-if="isLatestVersion && canDeleteDesign"
+ :is-deleting="isDeleting"
+ button-variant="danger"
+ @deleteSelectedDesigns="$emit('delete')"
+ >
+ <icon :size="18" name="remove" />
+ </delete-button>
+ </header>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination.vue b/app/assets/javascripts/design_management/components/toolbar/pagination.vue
new file mode 100644
index 00000000000..bf62a8f66a6
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/pagination.vue
@@ -0,0 +1,83 @@
+<script>
+/* global Mousetrap */
+import 'mousetrap';
+import { s__, sprintf } from '~/locale';
+import PaginationButton from './pagination_button.vue';
+import allDesignsMixin from '../../mixins/all_designs';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ PaginationButton,
+ },
+ mixins: [allDesignsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designsCount() {
+ return this.designs.length;
+ },
+ currentIndex() {
+ return this.designs.findIndex(design => design.filename === this.id);
+ },
+ paginationText() {
+ return sprintf(s__('DesignManagement|%{current_design} of %{designs_count}'), {
+ current_design: this.currentIndex + 1,
+ designs_count: this.designsCount,
+ });
+ },
+ previousDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex - 1];
+ },
+ nextDesign() {
+ if (!this.designsCount) return null;
+
+ return this.designs[this.currentIndex + 1];
+ },
+ },
+ mounted() {
+ Mousetrap.bind('left', () => this.navigateToDesign(this.previousDesign));
+ Mousetrap.bind('right', () => this.navigateToDesign(this.nextDesign));
+ },
+ beforeDestroy() {
+ Mousetrap.unbind(['left', 'right'], this.navigateToDesign);
+ },
+ methods: {
+ navigateToDesign(design) {
+ if (design) {
+ this.$router.push({
+ name: DESIGN_ROUTE_NAME,
+ params: { id: design.filename },
+ query: this.$route.query,
+ });
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="designsCount" class="d-flex align-items-center">
+ {{ paginationText }}
+ <div class="btn-group ml-3 mr-3">
+ <pagination-button
+ :design="previousDesign"
+ :title="s__('DesignManagement|Go to previous design')"
+ icon-name="angle-left"
+ class="js-previous-design"
+ />
+ <pagination-button
+ :design="nextDesign"
+ :title="s__('DesignManagement|Go to next design')"
+ icon-name="angle-right"
+ class="js-next-design"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue
new file mode 100644
index 00000000000..f00ecefca01
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue
@@ -0,0 +1,48 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { DESIGN_ROUTE_NAME } from '../../router/constants';
+
+export default {
+ components: {
+ Icon,
+ },
+ props: {
+ design: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ title: {
+ type: String,
+ required: true,
+ },
+ iconName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ designLink() {
+ if (!this.design) return {};
+
+ return {
+ name: DESIGN_ROUTE_NAME,
+ params: { id: this.design.filename },
+ query: this.$route.query,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <router-link
+ :to="designLink"
+ :disabled="!design"
+ :class="{ disabled: !design }"
+ :aria-label="title"
+ class="btn btn-default"
+ >
+ <icon :name="iconName" />
+ </router-link>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue
new file mode 100644
index 00000000000..e3c5e369170
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/button.vue
@@ -0,0 +1,58 @@
+<script>
+import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ isSaving: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onFileUploadChange(e) {
+ this.$emit('upload', e.target.files);
+ },
+ },
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div>
+ <gl-deprecated-button
+ v-gl-tooltip.hover
+ :title="
+ s__(
+ 'DesignManagement|Adding a design with the same filename replaces the file in a new version.',
+ )
+ "
+ :disabled="isSaving"
+ variant="success"
+ @click="openFileUpload"
+ >
+ {{ s__('DesignManagement|Add designs') }}
+ <gl-loading-icon v-if="isSaving" inline class="ml-1" />
+ </gl-deprecated-button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onFileUploadChange"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
new file mode 100644
index 00000000000..e2e1fc8bfad
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue
@@ -0,0 +1,134 @@
+<script>
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import uploadDesignMutation from '../../graphql/mutations/uploadDesign.mutation.graphql';
+import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages';
+import { isValidDesignFile } from '../../utils/design_management_utils';
+import { VALID_DATA_TRANSFER_TYPE, VALID_DESIGN_FILE_MIMETYPE } from '../../constants';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ GlSprintf,
+ },
+ data() {
+ return {
+ dragCounter: 0,
+ isDragDataValid: false,
+ };
+ },
+ computed: {
+ dragging() {
+ return this.dragCounter !== 0;
+ },
+ },
+ methods: {
+ isValidUpload(files) {
+ return files.every(isValidDesignFile);
+ },
+ isValidDragDataType({ dataTransfer }) {
+ return Boolean(dataTransfer && dataTransfer.types.some(t => t === VALID_DATA_TRANSFER_TYPE));
+ },
+ ondrop({ dataTransfer = {} }) {
+ this.dragCounter = 0;
+ // User already had feedback when dropzone was active, so bail here
+ if (!this.isDragDataValid) {
+ return;
+ }
+
+ const { files } = dataTransfer;
+ if (!this.isValidUpload(Array.from(files))) {
+ createFlash(UPLOAD_DESIGN_INVALID_FILETYPE_ERROR);
+ return;
+ }
+
+ this.$emit('change', files);
+ },
+ ondragenter(e) {
+ this.dragCounter += 1;
+ this.isDragDataValid = this.isValidDragDataType(e);
+ },
+ ondragleave() {
+ this.dragCounter -= 1;
+ },
+ openFileUpload() {
+ this.$refs.fileUpload.click();
+ },
+ onDesignInputChange(e) {
+ this.$emit('change', e.target.files);
+ },
+ },
+ uploadDesignMutation,
+ VALID_DESIGN_FILE_MIMETYPE,
+};
+</script>
+
+<template>
+ <div
+ class="w-100 position-relative"
+ @dragstart.prevent.stop
+ @dragend.prevent.stop
+ @dragover.prevent.stop
+ @dragenter.prevent.stop="ondragenter"
+ @dragleave.prevent.stop="ondragleave"
+ @drop.prevent.stop="ondrop"
+ >
+ <slot>
+ <button
+ class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3"
+ @click="openFileUpload"
+ >
+ <div class="d-flex-center flex-column text-center">
+ <gl-icon name="doc-new" :size="48" class="mb-4" />
+ <p>
+ <gl-sprintf
+ :message="
+ __(
+ '%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.',
+ )
+ "
+ >
+ <template #lineOne="{ content }"
+ ><span class="d-block">{{ content }}</span>
+ </template>
+
+ <template #link="{ content }">
+ <gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </button>
+
+ <input
+ ref="fileUpload"
+ type="file"
+ name="design_file"
+ :accept="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
+ class="hide"
+ multiple
+ @change="onDesignInputChange"
+ />
+ </slot>
+ <transition name="design-dropzone-fade">
+ <div
+ v-show="dragging"
+ class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white"
+ >
+ <div v-show="!isDragDataValid" class="mw-50 text-center">
+ <h3>{{ __('Oh no!') }}</h3>
+ <span>{{
+ __(
+ 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.',
+ )
+ }}</span>
+ </div>
+ <div v-show="isDragDataValid" class="mw-50 text-center">
+ <h3>{{ __('Incoming!') }}</h3>
+ <span>{{ __('Drop your designs to start your upload.') }}</span>
+ </div>
+ </div>
+ </transition>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
new file mode 100644
index 00000000000..993eac6f37f
--- /dev/null
+++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue
@@ -0,0 +1,76 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import allVersionsMixin from '../../mixins/all_versions';
+import { findVersionId } from '../../utils/design_management_utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ mixins: [allVersionsMixin],
+ computed: {
+ queryVersion() {
+ return this.$route.query.version;
+ },
+ currentVersionIdx() {
+ if (!this.queryVersion) return 0;
+
+ const idx = this.allVersions.findIndex(
+ version => this.findVersionId(version.node.id) === this.queryVersion,
+ );
+
+ // if the currentVersionId isn't a valid version (i.e. not in allVersions)
+ // then return the latest version (index 0)
+ return idx !== -1 ? idx : 0;
+ },
+ currentVersionId() {
+ if (this.queryVersion) return this.queryVersion;
+
+ const currentVersion = this.allVersions[this.currentVersionIdx];
+ return this.findVersionId(currentVersion.node.id);
+ },
+ dropdownText() {
+ if (this.isLatestVersion) {
+ return __('Showing Latest Version');
+ }
+ // allVersions is sorted in reverse chronological order (latest first)
+ const currentVersionNumber = this.allVersions.length - this.currentVersionIdx;
+
+ return sprintf(__('Showing Version #%{versionNumber}'), {
+ versionNumber: currentVersionNumber,
+ });
+ },
+ },
+ methods: {
+ findVersionId,
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown">
+ <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id">
+ <router-link
+ class="d-flex js-version-link"
+ :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }"
+ >
+ <div class="flex-grow-1 ml-2">
+ <div>
+ <strong
+ >{{ __('Version') }} {{ allVersions.length - index }}
+ <span v-if="findVersionId(version.node.id) === latestVersionId"
+ >({{ __('latest') }})</span
+ >
+ </strong>
+ </div>
+ </div>
+ <i
+ v-if="findVersionId(version.node.id) === currentVersionId"
+ class="fa fa-check pull-right"
+ ></i>
+ </router-link>
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/design_management/constants.js b/app/assets/javascripts/design_management/constants.js
new file mode 100644
index 00000000000..59d34669ad7
--- /dev/null
+++ b/app/assets/javascripts/design_management/constants.js
@@ -0,0 +1,14 @@
+// WARNING: replace this with something
+// more sensical as per https://gitlab.com/gitlab-org/gitlab/issues/118611
+export const VALID_DESIGN_FILE_MIMETYPE = {
+ mimetype: 'image/*',
+ regex: /image\/.+/,
+};
+
+// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
+export const VALID_DATA_TRANSFER_TYPE = 'Files';
+
+export const ACTIVE_DISCUSSION_SOURCE_TYPES = {
+ pin: 'pin',
+ discussion: 'discussion',
+};
diff --git a/app/assets/javascripts/design_management/graphql.js b/app/assets/javascripts/design_management/graphql.js
new file mode 100644
index 00000000000..fae337aa75b
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql.js
@@ -0,0 +1,45 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { uniqueId } from 'lodash';
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import activeDiscussionQuery from './graphql/queries/active_discussion.query.graphql';
+import typeDefs from './graphql/typedefs.graphql';
+
+Vue.use(VueApollo);
+
+const resolvers = {
+ Mutation: {
+ updateActiveDiscussion: (_, { id = null, source }, { cache }) => {
+ const data = cache.readQuery({ query: activeDiscussionQuery });
+ data.activeDiscussion = {
+ __typename: 'ActiveDiscussion',
+ id,
+ source,
+ };
+ cache.writeQuery({ query: activeDiscussionQuery, data });
+ },
+ },
+};
+
+const defaultClient = createDefaultClient(
+ resolvers,
+ // This config is added temporarily to resolve an issue with duplicate design IDs.
+ // Should be removed as soon as https://gitlab.com/gitlab-org/gitlab/issues/13495 is resolved
+ {
+ cacheConfig: {
+ dataIdFromObject: object => {
+ // eslint-disable-next-line no-underscore-dangle, @gitlab/require-i18n-strings
+ if (object.__typename === 'Design') {
+ return object.id && object.image ? `${object.id}-${object.image}` : uniqueId();
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ typeDefs,
+ },
+);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
new file mode 100644
index 00000000000..ca5b5a52c71
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/design.fragment.graphql
@@ -0,0 +1,22 @@
+#import "./designNote.fragment.graphql"
+#import "./designList.fragment.graphql"
+#import "./diffRefs.fragment.graphql"
+
+fragment DesignItem on Design {
+ ...DesignListItem
+ fullPath
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ discussions {
+ nodes {
+ id
+ replyId
+ notes {
+ nodes {
+ ...DesignNote
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql
new file mode 100644
index 00000000000..bc3132f9b42
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/designList.fragment.graphql
@@ -0,0 +1,8 @@
+fragment DesignListItem on Design {
+ id
+ event
+ filename
+ notesCount
+ image
+ imageV432x230
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
new file mode 100644
index 00000000000..2ad84f9cb17
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/designNote.fragment.graphql
@@ -0,0 +1,28 @@
+#import "./diffRefs.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+#import "./note_permissions.fragment.graphql"
+
+fragment DesignNote on Note {
+ id
+ author {
+ ...Author
+ }
+ body
+ bodyHtml
+ createdAt
+ position {
+ diffRefs {
+ ...DesignDiffRefs
+ }
+ x
+ y
+ height
+ width
+ }
+ userPermissions {
+ ...DesignNotePermissions
+ }
+ discussion {
+ id
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql
new file mode 100644
index 00000000000..984a55814b0
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/diffRefs.fragment.graphql
@@ -0,0 +1,5 @@
+fragment DesignDiffRefs on DiffRefs {
+ baseSha
+ startSha
+ headSha
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
new file mode 100644
index 00000000000..c243e39f3d3
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/note_permissions.fragment.graphql
@@ -0,0 +1,3 @@
+fragment DesignNotePermissions on NotePermissions {
+ adminNote
+}
diff --git a/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
new file mode 100644
index 00000000000..7eb40b12f51
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/fragments/version.fragment.graphql
@@ -0,0 +1,4 @@
+fragment VersionListItem on DesignVersion {
+ id
+ sha
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql
new file mode 100644
index 00000000000..9e2931b23f2
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/createImageDiffNote.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
+ createImageDiffNote(input: $input) {
+ note {
+ ...DesignNote
+ discussion {
+ id
+ replyId
+ notes {
+ edges {
+ node {
+ ...DesignNote
+ }
+ }
+ }
+ }
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql
new file mode 100644
index 00000000000..3ae478d658e
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/createNote.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation createNote($input: CreateNoteInput!) {
+ createNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql
new file mode 100644
index 00000000000..0b3cf636cdb
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/destroyDesign.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/version.fragment.graphql"
+
+mutation destroyDesign($filenames: [String!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementDelete(input: { projectPath: $projectPath, iid: $iid, filenames: $filenames }) {
+ version {
+ ...VersionListItem
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql
new file mode 100644
index 00000000000..cdb2264d233
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/updateImageDiffNote.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation updateImageDiffNote($input: UpdateImageDiffNoteInput!) {
+ updateImageDiffNote(input: $input) {
+ errors
+ note {
+ ...DesignNote
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
new file mode 100644
index 00000000000..343de4e3025
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_active_discussion.mutation.graphql
@@ -0,0 +1,3 @@
+mutation updateActiveDiscussion($id: String, $source: String) {
+ updateActiveDiscussion (id: $id, source: $source ) @client
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
new file mode 100644
index 00000000000..d96b2f3934a
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation updateNote($input: UpdateNoteInput!) {
+ updateNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql
new file mode 100644
index 00000000000..904acef599b
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/uploadDesign.mutation.graphql
@@ -0,0 +1,21 @@
+#import "../fragments/design.fragment.graphql"
+
+mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
+ designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
+ designs {
+ ...DesignItem
+ versions {
+ edges {
+ node {
+ id
+ sha
+ }
+ }
+ },
+ }
+ skippedDesigns {
+ filename
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql
new file mode 100644
index 00000000000..111023cea68
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/active_discussion.query.graphql
@@ -0,0 +1,6 @@
+query activeDiscussion {
+ activeDiscussion @client {
+ id
+ source
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql
new file mode 100644
index 00000000000..e1269761206
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/appData.query.graphql
@@ -0,0 +1,4 @@
+query projectFullPath {
+ projectPath @client
+ issueIid @client
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql
new file mode 100644
index 00000000000..a87b256dc95
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/design_permissions.query.graphql
@@ -0,0 +1,10 @@
+query permissions($fullPath: ID!, $iid: String!) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ userPermissions {
+ createDesign
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql
new file mode 100644
index 00000000000..07a9af55787
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/getDesign.query.graphql
@@ -0,0 +1,31 @@
+#import "../fragments/design.fragment.graphql"
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [String!]) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion, filenames: $filenames) {
+ edges {
+ node {
+ ...DesignItem
+ issue {
+ title
+ webPath
+ webUrl
+ participants {
+ edges {
+ node {
+ ...Author
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
new file mode 100644
index 00000000000..857f205ab07
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql
@@ -0,0 +1,26 @@
+#import "../fragments/designList.fragment.graphql"
+#import "../fragments/version.fragment.graphql"
+
+query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
+ project(fullPath: $fullPath) {
+ id
+ issue(iid: $iid) {
+ designCollection {
+ designs(atVersion: $atVersion) {
+ edges {
+ node {
+ ...DesignListItem
+ }
+ }
+ }
+ versions {
+ edges {
+ node {
+ ...VersionListItem
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/design_management/graphql/typedefs.graphql b/app/assets/javascripts/design_management/graphql/typedefs.graphql
new file mode 100644
index 00000000000..fdbad4a90e0
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/typedefs.graphql
@@ -0,0 +1,12 @@
+type ActiveDiscussion {
+ id: ID
+ source: String
+}
+
+extend type Query {
+ activeDiscussion: ActiveDiscussion
+}
+
+extend type Mutation {
+ updateActiveDiscussion(id: ID!, source: String!): Boolean
+}
diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js
new file mode 100644
index 00000000000..eb00e1742ea
--- /dev/null
+++ b/app/assets/javascripts/design_management/index.js
@@ -0,0 +1,58 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import createRouter from './router';
+import App from './components/app.vue';
+import apolloProvider from './graphql';
+import getDesignListQuery from './graphql/queries/get_design_list.query.graphql';
+import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants';
+
+export default () => {
+ const el = document.querySelector('.js-design-management');
+ const badge = document.querySelector('.js-designs-count');
+ const { issueIid, projectPath, issuePath } = el.dataset;
+ const router = createRouter(issuePath);
+
+ $('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => {
+ if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) {
+ router.push({ name: DESIGNS_ROUTE_NAME });
+ } else if (id === 'discussion') {
+ router.push({ name: ROOT_ROUTE_NAME });
+ }
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ projectPath,
+ issueIid,
+ activeDiscussion: {
+ __typename: 'ActiveDiscussion',
+ id: null,
+ source: null,
+ },
+ },
+ });
+
+ apolloProvider.clients.defaultClient
+ .watchQuery({
+ query: getDesignListQuery,
+ variables: {
+ fullPath: projectPath,
+ iid: issueIid,
+ atVersion: null,
+ },
+ })
+ .subscribe(({ data }) => {
+ if (badge) {
+ badge.textContent = data.project.issue.designCollection.designs.edges.length;
+ }
+ });
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ render(createElement) {
+ return createElement(App);
+ },
+ });
+};
diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js
new file mode 100644
index 00000000000..f7d6551c46c
--- /dev/null
+++ b/app/assets/javascripts/design_management/mixins/all_designs.js
@@ -0,0 +1,49 @@
+import { propertyOf } from 'lodash';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import { extractNodes } from '../utils/design_management_utils';
+import allVersionsMixin from './all_versions';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+export default {
+ mixins: [allVersionsMixin],
+ apollo: {
+ designs: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: this.designsVersion,
+ };
+ },
+ update: data => {
+ const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']);
+ if (designEdges) {
+ return extractNodes(designEdges);
+ }
+ return [];
+ },
+ error() {
+ this.error = true;
+ },
+ result() {
+ if (this.$route.query.version && !this.hasValidVersion) {
+ createFlash(
+ s__(
+ 'DesignManagement|Requested design version does not exist. Showing latest version instead',
+ ),
+ );
+ this.$router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: undefined } });
+ }
+ },
+ },
+ },
+ data() {
+ return {
+ designs: [],
+ error: false,
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js
new file mode 100644
index 00000000000..41c93064c26
--- /dev/null
+++ b/app/assets/javascripts/design_management/mixins/all_versions.js
@@ -0,0 +1,62 @@
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import appDataQuery from '../graphql/queries/appData.query.graphql';
+import { findVersionId } from '../utils/design_management_utils';
+
+export default {
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ allVersions: {
+ query: getDesignListQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ atVersion: null,
+ };
+ },
+ update: data => data.project.issue.designCollection.versions.edges,
+ },
+ },
+ computed: {
+ hasValidVersion() {
+ return (
+ this.$route.query.version &&
+ this.allVersions &&
+ this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version))
+ );
+ },
+ designsVersion() {
+ return this.hasValidVersion
+ ? `gid://gitlab/DesignManagement::Version/${this.$route.query.version}`
+ : null;
+ },
+ latestVersionId() {
+ const latestVersion = this.allVersions[0];
+ return latestVersion && findVersionId(latestVersion.node.id);
+ },
+ isLatestVersion() {
+ if (this.allVersions.length > 0) {
+ return (
+ !this.$route.query.version ||
+ !this.latestVersionId ||
+ this.$route.query.version === this.latestVersionId
+ );
+ }
+ return true;
+ },
+ },
+ data() {
+ return {
+ allVersions: [],
+ projectPath: '',
+ issueIid: null,
+ };
+ },
+};
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
new file mode 100644
index 00000000000..7ff3271394d
--- /dev/null
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -0,0 +1,400 @@
+<script>
+import { ApolloMutation } from 'vue-apollo';
+import Mousetrap from 'mousetrap';
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { fetchPolicies } from '~/lib/graphql';
+import allVersionsMixin from '../../mixins/all_versions';
+import Toolbar from '../../components/toolbar/index.vue';
+import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
+import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
+import DesignDestroyer from '../../components/design_destroyer.vue';
+import DesignScaler from '../../components/design_scaler.vue';
+import Participants from '~/sidebar/components/participants/participants.vue';
+import DesignPresentation from '../../components/design_presentation.vue';
+import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
+import appDataQuery from '../../graphql/queries/appData.query.graphql';
+import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
+import updateImageDiffNoteMutation from '../../graphql/mutations/updateImageDiffNote.mutation.graphql';
+import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql';
+import {
+ extractDiscussions,
+ extractDesign,
+ extractParticipants,
+ updateImageDiffNoteOptimisticResponse,
+} from '../../utils/design_management_utils';
+import {
+ updateStoreAfterAddImageDiffNote,
+ updateStoreAfterUpdateImageDiffNote,
+} from '../../utils/cache_update';
+import {
+ ADD_DISCUSSION_COMMENT_ERROR,
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ DESIGN_NOT_FOUND_ERROR,
+ DESIGN_VERSION_NOT_EXIST_ERROR,
+ UPDATE_NOTE_ERROR,
+ designDeletionError,
+} from '../../utils/error_messages';
+import { trackDesignDetailView } from '../../utils/tracking';
+import { DESIGNS_ROUTE_NAME } from '../../router/constants';
+import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../../constants';
+
+export default {
+ components: {
+ ApolloMutation,
+ DesignPresentation,
+ DesignDiscussion,
+ DesignScaler,
+ DesignDestroyer,
+ Toolbar,
+ DesignReplyForm,
+ GlLoadingIcon,
+ GlAlert,
+ Participants,
+ },
+ mixins: [allVersionsMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ design: {},
+ comment: '',
+ annotationCoordinates: null,
+ projectPath: '',
+ errorMessage: '',
+ issueIid: '',
+ scale: 1,
+ };
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ manual: true,
+ result({ data: { projectPath, issueIid } }) {
+ this.projectPath = projectPath;
+ this.issueIid = issueIid;
+ },
+ },
+ design: {
+ query: getDesignQuery,
+ // We want to see cached design version if we have one, and fetch newer version on the background to update discussions
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ variables() {
+ return this.designVariables;
+ },
+ update: data => extractDesign(data),
+ result(res) {
+ this.onDesignQueryResult(res);
+ },
+ error() {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ },
+ },
+ },
+ computed: {
+ isFirstLoading() {
+ // We only want to show spinner on initial design load (when opened from a deep link to design)
+ // If we already have cached a design, loading shouldn't be indicated to user
+ return this.$apollo.queries.design.loading && !this.design.filename;
+ },
+ discussions() {
+ return extractDiscussions(this.design.discussions);
+ },
+ discussionParticipants() {
+ return extractParticipants(this.design.issue.participants);
+ },
+ markdownPreviewPath() {
+ return `/${this.projectPath}/preview_markdown?target_type=Issue`;
+ },
+ isSubmitButtonDisabled() {
+ return this.comment.trim().length === 0;
+ },
+ renderDiscussions() {
+ return this.discussions.length || this.annotationCoordinates;
+ },
+ designVariables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ filenames: [this.$route.params.id],
+ atVersion: this.designsVersion,
+ };
+ },
+ mutationPayload() {
+ const { x, y, width, height } = this.annotationCoordinates;
+ return {
+ noteableId: this.design.id,
+ body: this.comment,
+ position: {
+ headSha: this.design.diffRefs.headSha,
+ baseSha: this.design.diffRefs.baseSha,
+ startSha: this.design.diffRefs.startSha,
+ x,
+ y,
+ width,
+ height,
+ paths: {
+ newPath: this.design.fullPath,
+ },
+ },
+ };
+ },
+ issue() {
+ return {
+ ...this.design.issue,
+ webPath: this.design.issue.webPath.substr(1),
+ };
+ },
+ isAnnotating() {
+ return Boolean(this.annotationCoordinates);
+ },
+ },
+ mounted() {
+ Mousetrap.bind('esc', this.closeDesign);
+ },
+ beforeDestroy() {
+ Mousetrap.unbind('esc', this.closeDesign);
+ },
+ methods: {
+ addImageDiffNoteToStore(
+ store,
+ {
+ data: { createImageDiffNote },
+ },
+ ) {
+ updateStoreAfterAddImageDiffNote(
+ store,
+ createImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ updateImageDiffNoteInStore(
+ store,
+ {
+ data: { updateImageDiffNote },
+ },
+ ) {
+ return updateStoreAfterUpdateImageDiffNote(
+ store,
+ updateImageDiffNote,
+ getDesignQuery,
+ this.designVariables,
+ );
+ },
+ onMoveNote({ noteId, discussionId, position }) {
+ const discussion = this.discussions.find(({ id }) => id === discussionId);
+ const note = discussion.notes.find(
+ ({ discussion: noteDiscussion }) => noteDiscussion.id === discussionId,
+ );
+
+ const mutationPayload = {
+ optimisticResponse: updateImageDiffNoteOptimisticResponse(note, {
+ position,
+ }),
+ variables: {
+ input: {
+ id: noteId,
+ position,
+ },
+ },
+ mutation: updateImageDiffNoteMutation,
+ update: this.updateImageDiffNoteInStore,
+ };
+
+ return this.$apollo.mutate(mutationPayload).catch(e => this.onUpdateImageDiffNoteError(e));
+ },
+ onDesignQueryResult({ data, loading }) {
+ // On the initial load with cache-and-network policy data is undefined while loading is true
+ // To prevent throwing an error, we don't perform any logic until loading is false
+ if (loading) {
+ return;
+ }
+
+ if (!data || !extractDesign(data)) {
+ this.onQueryError(DESIGN_NOT_FOUND_ERROR);
+ } else if (this.$route.query.version && !this.hasValidVersion) {
+ this.onQueryError(DESIGN_VERSION_NOT_EXIST_ERROR);
+ }
+ },
+ onQueryError(message) {
+ // because we redirect user to /designs (the issue page),
+ // we want to create these flashes on the issue page
+ createFlash(message);
+ this.$router.push({ name: this.$options.DESIGNS_ROUTE_NAME });
+ },
+ onError(message, e) {
+ this.errorMessage = message;
+ throw e;
+ },
+ onCreateImageDiffNoteError(e) {
+ this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onUpdateNoteError(e) {
+ this.onError(UPDATE_NOTE_ERROR, e);
+ },
+ onDesignDiscussionError(e) {
+ this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
+ },
+ onUpdateImageDiffNoteError(e) {
+ this.onError(UPDATE_IMAGE_DIFF_NOTE_ERROR, e);
+ },
+ onDesignDeleteError(e) {
+ this.onError(designDeletionError({ singular: true }), e);
+ },
+ openCommentForm(annotationCoordinates) {
+ this.annotationCoordinates = annotationCoordinates;
+ },
+ closeCommentForm() {
+ this.comment = '';
+ this.annotationCoordinates = null;
+ },
+ closeDesign() {
+ this.$router.push({
+ name: this.$options.DESIGNS_ROUTE_NAME,
+ query: this.$route.query,
+ });
+ },
+ trackEvent() {
+ // TODO: This needs to be made aware of referers, or if it's rendered in a different context than a Issue
+ trackDesignDetailView(
+ 'issue-design-collection',
+ 'issue',
+ this.$route.query.version || this.latestVersionId,
+ this.isLatestVersion,
+ );
+ },
+ updateActiveDiscussion(id) {
+ this.$apollo.mutate({
+ mutation: updateActiveDiscussionMutation,
+ variables: {
+ id,
+ source: ACTIVE_DISCUSSION_SOURCE_TYPES.discussion,
+ },
+ });
+ },
+ },
+ beforeRouteEnter(to, from, next) {
+ next(vm => {
+ vm.trackEvent();
+ });
+ },
+ beforeRouteUpdate(to, from, next) {
+ this.trackEvent();
+ this.closeCommentForm();
+ // We need to reset the active discussion when opening a new design
+ this.updateActiveDiscussion();
+ next();
+ },
+ beforeRouteLeave(to, from, next) {
+ // We need to reset the active discussion when moving to design list view
+ this.updateActiveDiscussion();
+ next();
+ },
+ createImageDiffNoteMutation,
+ DESIGNS_ROUTE_NAME,
+};
+</script>
+
+<template>
+ <div
+ class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
+ >
+ <gl-loading-icon v-if="isFirstLoading" size="xl" class="align-self-center" />
+ <template v-else>
+ <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
+ <design-destroyer
+ :filenames="[design.filename]"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="$router.push({ name: $options.DESIGNS_ROUTE_NAME })"
+ @error="onDesignDeleteError"
+ >
+ <template #default="{ mutate, loading }">
+ <toolbar
+ :id="id"
+ :is-deleting="loading"
+ :is-latest-version="isLatestVersion"
+ v-bind="design"
+ @delete="mutate"
+ />
+ </template>
+ </design-destroyer>
+
+ <div v-if="errorMessage" class="p-3">
+ <gl-alert variant="danger" @dismiss="errorMessage = null">
+ {{ errorMessage }}
+ </gl-alert>
+ </div>
+ <design-presentation
+ :image="design.image"
+ :image-name="design.filename"
+ :discussions="discussions"
+ :is-annotating="isAnnotating"
+ :scale="scale"
+ @openCommentForm="openCommentForm"
+ @closeCommentForm="closeCommentForm"
+ @moveNote="onMoveNote"
+ />
+
+ <div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
+ <design-scaler @scale="scale = $event" />
+ </div>
+ </div>
+ <div class="image-notes" @click="updateActiveDiscussion()">
+ <h2 class="gl-font-size-20-deprecated-no-really-do-not-use-me font-weight-bold mt-0">
+ {{ issue.title }}
+ </h2>
+ <a class="text-tertiary text-decoration-none mb-3 d-block" :href="issue.webUrl">{{
+ issue.webPath
+ }}</a>
+ <participants
+ :participants="discussionParticipants"
+ :show-participant-label="false"
+ class="mb-4"
+ />
+ <template v-if="renderDiscussions">
+ <design-discussion
+ v-for="(discussion, index) in discussions"
+ :key="discussion.id"
+ :discussion="discussion"
+ :design-id="id"
+ :noteable-id="design.id"
+ :discussion-index="index + 1"
+ :markdown-preview-path="markdownPreviewPath"
+ @error="onDesignDiscussionError"
+ @updateNoteError="onUpdateNoteError"
+ @click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
+ />
+ <apollo-mutation
+ v-if="annotationCoordinates"
+ #default="{ mutate, loading }"
+ :mutation="$options.createImageDiffNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ :update="addImageDiffNoteToStore"
+ @done="closeCommentForm"
+ @error="onCreateImageDiffNoteError"
+ >
+ <design-reply-form
+ v-model="comment"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ @submitForm="mutate"
+ @cancelForm="closeCommentForm"
+ />
+ </apollo-mutation>
+ </template>
+ <h2 v-else class="new-discussion-disclaimer gl-font-base m-0">
+ {{ __("Click the image where you'd like to start a new discussion") }}
+ </h2>
+ </div>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue
new file mode 100644
index 00000000000..7d419bc3ded
--- /dev/null
+++ b/app/assets/javascripts/design_management/pages/index.vue
@@ -0,0 +1,323 @@
+<script>
+import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import UploadButton from '../components/upload/button.vue';
+import DeleteButton from '../components/delete_button.vue';
+import Design from '../components/list/item.vue';
+import DesignDestroyer from '../components/design_destroyer.vue';
+import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue';
+import DesignDropzone from '../components/upload/design_dropzone.vue';
+import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
+import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
+import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
+import allDesignsMixin from '../mixins/all_designs';
+import {
+ UPLOAD_DESIGN_ERROR,
+ EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
+ EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
+ designUploadSkippedWarning,
+ designDeletionError,
+} from '../utils/error_messages';
+import { updateStoreAfterUploadDesign } from '../utils/cache_update';
+import {
+ designUploadOptimisticResponse,
+ isValidDesignFile,
+} from '../utils/design_management_utils';
+import { getFilename } from '~/lib/utils/file_upload';
+import { DESIGNS_ROUTE_NAME } from '../router/constants';
+
+const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
+
+export default {
+ components: {
+ GlLoadingIcon,
+ GlAlert,
+ GlDeprecatedButton,
+ UploadButton,
+ Design,
+ DesignDestroyer,
+ DesignVersionDropdown,
+ DeleteButton,
+ DesignDropzone,
+ },
+ mixins: [allDesignsMixin],
+ apollo: {
+ permissions: {
+ query: permissionsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ iid: this.issueIid,
+ };
+ },
+ update: data => data.project.issue.userPermissions,
+ },
+ },
+ data() {
+ return {
+ permissions: {
+ createDesign: false,
+ },
+ filesToBeSaved: [],
+ selectedDesigns: [],
+ };
+ },
+ computed: {
+ isLoading() {
+ return this.$apollo.queries.designs.loading || this.$apollo.queries.permissions.loading;
+ },
+ isSaving() {
+ return this.filesToBeSaved.length > 0;
+ },
+ canCreateDesign() {
+ return this.permissions.createDesign;
+ },
+ showToolbar() {
+ return this.canCreateDesign && this.allVersions.length > 0;
+ },
+ hasDesigns() {
+ return this.designs.length > 0;
+ },
+ hasSelectedDesigns() {
+ return this.selectedDesigns.length > 0;
+ },
+ canDeleteDesigns() {
+ return this.isLatestVersion && this.hasSelectedDesigns;
+ },
+ projectQueryBody() {
+ return {
+ query: getDesignListQuery,
+ variables: { fullPath: this.projectPath, iid: this.issueIid, atVersion: null },
+ };
+ },
+ selectAllButtonText() {
+ return this.hasSelectedDesigns
+ ? s__('DesignManagement|Deselect all')
+ : s__('DesignManagement|Select all');
+ },
+ },
+ mounted() {
+ this.toggleOnPasteListener(this.$route.name);
+ },
+ methods: {
+ resetFilesToBeSaved() {
+ this.filesToBeSaved = [];
+ },
+ /**
+ * Determine if a design upload is valid, given [files]
+ * @param {Array<File>} files
+ */
+ isValidDesignUpload(files) {
+ if (!this.canCreateDesign) return false;
+
+ if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
+ createFlash(
+ sprintf(
+ s__(
+ 'DesignManagement|The maximum number of designs allowed to be uploaded is %{upload_limit}. Please try again.',
+ ),
+ {
+ upload_limit: MAXIMUM_FILE_UPLOAD_LIMIT,
+ },
+ ),
+ );
+
+ return false;
+ }
+ return true;
+ },
+ onUploadDesign(files) {
+ // convert to Array so that we have Array methods (.map, .some, etc.)
+ this.filesToBeSaved = Array.from(files);
+ if (!this.isValidDesignUpload(this.filesToBeSaved)) return null;
+
+ const mutationPayload = {
+ optimisticResponse: designUploadOptimisticResponse(this.filesToBeSaved),
+ variables: {
+ files: this.filesToBeSaved,
+ projectPath: this.projectPath,
+ iid: this.issueIid,
+ },
+ context: {
+ hasUpload: true,
+ },
+ mutation: uploadDesignMutation,
+ update: this.afterUploadDesign,
+ };
+
+ return this.$apollo
+ .mutate(mutationPayload)
+ .then(res => this.onUploadDesignDone(res))
+ .catch(() => this.onUploadDesignError());
+ },
+ afterUploadDesign(
+ store,
+ {
+ data: { designManagementUpload },
+ },
+ ) {
+ updateStoreAfterUploadDesign(store, designManagementUpload, this.projectQueryBody);
+ },
+ onUploadDesignDone(res) {
+ const skippedFiles = res?.data?.designManagementUpload?.skippedDesigns || [];
+ const skippedWarningMessage = designUploadSkippedWarning(this.filesToBeSaved, skippedFiles);
+ if (skippedWarningMessage) {
+ createFlash(skippedWarningMessage, 'warning');
+ }
+
+ // if this upload resulted in a new version being created, redirect user to the latest version
+ if (!this.isLatestVersion) {
+ this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ }
+ this.resetFilesToBeSaved();
+ },
+ onUploadDesignError() {
+ this.resetFilesToBeSaved();
+ createFlash(UPLOAD_DESIGN_ERROR);
+ },
+ changeSelectedDesigns(filename) {
+ if (this.isDesignSelected(filename)) {
+ this.selectedDesigns = this.selectedDesigns.filter(design => design !== filename);
+ } else {
+ this.selectedDesigns.push(filename);
+ }
+ },
+ toggleDesignsSelection() {
+ if (this.hasSelectedDesigns) {
+ this.selectedDesigns = [];
+ } else {
+ this.selectedDesigns = this.designs.map(design => design.filename);
+ }
+ },
+ isDesignSelected(filename) {
+ return this.selectedDesigns.includes(filename);
+ },
+ isDesignToBeSaved(filename) {
+ return this.filesToBeSaved.some(file => file.name === filename);
+ },
+ canSelectDesign(filename) {
+ return this.isLatestVersion && this.canCreateDesign && !this.isDesignToBeSaved(filename);
+ },
+ onDesignDelete() {
+ this.selectedDesigns = [];
+ if (this.$route.query.version) this.$router.push({ name: DESIGNS_ROUTE_NAME });
+ },
+ onDesignDeleteError() {
+ const errorMessage = designDeletionError({ singular: this.selectedDesigns.length === 1 });
+ createFlash(errorMessage);
+ },
+ onExistingDesignDropzoneChange(files, existingDesignFilename) {
+ const filesArr = Array.from(files);
+
+ if (filesArr.length > 1) {
+ createFlash(EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE);
+ return;
+ }
+
+ if (!filesArr.some(({ name }) => existingDesignFilename === name)) {
+ createFlash(EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE);
+ return;
+ }
+
+ this.onUploadDesign(files);
+ },
+ onDesignPaste(event) {
+ const { clipboardData } = event;
+ const files = Array.from(clipboardData.files);
+ if (clipboardData && files.length > 0) {
+ if (!files.some(isValidDesignFile)) {
+ return;
+ }
+ event.preventDefault();
+ let filename = getFilename(event);
+ if (!filename || filename === 'image.png') {
+ filename = `design_${Date.now()}.png`;
+ }
+ const newFile = new File([files[0]], filename);
+ this.onUploadDesign([newFile]);
+ }
+ },
+ toggleOnPasteListener(route) {
+ if (route === DESIGNS_ROUTE_NAME) {
+ document.addEventListener('paste', this.onDesignPaste);
+ } else {
+ document.removeEventListener('paste', this.onDesignPaste);
+ }
+ },
+ },
+ beforeRouteUpdate(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ this.selectedDesigns = [];
+ next();
+ },
+ beforeRouteLeave(to, from, next) {
+ this.toggleOnPasteListener(to.name);
+ next();
+ },
+};
+</script>
+
+<template>
+ <div>
+ <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex">
+ <div class="d-flex justify-content-between align-items-center w-100">
+ <design-version-dropdown />
+ <div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]">
+ <gl-deprecated-button
+ v-if="isLatestVersion"
+ variant="link"
+ class="mr-2 js-select-all"
+ @click="toggleDesignsSelection"
+ >{{ selectAllButtonText }}</gl-deprecated-button
+ >
+ <design-destroyer
+ #default="{ mutate, loading }"
+ :filenames="selectedDesigns"
+ :project-path="projectPath"
+ :iid="issueIid"
+ @done="onDesignDelete"
+ @error="onDesignDeleteError"
+ >
+ <delete-button
+ v-if="isLatestVersion"
+ :is-deleting="loading"
+ button-class="btn-danger btn-inverted mr-2"
+ :has-selected-designs="hasSelectedDesigns"
+ @deleteSelectedDesigns="mutate()"
+ >
+ {{ s__('DesignManagement|Delete selected') }}
+ <gl-loading-icon v-if="loading" inline class="ml-1" />
+ </delete-button>
+ </design-destroyer>
+ <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" />
+ </div>
+ </div>
+ </header>
+ <div class="mt-4">
+ <gl-loading-icon v-if="isLoading" size="md" />
+ <gl-alert v-else-if="error" variant="danger" :dismissible="false">
+ {{ __('An error occurred while loading designs. Please try again.') }}
+ </gl-alert>
+ <ol v-else class="list-unstyled row">
+ <li class="col-md-6 col-lg-4 mb-3">
+ <design-dropzone class="design-list-item" @change="onUploadDesign" />
+ </li>
+ <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3">
+ <design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)"
+ ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)"
+ /></design-dropzone>
+
+ <input
+ v-if="canSelectDesign(design.filename)"
+ :checked="isDesignSelected(design.filename)"
+ type="checkbox"
+ class="design-checkbox"
+ @change="changeSelectedDesigns(design.filename)"
+ />
+ </li>
+ </ol>
+ </div>
+ <router-view />
+ </div>
+</template>
diff --git a/app/assets/javascripts/design_management/router/constants.js b/app/assets/javascripts/design_management/router/constants.js
new file mode 100644
index 00000000000..abeef520e33
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/constants.js
@@ -0,0 +1,3 @@
+export const ROOT_ROUTE_NAME = 'root';
+export const DESIGNS_ROUTE_NAME = 'designs';
+export const DESIGN_ROUTE_NAME = 'design';
diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js
new file mode 100644
index 00000000000..7dc92f55d47
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/index.js
@@ -0,0 +1,22 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import routes from './routes';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes,
+ });
+
+ router.beforeEach(({ meta: { el } }, from, next) => {
+ $(`#${el}`).tab('show');
+
+ next();
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/design_management/router/routes.js b/app/assets/javascripts/design_management/router/routes.js
new file mode 100644
index 00000000000..788910e5514
--- /dev/null
+++ b/app/assets/javascripts/design_management/router/routes.js
@@ -0,0 +1,44 @@
+import Home from '../pages/index.vue';
+import DesignDetail from '../pages/design/index.vue';
+import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants';
+
+export default [
+ {
+ name: ROOT_ROUTE_NAME,
+ path: '/',
+ component: Home,
+ meta: {
+ el: 'discussion',
+ },
+ },
+ {
+ name: DESIGNS_ROUTE_NAME,
+ path: '/designs',
+ component: Home,
+ meta: {
+ el: 'designs',
+ },
+ children: [
+ {
+ name: DESIGN_ROUTE_NAME,
+ path: ':id',
+ component: DesignDetail,
+ meta: {
+ el: 'designs',
+ },
+ beforeEnter(
+ {
+ params: { id },
+ },
+ from,
+ next,
+ ) {
+ if (typeof id === 'string') {
+ next();
+ }
+ },
+ props: ({ params: { id } }) => ({ id }),
+ },
+ ],
+ },
+];
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
new file mode 100644
index 00000000000..01c073bddc2
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -0,0 +1,272 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+import createFlash from '~/flash';
+import { extractCurrentDiscussion, extractDesign } from './design_management_utils';
+import {
+ ADD_IMAGE_DIFF_NOTE_ERROR,
+ UPDATE_IMAGE_DIFF_NOTE_ERROR,
+ ADD_DISCUSSION_COMMENT_ERROR,
+ designDeletionError,
+} from './error_messages';
+
+const deleteDesignsFromStore = (store, query, selectedDesigns) => {
+ const data = store.readQuery(query);
+
+ const changedDesigns = data.project.issue.designCollection.designs.edges.filter(
+ ({ node }) => !selectedDesigns.includes(node.filename),
+ );
+ data.project.issue.designCollection.designs.edges = [...changedDesigns];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+/**
+ * Adds a new version of designs to store
+ *
+ * @param {Object} store
+ * @param {Object} query
+ * @param {Object} version
+ */
+const addNewVersionToStore = (store, query, version) => {
+ if (!version) return;
+
+ const data = store.readQuery(query);
+ const newEdge = { node: version, __typename: 'DesignVersionEdge' };
+
+ data.project.issue.designCollection.versions.edges = [
+ newEdge,
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const addDiscussionCommentToStore = (store, createNote, query, queryVariables, discussionId) => {
+ const data = store.readQuery({
+ query,
+ variables: queryVariables,
+ });
+
+ const design = extractDesign(data);
+ const currentDiscussion = extractCurrentDiscussion(design.discussions, discussionId);
+ currentDiscussion.notes.nodes = [...currentDiscussion.notes.nodes, createNote.note];
+
+ design.notesCount += 1;
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables: queryVariables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ },
+ },
+ });
+};
+
+const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+ const newDiscussion = {
+ __typename: 'Discussion',
+ id: createImageDiffNote.note.discussion.id,
+ replyId: createImageDiffNote.note.discussion.replyId,
+ notes: {
+ __typename: 'NoteConnection',
+ nodes: [createImageDiffNote.note],
+ },
+ };
+ const design = extractDesign(data);
+ const notesCount = design.notesCount + 1;
+ design.discussions.nodes = [...design.discussions.nodes, newDiscussion];
+ if (
+ !design.issue.participants.edges.some(
+ participant => participant.node.username === createImageDiffNote.note.author.username,
+ )
+ ) {
+ design.issue.participants.edges = [
+ ...design.issue.participants.edges,
+ {
+ __typename: 'UserEdge',
+ node: {
+ __typename: 'User',
+ ...createImageDiffNote.note.author,
+ },
+ },
+ ];
+ }
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design: {
+ ...design,
+ notesCount,
+ },
+ },
+ });
+};
+
+const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables) => {
+ const data = store.readQuery({
+ query,
+ variables,
+ });
+
+ const design = extractDesign(data);
+ const discussion = extractCurrentDiscussion(
+ design.discussions,
+ updateImageDiffNote.note.discussion.id,
+ );
+
+ discussion.notes = {
+ ...discussion.notes,
+ nodes: [updateImageDiffNote.note, ...discussion.notes.nodes.slice(1)],
+ };
+
+ store.writeQuery({
+ query,
+ variables,
+ data: {
+ ...data,
+ design,
+ },
+ });
+};
+
+const addNewDesignToStore = (store, designManagementUpload, query) => {
+ const data = store.readQuery(query);
+
+ const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => {
+ if (!acc.find(d => d.filename === design.node.filename)) {
+ acc.push(design.node);
+ }
+
+ return acc;
+ }, designManagementUpload.designs);
+
+ let newVersionNode;
+ const findNewVersions = designManagementUpload.designs.find(design => design.versions);
+
+ if (findNewVersions) {
+ const findNewVersionsEdges = findNewVersions.versions.edges;
+
+ if (findNewVersionsEdges && findNewVersionsEdges.length) {
+ newVersionNode = [findNewVersionsEdges[0]];
+ }
+ }
+
+ const newVersions = [
+ ...(newVersionNode || []),
+ ...data.project.issue.designCollection.versions.edges,
+ ];
+
+ const updatedDesigns = {
+ __typename: 'DesignCollection',
+ designs: {
+ __typename: 'DesignConnection',
+ edges: newDesigns.map(design => ({
+ __typename: 'DesignEdge',
+ node: design,
+ })),
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: newVersions,
+ },
+ };
+
+ data.project.issue.designCollection = updatedDesigns;
+
+ store.writeQuery({
+ ...query,
+ data,
+ });
+};
+
+const onError = (data, message) => {
+ createFlash(message);
+ throw new Error(data.errors);
+};
+
+export const hasErrors = ({ errors = [] }) => errors?.length;
+
+/**
+ * Updates a store after design deletion
+ *
+ * @param {Object} store
+ * @param {Object} data
+ * @param {Object} query
+ * @param {Array} designs
+ */
+export const updateStoreAfterDesignsDelete = (store, data, query, designs) => {
+ if (hasErrors(data)) {
+ onError(data, designDeletionError({ singular: designs.length === 1 }));
+ } else {
+ deleteDesignsFromStore(store, query, designs);
+ addNewVersionToStore(store, query, data.version);
+ }
+};
+
+export const updateStoreAfterAddDiscussionComment = (
+ store,
+ data,
+ query,
+ queryVariables,
+ discussionId,
+) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_DISCUSSION_COMMENT_ERROR);
+ } else {
+ addDiscussionCommentToStore(store, data, query, queryVariables, discussionId);
+ }
+};
+
+export const updateStoreAfterAddImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, ADD_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ addImageDiffNoteToStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUpdateImageDiffNote = (store, data, query, queryVariables) => {
+ if (hasErrors(data)) {
+ onError(data, UPDATE_IMAGE_DIFF_NOTE_ERROR);
+ } else {
+ updateImageDiffNoteInStore(store, data, query, queryVariables);
+ }
+};
+
+export const updateStoreAfterUploadDesign = (store, data, query) => {
+ if (hasErrors(data)) {
+ onError(data, data.errors[0]);
+ } else {
+ addNewDesignToStore(store, data, query);
+ }
+};
diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js
new file mode 100644
index 00000000000..e6d8796ffa4
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/design_management_utils.js
@@ -0,0 +1,125 @@
+import { uniqueId } from 'lodash';
+import { VALID_DESIGN_FILE_MIMETYPE } from '../constants';
+
+export const isValidDesignFile = ({ type }) =>
+ (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0;
+
+/**
+ * Returns formatted array that doesn't contain
+ * `edges`->`node` nesting
+ *
+ * @param {Array} elements
+ */
+
+export const extractNodes = elements => elements.edges.map(({ node }) => node);
+
+/**
+ * Returns formatted array of discussions that doesn't contain
+ * `edges`->`node` nesting for child notes
+ *
+ * @param {Array} discussions
+ */
+
+export const extractDiscussions = discussions =>
+ discussions.nodes.map(discussion => ({
+ ...discussion,
+ notes: discussion.notes.nodes,
+ }));
+
+/**
+ * Returns a discussion with the given id from discussions array
+ *
+ * @param {Array} discussions
+ */
+
+export const extractCurrentDiscussion = (discussions, id) =>
+ discussions.nodes.find(discussion => discussion.id === id);
+
+export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1];
+
+export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1];
+
+export const extractDesigns = data => data.project.issue.designCollection.designs.edges;
+
+export const extractDesign = data => (extractDesigns(data) || [])[0]?.node;
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const designUploadOptimisticResponse = files => {
+ const designs = files.map(file => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Design',
+ id: -uniqueId(),
+ image: '',
+ imageV432x230: '',
+ filename: file.name,
+ fullPath: '',
+ notesCount: 0,
+ event: 'NONE',
+ diffRefs: {
+ __typename: 'DiffRefs',
+ baseSha: '',
+ startSha: '',
+ headSha: '',
+ },
+ discussions: {
+ __typename: 'DesignDiscussion',
+ nodes: [],
+ },
+ versions: {
+ __typename: 'DesignVersionConnection',
+ edges: {
+ __typename: 'DesignVersionEdge',
+ node: {
+ __typename: 'DesignVersion',
+ id: -uniqueId(),
+ sha: -uniqueId(),
+ },
+ },
+ },
+ }));
+
+ return {
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ designManagementUpload: {
+ __typename: 'DesignManagementUploadPayload',
+ designs,
+ skippedDesigns: [],
+ errors: [],
+ },
+ };
+};
+
+/**
+ * Generates optimistic response for a design upload mutation
+ * @param {Array<File>} files
+ */
+export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({
+ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'Mutation',
+ updateImageDiffNote: {
+ __typename: 'UpdateImageDiffNotePayload',
+ note: {
+ ...note,
+ position: {
+ ...note.position,
+ ...position,
+ },
+ },
+ errors: [],
+ },
+});
+
+const normalizeAuthor = author => ({
+ ...author,
+ web_url: author.webUrl,
+ avatar_url: author.avatarUrl,
+});
+
+export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node));
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
new file mode 100644
index 00000000000..7666c726c2f
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -0,0 +1,95 @@
+import { __, s__, n__, sprintf } from '~/locale';
+
+export const ADD_DISCUSSION_COMMENT_ERROR = s__(
+ 'DesignManagement|Could not add a new comment. Please try again.',
+);
+
+export const ADD_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not create new discussion. Please try again.',
+);
+
+export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
+ 'DesignManagement|Could not update discussion. Please try again.',
+);
+
+export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+
+export const UPLOAD_DESIGN_ERROR = s__(
+ 'DesignManagement|Error uploading a new design. Please try again.',
+);
+
+export const UPLOAD_DESIGN_INVALID_FILETYPE_ERROR = __(
+ 'Could not upload your designs as one or more files uploaded are not supported.',
+);
+
+export const DESIGN_NOT_FOUND_ERROR = __('Could not find design.');
+
+export const DESIGN_VERSION_NOT_EXIST_ERROR = __('Requested design version does not exist.');
+
+const DESIGN_UPLOAD_SKIPPED_MESSAGE = s__('DesignManagement|Upload skipped.');
+
+const ALL_DESIGNS_SKIPPED_MESSAGE = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'The designs you tried uploading did not change.',
+)}`;
+
+export const EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE = __(
+ 'You can only upload one design when dropping onto an existing design.',
+);
+
+export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __(
+ 'You must upload a file with the same file name when dropping onto an existing design.',
+);
+
+const MAX_SKIPPED_FILES_LISTINGS = 5;
+
+const oneDesignSkippedMessage = filename =>
+ `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${sprintf(s__('DesignManagement|%{filename} did not change.'), {
+ filename,
+ })}`;
+
+/**
+ * Return warning message indicating that some (but not all) uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} skippedFiles
+ */
+const someDesignsSkippedMessage = skippedFiles => {
+ const designsSkippedMessage = `${DESIGN_UPLOAD_SKIPPED_MESSAGE} ${s__(
+ 'Some of the designs you tried uploading did not change:',
+ )}`;
+
+ const moreText = sprintf(s__(`DesignManagement|and %{moreCount} more.`), {
+ moreCount: skippedFiles.length - MAX_SKIPPED_FILES_LISTINGS,
+ });
+
+ return `${designsSkippedMessage} ${skippedFiles
+ .slice(0, MAX_SKIPPED_FILES_LISTINGS)
+ .map(({ filename }) => filename)
+ .join(', ')}${skippedFiles.length > MAX_SKIPPED_FILES_LISTINGS ? `, ${moreText}` : '.'}`;
+};
+
+export const designDeletionError = ({ singular = true } = {}) => {
+ const design = singular ? __('a design') : __('designs');
+ return sprintf(s__('Could not delete %{design}. Please try again.'), {
+ design,
+ });
+};
+
+/**
+ * Return warning message, if applicable, that one, some or all uploaded
+ * files were skipped.
+ * @param {Array<{ filename }>} uploadedDesigns
+ * @param {Array<{ filename }>} skippedFiles
+ */
+export const designUploadSkippedWarning = (uploadedDesigns, skippedFiles) => {
+ if (skippedFiles.length === 0) {
+ return null;
+ }
+
+ if (skippedFiles.length === uploadedDesigns.length) {
+ const { filename } = skippedFiles[0];
+
+ return n__(oneDesignSkippedMessage(filename), ALL_DESIGNS_SKIPPED_MESSAGE, skippedFiles.length);
+ }
+
+ return someDesignsSkippedMessage(skippedFiles);
+};
diff --git a/app/assets/javascripts/design_management/utils/tracking.js b/app/assets/javascripts/design_management/utils/tracking.js
new file mode 100644
index 00000000000..39c20376271
--- /dev/null
+++ b/app/assets/javascripts/design_management/utils/tracking.js
@@ -0,0 +1,28 @@
+import Tracking from '~/tracking';
+
+function assembleDesignPayload(payloadArr) {
+ return {
+ value: {
+ 'internal-object-refrerer': payloadArr[0],
+ 'design-collection-owner': payloadArr[1],
+ 'design-version-number': payloadArr[2],
+ 'design-is-current-version': payloadArr[3],
+ },
+ };
+}
+
+// Tracking Constants
+const DESIGN_TRACKING_PAGE_NAME = 'projects:issues:design';
+
+// eslint-disable-next-line import/prefer-default-export
+export function trackDesignDetailView(
+ referer = '',
+ owner = '',
+ designVersion = 1,
+ latestVersion = false,
+) {
+ Tracking.event(DESIGN_TRACKING_PAGE_NAME, 'design_viewed', {
+ label: 'design_viewed',
+ ...assembleDesignPayload([referer, owner, designVersion, latestVersion]),
+ });
+}
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 84e07598fed..dd60e2c7684 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -1,4 +1,3 @@
-/* eslint-disable no-else-return, no-lonely-if */
/* global CommentsStore */
import $ from 'jquery';
@@ -22,27 +21,19 @@ const CommentAndResolveBtn = Vue.extend({
showButton() {
if (this.discussion) {
return this.discussion.isResolvable();
- } else {
- return false;
}
+ return false;
},
isDiscussionResolved() {
return this.discussion.isResolved();
},
buttonText() {
- if (this.isDiscussionResolved) {
- if (this.textareaIsEmpty) {
- return __('Unresolve thread');
- } else {
- return __('Comment & unresolve thread');
- }
- } else {
- if (this.textareaIsEmpty) {
- return __('Resolve thread');
- } else {
- return __('Comment & resolve thread');
- }
+ if (this.textareaIsEmpty) {
+ return this.isDiscussionResolved ? __('Unresolve thread') : __('Resolve thread');
}
+ return this.isDiscussionResolved
+ ? __('Comment & unresolve thread')
+ : __('Comment & resolve thread');
},
},
created() {
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index fa5f8ea4005..0c521fa29bd 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */
+/* eslint-disable func-names, guard-for-in, no-restricted-syntax, no-lonely-if, no-continue */
/* global CommentsStore */
import $ from 'jquery';
@@ -25,9 +25,8 @@ const JumpToDiscussion = Vue.extend({
buttonText() {
if (this.discussionId) {
return __('Jump to next unresolved thread');
- } else {
- return __('Jump to first unresolved thread');
}
+ return __('Jump to first unresolved thread');
},
allResolved() {
return this.unresolvedDiscussionCount === 0;
@@ -36,12 +35,10 @@ const JumpToDiscussion = Vue.extend({
if (this.discussionId) {
if (this.unresolvedDiscussionCount > 1) {
return true;
- } else {
- return this.discussionId !== this.lastResolvedId;
}
- } else {
- return this.unresolvedDiscussionCount >= 1;
+ return this.discussionId !== this.lastResolvedId;
}
+ return this.unresolvedDiscussionCount >= 1;
},
lastResolvedId() {
let lastId;
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 072bcaaad97..941365d9d1d 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -7,6 +7,7 @@ import createFlash from '~/flash';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
+import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -140,6 +141,20 @@ export default {
},
},
watch: {
+ commit(newCommit, oldCommit) {
+ const commitChangedAfterRender = newCommit && !this.isLoading;
+ const commitIsDifferent = oldCommit && newCommit.id !== oldCommit.id;
+ const url = window?.location ? String(window.location) : '';
+
+ if (commitChangedAfterRender && commitIsDifferent) {
+ updateHistory({
+ title: document.title,
+ url: url.replace(oldCommit.id, newCommit.id),
+ });
+ this.refetchDiffData();
+ this.adjustView();
+ }
+ },
diffViewType() {
if (this.needsReload() || this.needsFirstLoad()) {
this.refetchDiffData();
@@ -209,6 +224,7 @@ export default {
methods: {
...mapActions(['startTaskList']),
...mapActions('diffs', [
+ 'moveToNeighboringCommit',
'setBaseConfig',
'fetchDiffFiles',
'fetchDiffFilesMeta',
@@ -329,9 +345,16 @@ export default {
break;
}
});
+
+ if (this.commit && this.glFeatures.mrCommitNeighborNav) {
+ Mousetrap.bind('c', () => this.moveToNeighboringCommit({ direction: 'next' }));
+ Mousetrap.bind('x', () => this.moveToNeighboringCommit({ direction: 'previous' }));
+ }
},
removeEventListeners() {
Mousetrap.unbind(['[', 'k', ']', 'j']);
+ Mousetrap.unbind('c');
+ Mousetrap.unbind('x');
},
jumpToFile(step) {
const targetIndex = this.currentDiffIndex + step;
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 9d4edd84f25..ee93ca020e8 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -1,10 +1,18 @@
<script>
+import { mapActions } from 'vuex';
+import { GlButtonGroup, GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+
import initUserPopovers from '../../user_popovers';
+import { setUrlParams } from '../../lib/utils/url_utility';
/**
* CommitItem
@@ -18,7 +26,16 @@ import initUserPopovers from '../../user_popovers';
* coexist, but there is an issue to remove the duplication.
* https://gitlab.com/gitlab-org/gitlab-foss/issues/51613
*
+ * EXCEPTION WARNING
+ * 1. The commit navigation buttons (next neighbor, previous neighbor)
+ * are not duplicated because:
+ * - We don't have the same data available on the Rails side (yet,
+ * without backend work)
+ * - This Vue component should always be what's used when in the
+ * context of an MR diff, so the HAML should never have any idea
+ * about navigating among commits.
*/
+
export default {
components: {
UserAvatarLink,
@@ -26,7 +43,14 @@ export default {
ClipboardButton,
TimeAgoTooltip,
CommitPipelineStatus,
+ GlButtonGroup,
+ GlButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
commit: {
type: Object,
@@ -54,12 +78,28 @@ export default {
authorAvatar() {
return this.author.avatar_url || this.commit.author_gravatar_url;
},
+ nextCommitUrl() {
+ return this.commit.next_commit_id
+ ? setUrlParams({ commit_id: this.commit.next_commit_id })
+ : '';
+ },
+ previousCommitUrl() {
+ return this.commit.prev_commit_id
+ ? setUrlParams({ commit_id: this.commit.prev_commit_id })
+ : '';
+ },
+ hasNeighborCommits() {
+ return this.commit.next_commit_id || this.commit.prev_commit_id;
+ },
},
created() {
this.$nextTick(() => {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
+ methods: {
+ ...mapActions('diffs', ['moveToNeighboringCommit']),
+ },
};
</script>
@@ -123,6 +163,41 @@ export default {
class="btn btn-default"
/>
</div>
+ <div
+ v-if="hasNeighborCommits && glFeatures.mrCommitNeighborNav"
+ class="commit-nav-buttons ml-3"
+ >
+ <gl-button-group>
+ <gl-button
+ :href="previousCommitUrl"
+ :disabled="!commit.prev_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'previous' })"
+ >
+ <span
+ v-if="!commit.prev_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the first commit')"
+ ></span>
+ <gl-icon name="chevron-left" />
+ {{ __('Prev') }}
+ </gl-button>
+ <gl-button
+ :href="nextCommitUrl"
+ :disabled="!commit.next_commit_id"
+ @click.prevent="moveToNeighboringCommit({ direction: 'next' })"
+ >
+ <span
+ v-if="!commit.next_commit_id"
+ v-gl-tooltip
+ class="h-100 w-100 position-absolute"
+ :title="__('You\'re at the last commit')"
+ ></span>
+ {{ __('Next') }}
+ <gl-icon name="chevron-right" />
+ </gl-button>
+ </gl-button-group>
+ </div>
</div>
</div>
</li>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index b0460bacff2..b6a0724c201 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -85,9 +85,11 @@ export default {
:help-page-path="helpPagePath"
@noteDeleted="deleteNoteHandler"
>
- <span v-if="renderAvatarBadge" slot="avatar-badge" class="badge badge-pill">
- {{ index + 1 }}
- </span>
+ <template v-if="renderAvatarBadge" #avatar-badge>
+ <span class="badge badge-pill">
+ {{ index + 1 }}
+ </span>
+ </template>
</noteable-discussion>
</ul>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 82ca3749ac1..54852b113ae 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
@@ -46,7 +46,7 @@ export default {
return sprintf(
__('You can %{linkStart}view the blob%{linkEnd} instead.'),
{
- linkStart: `<a href="${esc(this.file.view_path)}">`,
+ linkStart: `<a href="${escape(this.file.view_path)}">`,
linkEnd: '</a>',
},
false,
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index d601c3769a3..61bbf13aa53 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapActions, mapGetters } from 'vuex';
import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { polyfillSticky } from '~/lib/utils/sticky';
@@ -91,7 +91,7 @@ export default {
return this.expanded ? 'chevron-down' : 'chevron-right';
},
viewFileButtonText() {
- const truncatedContentSha = esc(truncateSha(this.diffFile.content_sha));
+ const truncatedContentSha = escape(truncateSha(this.diffFile.content_sha));
return sprintf(
s__('MergeRequests|View file @ %{commitId}'),
{ commitId: truncatedContentSha },
@@ -99,7 +99,7 @@ export default {
);
},
viewReplacedFileButtonText() {
- const truncatedBaseSha = esc(truncateSha(this.diffFile.diff_refs.base_sha));
+ const truncatedBaseSha = escape(truncateSha(this.diffFile.diff_refs.base_sha));
return sprintf(
s__('MergeRequests|View replaced file @ %{commitId}'),
{
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
index 91e296f8572..21fdb19287d 100644
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ b/app/assets/javascripts/diffs/components/edit_button.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -13,7 +14,8 @@ export default {
props: {
editPath: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
canCurrentUserFork: {
type: Boolean,
@@ -25,6 +27,18 @@ export default {
default: false,
},
},
+ computed: {
+ tooltipTitle() {
+ if (this.isDisabled) {
+ return __("Can't edit as source branch was deleted");
+ }
+
+ return __('Edit file');
+ },
+ isDisabled() {
+ return !this.editPath;
+ },
+ },
methods: {
handleEditClick(evt) {
if (this.canCurrentUserFork && !this.canModifyBlob) {
@@ -37,13 +51,15 @@ export default {
</script>
<template>
- <gl-deprecated-button
- v-gl-tooltip.top
- :href="editPath"
- :title="__('Edit file')"
- class="js-edit-blob"
- @click.native="handleEditClick"
- >
- <icon name="pencil" />
- </gl-deprecated-button>
+ <span v-gl-tooltip.top :title="tooltipTitle">
+ <gl-deprecated-button
+ :href="editPath"
+ :disabled="isDisabled"
+ :class="{ 'cursor-not-allowed': isDisabled }"
+ class="rounded-0 js-edit-blob"
+ @click.native="handleEditClick"
+ >
+ <icon name="pencil" />
+ </gl-deprecated-button>
+ </span>
</template>
diff --git a/app/assets/javascripts/diffs/components/no_changes.vue b/app/assets/javascripts/diffs/components/no_changes.vue
index 5fd68471094..94c2695a945 100644
--- a/app/assets/javascripts/diffs/components/no_changes.vue
+++ b/app/assets/javascripts/diffs/components/no_changes.vue
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlDeprecatedButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
@@ -24,8 +24,8 @@ export default {
{
ref_start: '<span class="ref-name">',
ref_end: '</span>',
- source_branch: esc(this.getNoteableData.source_branch),
- target_branch: esc(this.getNoteableData.target_branch),
+ source_branch: escape(this.getNoteableData.source_branch),
+ target_branch: escape(this.getNoteableData.target_branch),
},
false,
);
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 93c242e32ac..1975d6996a5 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -16,6 +16,7 @@ import {
idleCallback,
allDiscussionWrappersExpanded,
prepareDiffData,
+ prepareLineForRenamedFile,
} from './utils';
import * as types from './mutation_types';
import {
@@ -627,6 +628,42 @@ export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => {
}
};
+export function switchToFullDiffFromRenamedFile({ commit, dispatch, state }, { diffFile }) {
+ return axios
+ .get(diffFile.context_lines_path, {
+ params: {
+ full: true,
+ from_merge_request: true,
+ },
+ })
+ .then(({ data }) => {
+ const lines = data.map((line, index) =>
+ prepareLineForRenamedFile({
+ diffViewType: state.diffViewType,
+ line,
+ diffFile,
+ index,
+ }),
+ );
+
+ commit(types.SET_DIFF_FILE_VIEWER, {
+ filePath: diffFile.file_path,
+ viewer: {
+ ...diffFile.alternate_viewer,
+ collapsed: false,
+ },
+ });
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: diffFile.file_path, lines });
+
+ dispatch('startRenderDiffsQueue');
+ })
+ .catch(error => {
+ dispatch('receiveFullDiffError', diffFile.file_path);
+
+ throw error;
+ });
+}
+
export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
@@ -642,5 +679,48 @@ export const setSuggestPopoverDismissed = ({ commit, state }) =>
createFlash(s__('MergeRequest|Error dismissing suggestion popover. Please try again.'));
});
+export function changeCurrentCommit({ dispatch, commit, state }, { commitId }) {
+ /* eslint-disable @gitlab/require-i18n-strings */
+ if (!commitId) {
+ return Promise.reject(new Error('`commitId` is a required argument'));
+ } else if (!state.commit) {
+ return Promise.reject(new Error('`state` must already contain a valid `commit`'));
+ }
+ /* eslint-enable @gitlab/require-i18n-strings */
+
+ // this is less than ideal, see: https://gitlab.com/gitlab-org/gitlab/-/issues/215421
+ const commitRE = new RegExp(state.commit.id, 'g');
+
+ commit(types.SET_DIFF_FILES, []);
+ commit(types.SET_BASE_CONFIG, {
+ ...state,
+ endpoint: state.endpoint.replace(commitRE, commitId),
+ endpointBatch: state.endpointBatch.replace(commitRE, commitId),
+ endpointMetadata: state.endpointMetadata.replace(commitRE, commitId),
+ });
+
+ return dispatch('fetchDiffFilesMeta');
+}
+
+export function moveToNeighboringCommit({ dispatch, state }, { direction }) {
+ const previousCommitId = state.commit?.prev_commit_id;
+ const nextCommitId = state.commit?.next_commit_id;
+ const canMove = {
+ next: !state.isLoading && nextCommitId,
+ previous: !state.isLoading && previousCommitId,
+ };
+ let commitId;
+
+ if (direction === 'next' && canMove.next) {
+ commitId = nextCommitId;
+ } else if (direction === 'previous' && canMove.previous) {
+ commitId = previousCommitId;
+ }
+
+ if (commitId) {
+ dispatch('changeCurrentCommit', { commitId });
+ }
+}
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
index acc8874dad8..1e8e736c028 100644
--- a/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
+++ b/app/assets/javascripts/diffs/store/getters_versions_dropdowns.js
@@ -40,10 +40,7 @@ export const diffCompareDropdownTargetVersions = (state, getters) => {
};
};
- if (gon.features?.diffCompareWithHead) {
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
- }
- return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion];
+ return [...state.mergeRequestDiffs.slice(1).map(formatVersion), baseVersion, headVersion];
};
export const diffCompareDropdownSourceVersions = (state, getters) => {
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 699c61b3ddd..4b1dbc34902 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -41,6 +41,8 @@ export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINE
export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES';
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
+export const SET_DIFF_FILE_VIEWER = 'SET_DIFF_FILE_VIEWER';
+
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 104686993a8..7e89d041c21 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -383,6 +383,11 @@ export default {
file.renderingLines = !file.renderingLines;
},
+ [types.SET_DIFF_FILE_VIEWER](state, { filePath, viewer }) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.viewer = viewer;
+ },
[types.SET_SHOW_SUGGEST_POPOVER](state) {
state.showSuggestPopover = false;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index dd8dec49a37..2be71c77087 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -233,7 +233,7 @@ export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
delete line.text;
- const parsedLine = Object.assign({}, line);
+ const parsedLine = { ...line };
if (line.rich_text) {
const firstChar = parsedLine.rich_text.charAt(0);
@@ -303,6 +303,42 @@ function prepareLine(line) {
}
}
+export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index = 0 }) {
+ /*
+ Renamed files are a little different than other diffs, which
+ is why this is distinct from `prepareDiffFileLines` below.
+
+ We don't get any of the diff file context when we get the diff
+ (so no "inline" vs. "parallel", no "line_code", etc.).
+
+ We can also assume that both the left and the right of each line
+ (for parallel diff view type) are identical, because the file
+ is renamed, not modified.
+
+ This should be cleaned up as part of the effort around flattening our data
+ ==> https://gitlab.com/groups/gitlab-org/-/epics/2852#note_304803402
+ */
+ const lineNumber = index + 1;
+ const cleanLine = {
+ ...line,
+ line_code: `${diffFile.file_hash}_${lineNumber}_${lineNumber}`,
+ new_line: lineNumber,
+ old_line: lineNumber,
+ };
+
+ prepareLine(cleanLine); // WARNING: In-Place Mutations!
+
+ if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) {
+ return {
+ left: { ...cleanLine },
+ right: { ...cleanLine },
+ line_code: cleanLine.line_code,
+ };
+ }
+
+ return cleanLine;
+}
+
function prepareDiffFileLines(file) {
const inlineLines = file.highlighted_diff_lines;
const parallelLines = file.parallel_diff_lines;
@@ -437,7 +473,11 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
// This method will check whether the discussion is still applicable
// to the diff line in question regarding different versions of the MR
export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) {
- const { line_code, ...diffPositionCopy } = diffPosition;
+ const { line_code, ...dp } = diffPosition;
+ // Removing `line_range` from diffPosition because the backend does not
+ // yet consistently return this property. This check can be removed,
+ // once this is addressed. see https://gitlab.com/gitlab-org/gitlab/-/issues/213010
+ const { line_range: dpNotUsed, ...diffPositionCopy } = dp;
if (discussion.original_position && discussion.position) {
const discussionPositions = [
@@ -446,7 +486,14 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
...(discussion.positions || []),
];
- return discussionPositions.some(position => isEqual(position, diffPositionCopy));
+ const removeLineRange = position => {
+ const { line_range: pNotUsed, ...positionNoLineRange } = position;
+ return positionNoLineRange;
+ };
+
+ return discussionPositions
+ .map(removeLineRange)
+ .some(position => isEqual(position, diffPositionCopy));
}
// eslint-disable-next-line
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index f839e9acf04..9a0b85bd610 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Dropzone from 'dropzone';
-import _ from 'underscore';
+import { escape } from 'lodash';
import './behaviors/preview_markdown';
import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf';
@@ -16,7 +16,7 @@ Dropzone.autoDiscover = false;
* @param {String|Object} res
*/
function getErrorMessage(res) {
- if (!res || _.isString(res)) {
+ if (!res || typeof res === 'string') {
return res;
}
@@ -233,7 +233,7 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) {
};
addFileToForm = path => {
- $(form).append(`<input type="hidden" name="files[]" value="${_.escape(path)}">`);
+ $(form).append(`<input type="hidden" name="files[]" value="${escape(path)}">`);
};
const showSpinner = () => $uploadingProgressContainer.removeClass('hide');
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index 663d14bcfcb..020ed6dc867 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
+import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { registerLanguages } from '~/ide/utils';
import { clearDomElement } from './utils';
export default class Editor {
@@ -17,6 +19,8 @@ export default class Editor {
};
Editor.setupMonacoTheme();
+
+ registerLanguages(...languages);
}
static setupMonacoTheme() {
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
index 5c03c008faf..f0723e96ddf 100644
--- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -3,7 +3,7 @@
* Render modal to confirm rollback/redeploy.
*/
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -30,7 +30,7 @@ export default {
: s__('Environments|Rollback environment %{name}?');
return sprintf(title, {
- name: esc(this.environment.name),
+ name: escape(this.environment.name),
});
},
@@ -50,10 +50,10 @@ export default {
},
modalText() {
- const linkStart = `<a class="commit-sha mr-0" href="${esc(this.commitUrl)}">`;
- const commitId = esc(this.commitShortSha);
+ const linkStart = `<a class="commit-sha mr-0" href="${escape(this.commitUrl)}">`;
+ const commitId = escape(this.commitShortSha);
const linkEnd = '</a>';
- const name = esc(this.name);
+ const name = escape(this.name);
const body = this.environment.isLastDeployment
? s__(
'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index 0a978ab5869..899d7ec8521 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,8 +1,7 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import containerMixin from 'ee_else_ce/environments/mixins/container_mixin';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
-import EnvironmentTable from '../components/environments_table.vue';
+import EnvironmentTable from './environments_table.vue';
export default {
components: {
@@ -10,8 +9,12 @@ export default {
TablePagination,
GlLoadingIcon,
},
- mixins: [containerMixin],
props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: null,
+ },
isLoading: {
type: Boolean,
required: true,
@@ -28,6 +31,31 @@ export default {
type: Boolean,
required: true,
},
+ deployBoardsHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
onChangePage(page) {
diff --git a/app/assets/javascripts/environments/components/delete_environment_modal.vue b/app/assets/javascripts/environments/components/delete_environment_modal.vue
index f731dc49a5b..29aab268fd3 100644
--- a/app/assets/javascripts/environments/components/delete_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/delete_environment_modal.vue
@@ -52,7 +52,7 @@ export default {
footer-primary-button-variant="danger"
@submit="onSubmit"
>
- <template slot="header">
+ <template #header>
<h4 class="modal-title d-flex mw-100">
{{ __('Delete') }}
<span v-gl-tooltip :title="environment.name" class="text-truncate mx-1 flex-fill">
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 335c668474e..fa3d217f148 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -9,7 +9,6 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
import CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@@ -44,7 +43,7 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [environmentItemMixin, timeagoMixin],
+ mixins: [timeagoMixin],
props: {
canReadEnvironment: {
@@ -65,6 +64,9 @@ export default {
},
computed: {
+ deployIconName() {
+ return this.model.isDeployBoardVisible ? 'chevron-down' : 'chevron-right';
+ },
/**
* Verifies if `last_deployment` key exists in the current Environment.
* This key is required to render most of the html - this method works has
@@ -210,6 +212,10 @@ export default {
}));
},
+ shouldRenderDeployBoard() {
+ return this.model.hasDeployBoard;
+ },
+
/**
* Builds the string used in the user image alt attribute.
*
@@ -501,6 +507,9 @@ export default {
},
methods: {
+ toggleDeployBoard() {
+ eventHub.$emit('toggleDeployBoard', this.model);
+ },
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index 0cc6f3df2d7..0a5538237f9 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,6 +1,5 @@
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
-import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin';
import Flash from '~/flash';
import { s__ } from '~/locale';
import emptyState from './empty_state.vue';
@@ -22,13 +21,18 @@ export default {
DeleteEnvironmentModal,
},
- mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
+ mixins: [CIPaginationMixin, environmentsMixin],
props: {
endpoint: {
type: String,
required: true,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
canCreateEnvironment: {
type: Boolean,
required: true,
@@ -41,6 +45,11 @@ export default {
type: String,
required: true,
},
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
helpPagePath: {
type: String,
required: true,
@@ -50,17 +59,37 @@ export default {
required: false,
default: '',
},
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
created() {
eventHub.$on('toggleFolder', this.toggleFolder);
+ eventHub.$on('toggleDeployBoard', this.toggleDeployBoard);
},
beforeDestroy() {
eventHub.$off('toggleFolder');
+ eventHub.$off('toggleDeployBoard');
},
methods: {
+ toggleDeployBoard(model) {
+ this.store.toggleDeployBoard(model.id);
+ },
toggleFolder(folder) {
this.store.toggleFolder(folder);
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 89e40faa23e..380e16c7b71 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -4,7 +4,6 @@
*/
import { GlLoadingIcon } from '@gitlab/ui';
import { flow, reverse, sortBy } from 'lodash/fp';
-import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale';
import EnvironmentItem from './environment_item.vue';
@@ -16,7 +15,6 @@ export default {
CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'),
},
- mixins: [environmentTableMixin],
props: {
environments: {
type: Array,
@@ -33,6 +31,31 @@ export default {
required: false,
default: false,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
sortedEnvironments() {
@@ -79,9 +102,15 @@ export default {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
+ shouldRenderDeployBoard(model) {
+ return model.hasDeployBoard && model.isDeployBoardVisible;
+ },
shouldRenderFolderContent(env) {
return env.isFolder && env.isOpen && env.children && env.children.length > 0;
},
+ shouldShowCanaryCallout(env) {
+ return env.showCanaryCallout && this.showCanaryDeploymentCallout;
+ },
sortEnvironments(environments) {
/*
* The sorting algorithm should sort in the following priorities:
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index d3e8fb7ff08..7448fd584c6 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -60,7 +60,7 @@ export default {
footer-primary-button-variant="danger"
@submit="onSubmit"
>
- <template slot="header">
+ <template #header>
<h4 class="modal-title d-flex mw-100">
Stopping
<span v-gl-tooltip :title="environment.name" class="text-truncate ml-1 mr-1 flex-fill">
diff --git a/app/assets/javascripts/environments/event_hub.js b/app/assets/javascripts/environments/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/environments/event_hub.js
+++ b/app/assets/javascripts/environments/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index c1bfe8d05fe..56896ac4d43 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
+import canaryCalloutMixin from '../mixins/canary_callout_mixin';
import environmentsFolderApp from './environments_folder_view.vue';
import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index 30b02585692..e1e356a977f 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,5 +1,4 @@
<script>
-import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -11,7 +10,7 @@ export default {
DeleteEnvironmentModal,
},
- mixins: [environmentsMixin, CIPaginationMixin, folderMixin],
+ mixins: [environmentsMixin, CIPaginationMixin],
props: {
endpoint: {
@@ -30,6 +29,31 @@ export default {
type: Boolean,
required: true,
},
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
methods: {
successCallback(resp) {
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index 9a68619d4f7..4848cb0f13d 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
+import canaryCalloutMixin from './mixins/canary_callout_mixin';
import environmentsComponent from './components/environments_app.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import Translate from '../vue_shared/translate';
diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
index f6d3d67b777..398576a31cb 100644
--- a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
+++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
@@ -1,5 +1,26 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
export default {
+ data() {
+ const data = document.querySelector(this.$options.el).dataset;
+
+ return {
+ canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId,
+ showCanaryDeploymentCallout: parseBoolean(data.environmentsDataShowCanaryDeploymentCallout),
+ userCalloutsPath: data.environmentsDataUserCalloutsPath,
+ lockPromotionSvgPath: data.environmentsDataLockPromotionSvgPath,
+ helpCanaryDeploymentsPath: data.environmentsDataHelpCanaryDeploymentsPath,
+ };
+ },
computed: {
- canaryCalloutProps() {},
+ canaryCalloutProps() {
+ return {
+ canaryDeploymentFeatureId: this.canaryDeploymentFeatureId,
+ showCanaryDeploymentCallout: this.showCanaryDeploymentCallout,
+ userCalloutsPath: this.userCalloutsPath,
+ lockPromotionSvgPath: this.lockPromotionSvgPath,
+ helpCanaryDeploymentsPath: this.helpCanaryDeploymentsPath,
+ };
+ },
},
};
diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js
deleted file mode 100644
index abf7d33be91..00000000000
--- a/app/assets/javascripts/environments/mixins/container_mixin.js
+++ /dev/null
@@ -1,34 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: null,
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: null,
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: null,
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: null,
- },
- deployBoardsHelpPath: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
deleted file mode 100644
index 2dfed36ec99..00000000000
--- a/app/assets/javascripts/environments/mixins/environment_item_mixin.js
+++ /dev/null
@@ -1,13 +0,0 @@
-export default {
- computed: {
- deployIconName() {
- return '';
- },
- shouldRenderDeployBoard() {
- return false;
- },
- },
- methods: {
- toggleDeployBoard() {},
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
deleted file mode 100644
index fc805b9235a..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_app_mixin.js
+++ /dev/null
@@ -1,32 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: '',
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: '',
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: '',
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: '',
- },
- },
- metods: {
- toggleDeployBoard() {},
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
deleted file mode 100644
index e793a7cadf2..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
+++ /dev/null
@@ -1,29 +0,0 @@
-export default {
- props: {
- canaryDeploymentFeatureId: {
- type: String,
- required: false,
- default: '',
- },
- showCanaryDeploymentCallout: {
- type: Boolean,
- required: false,
- default: false,
- },
- userCalloutsPath: {
- type: String,
- required: false,
- default: '',
- },
- lockPromotionSvgPath: {
- type: String,
- required: false,
- default: '',
- },
- helpCanaryDeploymentsPath: {
- type: String,
- required: false,
- default: '',
- },
- },
-};
diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
deleted file mode 100644
index 208f1a7373d..00000000000
--- a/app/assets/javascripts/environments/mixins/environments_table_mixin.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default {
- methods: {
- shouldShowCanaryCallout() {
- return false;
- },
- shouldRenderDeployBoard() {
- return false;
- },
- },
-};
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 6b7c1ff627d..1992e753255 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -58,13 +58,14 @@ export default class EnvironmentsStore {
let filtered = {};
if (env.size > 1) {
- filtered = Object.assign({}, env, {
+ filtered = {
+ ...env,
isFolder: true,
isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name,
isOpen: oldEnvironmentState.isOpen || false,
children: oldEnvironmentState.children || [],
- });
+ };
}
if (env.latest) {
@@ -133,6 +134,17 @@ export default class EnvironmentsStore {
}
/**
+ * Toggles deploy board visibility for the provided environment ID.
+ * Currently only works on EE.
+ *
+ * @param {Object} environment
+ * @return {Array}
+ */
+ toggleDeployBoard() {
+ return this.state.environments;
+ }
+
+ /**
* Toggles folder open property for the given folder.
*
* @param {Object} folder
@@ -155,7 +167,7 @@ export default class EnvironmentsStore {
let updated = env;
if (env.latest) {
- updated = Object.assign({}, env, env.latest);
+ updated = { ...env, ...env.latest };
delete updated.latest;
} else {
updated = env;
@@ -181,7 +193,7 @@ export default class EnvironmentsStore {
const { environments } = this.state;
const updatedEnvironments = environments.map(env => {
- const updateEnv = Object.assign({}, env);
+ const updateEnv = { ...env };
if (env.id === environment.id) {
updateEnv[prop] = newValue;
}
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
index 3d700f4d216..45432e8ebd8 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -393,9 +393,9 @@ export default {
<template #description>
<div>
<span>{{ __('Monitor your errors by integrating with Sentry.') }}</span>
- <a href="/help/user/project/operations/error_tracking.html">
- {{ __('More information') }}
- </a>
+ <gl-link target="_blank" href="/help/user/project/operations/error_tracking.html">{{
+ __('More information')
+ }}</gl-link>
</div>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 8db0b1c5da0..f7f2c450be1 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -62,7 +62,7 @@ export default {
? sprintf(
__(`%{spanStart}in%{spanEnd} %{errorFn}`),
{
- errorFn: `<strong>${esc(this.errorFn)}</strong>`,
+ errorFn: `<strong>${escape(this.errorFn)}</strong>`,
spanStart: `<span class="text-tertiary">`,
spanEnd: `</span>`,
},
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
index 8f6f404ef8a..05554b2b566 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -1,4 +1,4 @@
-import service from './../services';
+import service from '../services';
import * as types from './mutation_types';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index d7264e96b13..7e7a2588951 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -4,3 +4,8 @@ export const DROPDOWN_TYPE = {
hint: 'hint',
operator: 'operator',
};
+
+export const FILTER_TYPE = {
+ none: 'none',
+ any: 'any',
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
index 0c8c8140ee9..1bbd33b6258 100644
--- a/app/assets/javascripts/filtered_search/dropdown_operator.js
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -47,13 +47,17 @@ export default class DropdownOperator extends FilteredSearchDropdown {
title: '=',
help: __('is'),
},
- {
+ ];
+
+ if (gon.features?.notIssuableQueries) {
+ dropdownData.push({
tag: 'not-equal',
type: 'string',
title: '!=',
help: __('is not'),
- },
- ];
+ });
+ }
+
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
super.renderContent(forceShowList);
diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/filtered_search/event_hub.js
+++ b/app/assets/javascripts/filtered_search/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 2b6e1f25dc6..f7ce2ea01e0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,6 +1,7 @@
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { FILTER_TYPE } from './constants';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
@@ -74,6 +75,9 @@ export default class FilteredSearchDropdown {
renderContent(forceShowList = false) {
const currentHook = this.getCurrentHook();
+
+ FilteredSearchDropdown.hideDropdownItemsforNotOperator(currentHook);
+
if (forceShowList && currentHook && currentHook.list.hidden) {
currentHook.list.show();
}
@@ -138,4 +142,41 @@ export default class FilteredSearchDropdown {
hook.list.render(results);
}
}
+
+ /**
+ * Hide None & Any options from the current dropdown.
+ * Hiding happens only for NOT operator.
+ */
+ static hideDropdownItemsforNotOperator(currentHook) {
+ const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
+
+ if (lastOperator === '!=') {
+ const { list: dropdownEl } = currentHook.list;
+
+ let shouldHideDivider = true;
+
+ // Iterate over all the static dropdown values,
+ // then hide `None` and `Any` items.
+ Array.from(dropdownEl.querySelectorAll('li[data-value]')).forEach(itemEl => {
+ const {
+ dataset: { value },
+ } = itemEl;
+
+ if (value.toLowerCase() === FILTER_TYPE.none || value.toLowerCase() === FILTER_TYPE.any) {
+ itemEl.classList.add('hidden');
+ } else {
+ // If we encountered any element other than None/Any, then
+ // we shouldn't hide the divider
+ shouldHideDivider = false;
+ }
+ });
+
+ if (shouldHideDivider) {
+ const divider = dropdownEl.querySelector('li.divider');
+ if (divider) {
+ divider.classList.add('hidden');
+ }
+ }
+ }
+ }
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index d051b60814e..161a65c511d 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -120,7 +120,7 @@ export default class FilteredSearchDropdownManager {
filter: key,
};
const extraArguments = mappingKey.extraArguments || {};
- const glArguments = Object.assign({}, defaultArguments, extraArguments);
+ const glArguments = { ...defaultArguments, ...extraArguments };
// Passing glArguments to `new glClass(<arguments>)`
mappingKey.reference = new (Function.prototype.bind.apply(glClass, [null, glArguments]))();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 724f80f8866..55a0e91b0f3 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -31,6 +31,7 @@ export default class FilteredSearchManager {
isGroupDecendent = false,
filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
+ placeholder = __('Search or filter results...'),
}) {
this.isGroup = isGroup;
this.isGroupAncestor = isGroupAncestor;
@@ -45,6 +46,7 @@ export default class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
+ this.placeholder = placeholder;
const { multipleAssignees } = this.filteredSearchInput.dataset;
if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
@@ -395,11 +397,10 @@ export default class FilteredSearchManager {
handleInputPlaceholder() {
const query = DropdownUtils.getSearchQuery();
- const placeholder = __('Search or filter results...');
const currentPlaceholder = this.filteredSearchInput.placeholder;
- if (query.length === 0 && currentPlaceholder !== placeholder) {
- this.filteredSearchInput.placeholder = placeholder;
+ if (query.length === 0 && currentPlaceholder !== this.placeholder) {
+ this.filteredSearchInput.placeholder = this.placeholder;
} else if (query.length > 0 && currentPlaceholder !== '') {
this.filteredSearchInput.placeholder = '';
}
@@ -710,13 +711,17 @@ export default class FilteredSearchManager {
}
}
- search(state = null) {
- const paths = [];
+ getSearchTokens() {
const searchQuery = DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
- const { tokens, searchToken } = this.tokenizer.processTokens(searchQuery, tokenKeys);
+ return this.tokenizer.processTokens(searchQuery, tokenKeys);
+ }
+
+ search(state = null) {
+ const paths = [];
+ const { tokens, searchToken } = this.getSearchTokens();
const currentState = state || getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
index b3eb0475d6f..cdbc9ec84bd 100644
--- a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -2,14 +2,12 @@ import { uniq } from 'lodash';
class RecentSearchesStore {
constructor(initialState = {}, allowedKeys) {
- this.state = Object.assign(
- {
- isLocalStorageAvailable: true,
- recentSearches: [],
- allowedKeys,
- },
- initialState,
- );
+ this.state = {
+ isLocalStorageAvailable: true,
+ recentSearches: [],
+ allowedKeys,
+ ...initialState,
+ };
}
addRecentSearch(newSearch) {
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index b8f4cd8a1e1..02caf0851af 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { USER_TOKEN_TYPES } from 'ee_else_ce/filtered_search/constants';
import FilteredSearchContainer from '~/filtered_search/container';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
@@ -48,7 +48,7 @@ export default class VisualTokenValue {
tokenValueContainer.dataset.originalValue = tokenValue;
tokenValueElement.innerHTML = `
<img class="avatar s20" src="${user.avatar_url}" alt="">
- ${esc(user.name)}
+ ${escape(user.name)}
`;
/* eslint-enable no-param-reassign */
})
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index 4d62ec6e385..74c00d21535 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { escape } from 'lodash';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
@@ -39,14 +39,14 @@ const createAction = config => `
class="flash-action"
${config.href ? '' : 'role="button"'}
>
- ${_.escape(config.title)}
+ ${escape(config.title)}
</a>
`;
const createFlashEl = (message, type) => `
<div class="flash-${type}">
<div class="flash-text">
- ${_.escape(message)}
+ ${escape(message)}
<div class="close-icon-wrapper js-close-icon">
${spriteIcon('close', 'close-icon')}
</div>
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 1f1776a5487..61080fb5487 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -3,7 +3,7 @@ import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import AccessorUtilities from '~/lib/utils/accessor';
import eventHub from '../event_hub';
-import store from '../store/';
+import store from '../store';
import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants';
import { isMobile, updateExistingFrequentItem, sanitizeItem } from '../utils';
import FrequentItemsSearchInput from './frequent_items_search_input.vue';
diff --git a/app/assets/javascripts/frequent_items/event_hub.js b/app/assets/javascripts/frequent_items/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/frequent_items/event_hub.js
+++ b/app/assets/javascripts/frequent_items/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index b6deedfa5e4..f3ce30c942f 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '@gitlab/at.js';
-import _ from 'underscore';
+import { escape, template } from 'lodash';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
@@ -11,7 +11,7 @@ function sanitize(str) {
}
export function membersBeforeSave(members) {
- return _.map(members, member => {
+ return members.map(member => {
const GROUP_TYPE = 'Group';
let title = '';
@@ -122,7 +122,7 @@ class GfmAutoComplete {
cssClasses.push('has-warning');
}
- return _.template(tpl)({
+ return template(tpl)({
...value,
className: cssClasses.join(' '),
});
@@ -137,7 +137,7 @@ class GfmAutoComplete {
tpl += '<%- referencePrefix %>';
}
}
- return _.template(tpl)({ referencePrefix });
+ return template(tpl, { interpolate: /<%=([\s\S]+?)%>/g })({ referencePrefix });
},
suffix: '',
callbacks: {
@@ -692,14 +692,14 @@ GfmAutoComplete.Emoji = {
// Team Members
GfmAutoComplete.Members = {
templateFunction({ avatarTag, username, title, icon }) {
- return `<li>${avatarTag} ${username} <small>${_.escape(title)}</small> ${icon}</li>`;
+ return `<li>${avatarTag} ${username} <small>${escape(title)}</small> ${icon}</li>`;
},
};
GfmAutoComplete.Labels = {
templateFunction(color, title) {
- return `<li><span class="dropdown-label-box" style="background: ${_.escape(
+ return `<li><span class="dropdown-label-box" style="background: ${escape(
color,
- )}"></span> ${_.escape(title)}</li>`;
+ )}"></span> ${escape(title)}</li>`;
},
};
// Issues, MergeRequests and Snippets
@@ -709,13 +709,13 @@ GfmAutoComplete.Issues = {
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
- return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
+ return `<li><small>${reference || id}</small> ${escape(title)}</li>`;
},
};
// Milestones
GfmAutoComplete.Milestones = {
templateFunction(title) {
- return `<li>${_.escape(title)}</li>`;
+ return `<li>${escape(title)}</li>`;
},
};
GfmAutoComplete.Loading = {
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 918276ce329..be4b4b5f87d 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -1,7 +1,7 @@
/* eslint-disable max-classes-per-file, one-var, consistent-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { escape } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from './lib/utils/axios_utils';
import { visitUrl } from './lib/utils/url_utility';
@@ -145,7 +145,7 @@ class GitLabDropdownFilter {
// { prop: 'foo' },
// { prop: 'baz' }
// ]
- if (_.isArray(data)) {
+ if (Array.isArray(data)) {
results = fuzzaldrinPlus.filter(data, searchText, {
key: this.options.keys,
});
@@ -261,14 +261,14 @@ class GitLabDropdown {
// If no input is passed create a default one
self = this;
// If selector was passed
- if (_.isString(this.filterInput)) {
+ if (typeof this.filterInput === 'string') {
this.filterInput = this.getElement(this.filterInput);
}
const searchFields = this.options.search ? this.options.search.fields : [];
if (this.options.data) {
// If we provided data
// data could be an array of objects or a group of arrays
- if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) {
+ if (typeof this.options.data === 'object' && !(this.options.data instanceof Function)) {
this.fullData = this.options.data;
currentIndex = -1;
this.parseData(this.options.data);
@@ -595,13 +595,14 @@ class GitLabDropdown {
return renderItem({
instance: this,
- options: Object.assign({}, this.options, {
+ options: {
+ ...this.options,
icon: this.icon,
highlight: this.highlight,
highlightText: text => this.highlightTextMatches(text, this.filterInput.val()),
highlightTemplate: this.highlightTemplate.bind(this),
parent,
- }),
+ },
data,
group,
index,
@@ -610,7 +611,7 @@ class GitLabDropdown {
// eslint-disable-next-line class-methods-use-this
highlightTemplate(text, template) {
- return `"<b>${_.escape(text)}</b>" ${template}`;
+ return `"<b>${escape(text)}</b>" ${template}`;
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index 1811a942beb..0b7735a7db9 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -8,7 +8,7 @@ export default class GLForm {
constructor(form, enableGFM = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
- this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
+ this.enableGFM = { ...defaultAutocompleteConfig, ...enableGFM };
// Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => {
@@ -29,6 +29,10 @@ export default class GLForm {
if (this.autoComplete) {
this.autoComplete.destroy();
}
+ if (this.formDropzone) {
+ this.formDropzone.destroy();
+ }
+
this.form.data('glForm', null);
}
@@ -45,7 +49,7 @@ export default class GLForm {
);
this.autoComplete = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
this.autoComplete.setup(this.form.find('.js-gfm-input'), this.enableGFM);
- dropzoneInput(this.form, { parallelUploads: 1 });
+ this.formDropzone = dropzoneInput(this.form, { parallelUploads: 1 });
autosize(this.textarea);
}
// form and textarea event listeners
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index ce6591e85cf..0b401f4d732 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -111,8 +111,8 @@ export default {
const filterGroupsBy = getParameterByName('filter') || null;
this.isLoading = true;
- // eslint-disable-next-line promise/catch-or-return
- this.fetchGroups({
+
+ return this.fetchGroups({
page,
filterGroupsBy,
sortBy,
@@ -126,8 +126,7 @@ export default {
fetchPage(page, filterGroupsBy, sortBy, archived) {
this.isLoading = true;
- // eslint-disable-next-line promise/catch-or-return
- this.fetchGroups({
+ return this.fetchGroups({
page,
filterGroupsBy,
sortBy,
diff --git a/app/assets/javascripts/groups/event_hub.js b/app/assets/javascripts/groups/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/groups/event_hub.js
+++ b/app/assets/javascripts/groups/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/groups/new_group_child.js b/app/assets/javascripts/groups/new_group_child.js
index 012177479c6..bb2aea3ea76 100644
--- a/app/assets/javascripts/groups/new_group_child.js
+++ b/app/assets/javascripts/groups/new_group_child.js
@@ -2,7 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility';
import DropLab from '../droplab/drop_lab';
import ISetter from '../droplab/plugins/input_setter';
-const InputSetter = Object.assign({}, ISetter);
+const InputSetter = { ...ISetter };
const NEW_PROJECT = 'new-project';
const NEW_SUBGROUP = 'new-subgroup';
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 1678991b1ea..67b068f1c6b 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -74,20 +74,27 @@ function initStatusTriggers() {
}
}
+function trackShowUserDropdownLink(trackEvent, elToTrack, el) {
+ const { trackLabel, trackProperty } = elToTrack.dataset;
+
+ $(el).on('shown.bs.dropdown', () => {
+ Tracking.event(document.body.dataset.page, trackEvent, {
+ label: trackLabel,
+ property: trackProperty,
+ });
+ });
+}
export function initNavUserDropdownTracking() {
const el = document.querySelector('.js-nav-user-dropdown');
const buyEl = document.querySelector('.js-buy-ci-minutes-link');
+ const upgradeEl = document.querySelector('.js-upgrade-plan-link');
if (el && buyEl) {
- const { trackLabel, trackProperty } = buyEl.dataset;
- const trackEvent = 'show_buy_ci_minutes';
+ trackShowUserDropdownLink('show_buy_ci_minutes', buyEl, el);
+ }
- $(el).on('shown.bs.dropdown', () => {
- Tracking.event(undefined, trackEvent, {
- label: trackLabel,
- property: trackProperty,
- });
- });
+ if (el && upgradeEl) {
+ trackShowUserDropdownLink('show_upgrade_link', upgradeEl, el);
}
}
diff --git a/app/assets/javascripts/helpers/avatar_helper.js b/app/assets/javascripts/helpers/avatar_helper.js
index 7891b44dd27..4f04a1b8c16 100644
--- a/app/assets/javascripts/helpers/avatar_helper.js
+++ b/app/assets/javascripts/helpers/avatar_helper.js
@@ -1,11 +1,14 @@
import { escape } from 'lodash';
import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
export const DEFAULT_SIZE_CLASS = 's40';
export const IDENTICON_BG_COUNT = 7;
export function getIdenticonBackgroundClass(entityId) {
- const type = (entityId % IDENTICON_BG_COUNT) + 1;
+ // If a GraphQL string id is passed in, convert it to the entity number
+ const id = typeof entityId === 'string' ? getIdFromGraphQLId(entityId) : entityId;
+ const type = (id % IDENTICON_BG_COUNT) + 1;
return `bg${type}`;
}
diff --git a/app/assets/javascripts/helpers/event_hub_factory.js b/app/assets/javascripts/helpers/event_hub_factory.js
new file mode 100644
index 00000000000..4d7f7550a94
--- /dev/null
+++ b/app/assets/javascripts/helpers/event_hub_factory.js
@@ -0,0 +1,20 @@
+import mitt from 'mitt';
+
+export default () => {
+ const emitter = mitt();
+
+ emitter.once = (event, handler) => {
+ const wrappedHandler = evt => {
+ handler(evt);
+ emitter.off(event, wrappedHandler);
+ };
+ emitter.on(event, wrappedHandler);
+ };
+
+ emitter.$on = emitter.on;
+ emitter.$once = emitter.once;
+ emitter.$off = emitter.off;
+ emitter.$emit = emitter.emit;
+
+ return emitter;
+};
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index 6a8ea506d1b..6c563776533 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { sprintf, s__ } from '~/locale';
import consts from '../../stores/modules/commit/constants';
@@ -22,7 +22,7 @@ export default {
commitToCurrentBranchText() {
return sprintf(
s__('IDE|Commit to %{branchName} branch'),
- { branchName: `<strong class="monospace">${esc(this.currentBranchId)}</strong>` },
+ { branchName: `<strong class="monospace">${escape(this.currentBranchId)}</strong>` },
false,
);
},
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index e618fb3daae..24499fb9f6d 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -24,8 +24,8 @@ export default {
discardModalTitle() {
return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
},
- isStaged() {
- return !this.activeFile.changed && this.activeFile.staged;
+ canDiscard() {
+ return this.activeFile.changed || this.activeFile.staged;
},
},
methods: {
@@ -53,7 +53,7 @@ export default {
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<button
- v-if="!isStaged"
+ v-if="canDiscard"
ref="discardButton"
type="button"
class="btn btn-remove btn-inverted append-right-8"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index f6ca728defc..4cbd33e6ed6 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -1,11 +1,13 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { n__, __ } from '~/locale';
+import { GlModal } from '@gitlab/ui';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
+import consts from '../../stores/modules/commit/constants';
export default {
components: {
@@ -13,6 +15,7 @@ export default {
LoadingButton,
CommitMessageField,
SuccessMessage,
+ GlModal,
},
data() {
return {
@@ -54,7 +57,20 @@ export default {
},
methods: {
...mapActions(['updateActivityBarView']),
- ...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']),
+ ...mapActions('commit', [
+ 'updateCommitMessage',
+ 'discardDraft',
+ 'commitChanges',
+ 'updateCommitAction',
+ ]),
+ commit() {
+ return this.commitChanges().catch(() => {
+ this.$refs.createBranchModal.show();
+ });
+ },
+ forceCreateNewBranch() {
+ return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
+ },
toggleIsCompact() {
if (this.currentViewIsCommitView) {
this.isCompact = !this.isCompact;
@@ -119,13 +135,13 @@ export default {
</button>
<p class="text-center bold">{{ overviewText }}</p>
</div>
- <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commitChanges">
+ <form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commit">
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
<commit-message-field
:text="commitMessage"
:placeholder="preBuiltCommitMessage"
@input="updateCommitMessage"
- @submit="commitChanges"
+ @submit="commit"
/>
<div class="clearfix prepend-top-15">
<actions />
@@ -133,7 +149,7 @@ export default {
:loading="submitCommitLoading"
:label="commitButtonText"
container-class="btn btn-success btn-sm float-left qa-commit-button"
- @click="commitChanges"
+ @click="commit"
/>
<button
v-if="!discardDraftButtonDisabled"
@@ -152,6 +168,19 @@ export default {
{{ __('Collapse') }}
</button>
</div>
+ <gl-modal
+ ref="createBranchModal"
+ modal-id="ide-create-branch-modal"
+ :ok-title="__('Create new branch')"
+ :title="__('Branch has changed')"
+ ok-variant="success"
+ @ok="forceCreateNewBranch"
+ >
+ {{
+ __(`This branch has changed since you started editing.
+ Would you like to create a new branch?`)
+ }}
+ </gl-modal>
</form>
</transition>
</div>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index a15e22d4742..e6a1a1ba73c 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -1,9 +1,8 @@
<script>
-import $ from 'jquery';
import { mapActions } from 'vuex';
import { __, sprintf } from '~/locale';
+import { GlModal } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import ListItem from './list_item.vue';
@@ -11,7 +10,7 @@ export default {
components: {
Icon,
ListItem,
- GlModal: DeprecatedModal2,
+ GlModal,
},
directives: {
tooltip,
@@ -58,7 +57,7 @@ export default {
methods: {
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
openDiscardModal() {
- $('#discard-all-changes').modal('show');
+ this.$refs.discardAllModal.show();
},
unstageAndDiscardAllChanges() {
this.unstageAllChanges();
@@ -114,11 +113,12 @@ export default {
</p>
<gl-modal
v-if="!stagedList"
- id="discard-all-changes"
- :footer-primary-button-text="__('Discard all changes')"
- :header-title-text="__('Discard all changes?')"
- footer-primary-button-variant="danger"
- @submit="unstageAndDiscardAllChanges"
+ ref="discardAllModal"
+ ok-variant="danger"
+ modal-id="discard-all-changes"
+ :ok-title="__('Discard all changes')"
+ :title="__('Discard all changes?')"
+ @ok="unstageAndDiscardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 7ebcacc530f..36c8b18e205 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -3,6 +3,7 @@ import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
+import { modalTypes } from '../constants';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
@@ -12,6 +13,7 @@ import RepoEditor from './repo_editor.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -26,6 +28,7 @@ export default {
GlDeprecatedButton,
GlLoadingIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
rightPaneComponent: {
type: Vue.Component,
@@ -52,13 +55,20 @@ export default {
'allBlobs',
'emptyRepo',
'currentTree',
+ 'editorTheme',
]),
+ themeName() {
+ return window.gon?.user_color_scheme;
+ },
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
+
+ if (this.themeName)
+ document.querySelector('.navbar-gitlab').classList.add(`theme-${this.themeName}`);
},
methods: {
- ...mapActions(['toggleFileFinder', 'openNewEntryModal']),
+ ...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
@@ -72,12 +82,18 @@ export default {
openFile(file) {
this.$router.push(`/project${file.url}`);
},
+ createNewFile() {
+ this.$refs.newModal.open(modalTypes.blob);
+ },
},
};
</script>
<template>
- <article class="ide position-relative d-flex flex-column align-items-stretch">
+ <article
+ class="ide position-relative d-flex flex-column align-items-stretch"
+ :class="{ [`theme-${themeName}`]: themeName }"
+ >
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
<find-file
@@ -125,7 +141,7 @@ export default {
variant="success"
:title="__('New file')"
:aria-label="__('New file')"
- @click="openNewEntryModal({ type: 'blob' })"
+ @click="createNewFile()"
>
{{ __('New file') }}
</gl-deprecated-button>
@@ -147,6 +163,6 @@ export default {
<component :is="rightPaneComponent" v-if="currentProjectId" />
</div>
<ide-status-bar />
- <new-modal />
+ <new-modal ref="newModal" />
</article>
</template>
diff --git a/app/assets/javascripts/ide/components/ide_review.vue b/app/assets/javascripts/ide/components/ide_review.vue
index 901b8892e80..62dbfea2088 100644
--- a/app/assets/javascripts/ide/components/ide_review.vue
+++ b/app/assets/javascripts/ide/components/ide_review.vue
@@ -43,7 +43,7 @@ export default {
<template>
<ide-tree-list :viewer-type="viewer" header-class="ide-review-header">
- <template slot="header">
+ <template #header>
<div class="ide-review-button-holder">
{{ __('Review') }}
<editor-mode-dropdown
diff --git a/app/assets/javascripts/ide/components/ide_side_bar.vue b/app/assets/javascripts/ide/components/ide_side_bar.vue
index 40cd2178e09..7cb31df85ce 100644
--- a/app/assets/javascripts/ide/components/ide_side_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_side_bar.vue
@@ -37,7 +37,7 @@ export default {
</script>
<template>
- <resizable-panel :collapsible="false" :initial-width="340" side="left" class="flex-column">
+ <resizable-panel :initial-width="340" side="left" class="flex-column">
<template v-if="loading">
<div class="multi-file-commit-panel-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 598f3a1dac6..647f4d4be85 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,14 +1,17 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { modalTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
+import NewModal from './new_dropdown/modal.vue';
export default {
components: {
Upload,
IdeTreeList,
NewEntryButton,
+ NewModal,
},
computed: {
...mapState(['currentBranchId']),
@@ -26,14 +29,20 @@ export default {
}
},
methods: {
- ...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry', 'resetOpenFiles']),
+ ...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
+ createNewFile() {
+ this.$refs.newModal.open(modalTypes.blob);
+ },
+ createNewFolder() {
+ this.$refs.newModal.open(modalTypes.tree);
+ },
},
};
</script>
<template>
<ide-tree-list viewer-type="editor">
- <template slot="header">
+ <template #header>
{{ __('Edit') }}
<div class="ide-tree-actions ml-auto d-flex">
<new-entry-button
@@ -41,7 +50,7 @@ export default {
:show-label="false"
class="d-flex border-0 p-0 mr-3 qa-new-file"
icon="doc-new"
- @click="openNewEntryModal({ type: 'blob' })"
+ @click="createNewFile()"
/>
<upload
:show-label="false"
@@ -54,9 +63,10 @@ export default {
:show-label="false"
class="d-flex border-0 p-0"
icon="folder-new"
- @click="openNewEntryModal({ type: 'tree' })"
+ @click="createNewFolder()"
/>
</div>
+ <new-modal ref="newModal" />
</template>
</ide-tree-list>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index 504391ffdc7..975d54c7a4e 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -79,7 +79,7 @@ export default {
<icon name="chevron-left" /> {{ __('View jobs') }}
</button>
</header>
- <div class="top-bar d-flex border-left-0">
+ <div class="top-bar d-flex border-left-0 mr-3">
<job-description :job="detailJob" />
<div class="controllers ml-auto">
<a
@@ -97,7 +97,7 @@ export default {
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
</div>
</div>
- <pre ref="buildTrace" class="build-trace mb-0 h-100" @scroll="scrollBuildLog">
+ <pre ref="buildTrace" class="build-trace mb-0 h-100 mr-3" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
class="bash"
diff --git a/app/assets/javascripts/ide/components/nav_form.vue b/app/assets/javascripts/ide/components/nav_form.vue
index 195504a6861..70a92b8d3ab 100644
--- a/app/assets/javascripts/ide/components/nav_form.vue
+++ b/app/assets/javascripts/ide/components/nav_form.vue
@@ -25,13 +25,13 @@ export default {
<div class="ide-nav-form p-0">
<tabs v-if="showMergeRequests" stop-propagation>
<tab active>
- <template slot="title">
+ <template #title>
{{ __('Branches') }}
</template>
<branches-search-list />
</tab>
<tab>
- <template slot="title">
+ <template #title>
{{ __('Merge Requests') }}
</template>
<merge-request-search-list />
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index 9961c0df52e..2798ede5341 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -4,12 +4,14 @@ import icon from '~/vue_shared/components/icon.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
import { modalTypes } from '../../constants';
+import NewModal from './modal.vue';
export default {
components: {
icon,
upload,
ItemButton,
+ NewModal,
},
props: {
type: {
@@ -37,9 +39,9 @@ export default {
},
},
methods: {
- ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
+ ...mapActions(['createTempEntry', 'deleteEntry']),
createNewItem(type) {
- this.openNewEntryModal({ type, path: this.path });
+ this.$refs.newModal.open(type, this.path);
this.$emit('toggle', false);
},
openDropdown() {
@@ -109,5 +111,6 @@ export default {
</li>
</ul>
</div>
+ <new-modal ref="newModal" />
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index bf3d736ddf3..4766a2fe6ae 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,61 +1,49 @@
<script>
-import $ from 'jquery';
import { mapActions, mapState, mapGetters } from 'vuex';
import flash from '~/flash';
import { __, sprintf, s__ } from '~/locale';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
+import { GlModal } from '@gitlab/ui';
import { modalTypes } from '../../constants';
+import { trimPathComponents } from '../../utils';
export default {
components: {
- GlModal: DeprecatedModal2,
+ GlModal,
},
data() {
return {
- name: '',
+ entryName: '',
+ modalType: modalTypes.blob,
+ path: '',
};
},
computed: {
- ...mapState(['entries', 'entryModal']),
+ ...mapState(['entries']),
...mapGetters('fileTemplates', ['templateTypes']),
- entryName: {
- get() {
- const entryPath = this.entryModal.entry.path;
-
- if (this.entryModal.type === modalTypes.rename) {
- return this.name || entryPath;
- }
-
- return this.name || (entryPath ? `${entryPath}/` : '');
- },
- set(val) {
- this.name = val.trim();
- },
- },
modalTitle() {
- if (this.entryModal.type === modalTypes.tree) {
+ const entry = this.entries[this.path];
+
+ if (this.modalType === modalTypes.tree) {
return __('Create new directory');
- } else if (this.entryModal.type === modalTypes.rename) {
- return this.entryModal.entry.type === modalTypes.tree
- ? __('Rename folder')
- : __('Rename file');
+ } else if (this.modalType === modalTypes.rename) {
+ return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create new file');
},
buttonLabel() {
- if (this.entryModal.type === modalTypes.tree) {
+ const entry = this.entries[this.path];
+
+ if (this.modalType === modalTypes.tree) {
return __('Create directory');
- } else if (this.entryModal.type === modalTypes.rename) {
- return this.entryModal.entry.type === modalTypes.tree
- ? __('Rename folder')
- : __('Rename file');
+ } else if (this.modalType === modalTypes.rename) {
+ return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create file');
},
isCreatingNewFile() {
- return this.entryModal.type === 'blob';
+ return this.modalType === modalTypes.blob;
},
placeholder() {
return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
@@ -64,7 +52,9 @@ export default {
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
submitForm() {
- if (this.entryModal.type === modalTypes.rename) {
+ this.entryName = trimPathComponents(this.entryName);
+
+ if (this.modalType === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
flash(
sprintf(s__('The name "%{name}" is already taken in this directory.'), {
@@ -78,32 +68,32 @@ export default {
);
} else {
let parentPath = this.entryName.split('/');
- const entryName = parentPath.pop();
+ const name = parentPath.pop();
parentPath = parentPath.join('/');
this.renameEntry({
- path: this.entryModal.entry.path,
- name: entryName,
+ path: this.path,
+ name,
parentPath,
});
}
} else {
this.createTempEntry({
- name: this.name,
- type: this.entryModal.type,
+ name: this.entryName,
+ type: this.modalType,
});
}
},
createFromTemplate(template) {
this.createTempEntry({
name: template.name,
- type: this.entryModal.type,
+ type: this.modalType,
});
- $('#ide-new-entry').modal('toggle');
+ this.$refs.modal.toggle();
},
focusInput() {
- const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
+ const name = this.entries[this.entryName]?.name;
const inputValue = this.$refs.fieldName.value;
this.$refs.fieldName.focus();
@@ -112,8 +102,28 @@ export default {
this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
}
},
- closedModal() {
- this.name = '';
+ resetData() {
+ this.entryName = '';
+ this.path = '';
+ this.modalType = modalTypes.blob;
+ },
+ open(type = modalTypes.blob, path = '') {
+ this.modalType = type;
+ this.path = path;
+
+ if (this.modalType === modalTypes.rename) {
+ this.entryName = path;
+ } else {
+ this.entryName = path ? `${path}/` : '';
+ }
+
+ this.$refs.modal.show();
+
+ // wait for modal to show first
+ this.$nextTick(() => this.focusInput());
+ },
+ close() {
+ this.$refs.modal.hide();
},
},
};
@@ -121,22 +131,22 @@ export default {
<template>
<gl-modal
- id="ide-new-entry"
- class="qa-new-file-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="buttonLabel"
- footer-primary-button-variant="success"
- modal-size="lg"
- @submit="submitForm"
- @open="focusInput"
- @closed="closedModal"
+ ref="modal"
+ modal-id="ide-new-entry"
+ modal-class="qa-new-file-modal"
+ :title="modalTitle"
+ :ok-title="buttonLabel"
+ ok-variant="success"
+ size="lg"
+ @ok="submitForm"
+ @hide="resetData"
>
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
<input
ref="fieldName"
- v-model="entryName"
+ v-model.trim="entryName"
type="text"
class="form-control qa-full-file-path"
:placeholder="placeholder"
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
index 8adf0122fb4..91e80be7d18 100644
--- a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -103,7 +103,6 @@ export default {
>
<resizable-panel
v-show="isOpen"
- :collapsible="false"
:initial-width="width"
:min-size="width"
:class="`ide-${side}-sidebar-${currentView}`"
@@ -116,7 +115,7 @@ export default {
v-for="tabView in aliveTabViews"
v-show="isActiveView(tabView.name)"
:key="tabView.name"
- class="flex-fill js-tab-view"
+ class="flex-fill gl-overflow-hidden js-tab-view"
>
<component :is="tabView.component" />
</div>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index d3e5add2e83..cf6d01b6351 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import { sprintf, __ } from '../../../locale';
import Icon from '../../../vue_shared/components/icon.vue';
@@ -10,6 +10,8 @@ import Tab from '../../../vue_shared/components/tabs/tab.vue';
import EmptyState from '../../../pipelines/components/empty_state.vue';
import JobsList from '../jobs/list.vue';
+import IDEServices from '~/ide/services';
+
export default {
components: {
Icon,
@@ -35,7 +37,7 @@ export default {
return sprintf(
__('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'),
{
- linkStart: `<a href="${esc(this.currentProject.web_url)}/-/ci/lint">`,
+ linkStart: `<a href="${escape(this.currentProject.web_url)}/-/ci/lint">`,
linkEnd: '</a>',
},
false,
@@ -47,6 +49,7 @@ export default {
},
created() {
this.fetchLatestPipeline();
+ IDEServices.pingUsage(this.currentProject.path_with_namespace);
},
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
@@ -85,14 +88,14 @@ export default {
</div>
<tabs v-else class="ide-pipeline-list">
<tab :active="!pipelineFailed">
- <template slot="title">
+ <template #title>
{{ __('Jobs') }}
<span v-if="jobsCount" class="badge badge-pill"> {{ jobsCount }} </span>
</template>
<jobs-list :loading="isLoadingJobs" :stages="stages" />
</tab>
<tab :active="pipelineFailed">
- <template slot="title">
+ <template #title>
{{ __('Failed Jobs') }}
<span v-if="failedJobsCount" class="badge badge-pill"> {{ failedJobsCount }} </span>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 2e7e55a61c5..530fba49df2 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,15 +1,12 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
-import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import consts from '../stores/modules/commit/constants';
import { leftSidebarViews, stageKeys } from '../constants';
export default {
components: {
- DeprecatedModal,
CommitFilesList,
EmptyState,
},
@@ -17,13 +14,7 @@ export default {
tooltip,
},
computed: {
- ...mapState([
- 'changedFiles',
- 'stagedFiles',
- 'rightPanelCollapsed',
- 'lastCommitMsg',
- 'unusedSeal',
- ]),
+ ...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg', 'unusedSeal']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommittedChanges', 'activeFile']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
@@ -59,10 +50,6 @@ export default {
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
- ...mapActions('commit', ['commitChanges', 'updateCommitAction']),
- forceCreateNewBranch() {
- return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
- },
},
stageKeys,
};
@@ -70,20 +57,6 @@ export default {
<template>
<div class="multi-file-commit-panel-section">
- <deprecated-modal
- id="ide-create-branch-modal"
- :primary-button-label="__('Create new branch')"
- :title="__('Branch has changed')"
- kind="success"
- @submit="forceCreateNewBranch"
- >
- <template slot="body">
- {{
- __(`This branch has changed since you started editing.
- Would you like to create a new branch?`)
- }}
- </template>
- </deprecated-modal>
<template v-if="showStageUnstageArea">
<commit-files-list
:key-prefix="$options.stageKeys.staged"
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 08850679152..c72a8b2b0d0 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -13,6 +13,7 @@ import {
import Editor from '../lib/editor';
import FileTemplatesBar from './file_templates/bar.vue';
import { __ } from '~/locale';
+import { extractMarkdownImagesFromEntries } from '../stores/utils';
export default {
components: {
@@ -26,17 +27,23 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ content: '',
+ images: {},
+ };
+ },
computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState([
- 'rightPanelCollapsed',
'viewer',
'panelResizing',
'currentActivityView',
'renderWhitespaceInCode',
'editorTheme',
+ 'entries',
]),
...mapGetters([
'currentMergeRequest',
@@ -44,6 +51,7 @@ export default {
'isEditModeActive',
'isCommitModeActive',
'isReviewModeActive',
+ 'currentBranch',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
@@ -87,6 +95,9 @@ export default {
theme: this.editorTheme,
};
},
+ currentBranchCommit() {
+ return this.currentBranch?.commit.id;
+ },
},
watch: {
file(newVal, oldVal) {
@@ -114,9 +125,6 @@ export default {
});
}
},
- rightPanelCollapsed() {
- this.refreshEditorDimensions();
- },
viewer() {
if (!this.file.pending) {
this.createEditorInstance();
@@ -136,6 +144,18 @@ export default {
this.$nextTick(() => this.refreshEditorDimensions());
}
},
+ showContentViewer(val) {
+ if (!val) return;
+
+ if (this.fileType === 'markdown') {
+ const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
+ this.content = content;
+ this.images = images;
+ } else {
+ this.content = this.file.content || this.file.raw;
+ this.images = {};
+ }
+ },
},
beforeDestroy() {
this.editor.dispose();
@@ -310,11 +330,13 @@ export default {
></div>
<content-viewer
v-if="showContentViewer"
- :content="file.content || file.raw"
+ :content="content"
+ :images="images"
:path="file.rawPath || file.path"
:file-path="file.path"
:file-size="file.size"
:project-path="file.projectId"
+ :commit-sha="currentBranchCommit"
:type="fileType"
/>
<diff-viewer
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index 7277fcb7617..86a4622401c 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions, mapState } from 'vuex';
+import { mapActions } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
export default {
@@ -7,10 +7,6 @@ export default {
PanelResizer,
},
props: {
- collapsible: {
- type: Boolean,
- required: true,
- },
initialWidth: {
type: Number,
required: true,
@@ -31,11 +27,6 @@ export default {
};
},
computed: {
- ...mapState({
- collapsed(state) {
- return state[`${this.side}PanelCollapsed`];
- },
- }),
panelStyle() {
if (!this.collapsed) {
return {
@@ -47,33 +38,17 @@ export default {
},
},
methods: {
- ...mapActions(['setPanelCollapsedStatus', 'setResizingStatus']),
- toggleFullbarCollapsed() {
- if (this.collapsed && this.collapsible) {
- this.setPanelCollapsedStatus({
- side: this.side,
- collapsed: !this.collapsed,
- });
- }
- },
+ ...mapActions(['setResizingStatus']),
},
maxSize: window.innerWidth / 2,
};
</script>
<template>
- <div
- :class="{
- 'is-collapsed': collapsed && collapsible,
- }"
- :style="panelStyle"
- class="multi-file-commit-panel"
- @click="toggleFullbarCollapsed"
- >
+ <div :style="panelStyle" class="multi-file-commit-panel">
<slot></slot>
<panel-resizer
:size.sync="width"
- :enabled="!collapsed"
:start-size="initialWidth"
:min-size="minSize"
:max-size="$options.maxSize"
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index fa2672aaece..ae8550cba76 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -78,6 +78,7 @@ export const commitItemIconMap = {
export const modalTypes = {
rename: 'rename',
tree: 'tree',
+ blob: 'blob',
};
export const commitActionTypes = {
diff --git a/app/assets/javascripts/ide/eventhub.js b/app/assets/javascripts/ide/eventhub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/ide/eventhub.js
+++ b/app/assets/javascripts/ide/eventhub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/ide/lib/diff/diff.js b/app/assets/javascripts/ide/lib/diff/diff.js
index 9b7ed68b893..29e29d7fcd3 100644
--- a/app/assets/javascripts/ide/lib/diff/diff.js
+++ b/app/assets/javascripts/ide/lib/diff/diff.js
@@ -14,13 +14,12 @@ export const computeDiff = (originalContent, newContent) => {
endLineNumber: lineNumber + change.count - 1,
});
} else if ('added' in change || 'removed' in change) {
- acc.push(
- Object.assign({}, change, {
- lineNumber,
- modified: undefined,
- endLineNumber: lineNumber + change.count - 1,
- }),
- );
+ acc.push({
+ ...change,
+ lineNumber,
+ modified: undefined,
+ endLineNumber: lineNumber + change.count - 1,
+ });
}
if (!change.removed) {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 3aff4d30d81..25224abd77c 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -7,8 +7,10 @@ import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import { themes } from './themes';
+import languages from './languages';
import keymap from './keymap.json';
import { clearDomElement } from '~/editor/utils';
+import { registerLanguages } from '../utils';
function setupThemes() {
themes.forEach(theme => {
@@ -37,6 +39,7 @@ export default class Editor {
};
setupThemes();
+ registerLanguages(...languages);
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
new file mode 100644
index 00000000000..0c85a1104fc
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -0,0 +1,5 @@
+import vue from './vue';
+
+const languages = [vue];
+
+export default languages;
diff --git a/app/assets/javascripts/ide/lib/languages/vue.js b/app/assets/javascripts/ide/lib/languages/vue.js
new file mode 100644
index 00000000000..b9ff5c5d776
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/vue.js
@@ -0,0 +1,306 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
+ *--------------------------------------------------------------------------------------------*/
+
+// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
+// Look for "vuejs template attributes" in this file for Vue specific syntax.
+
+import { languages } from 'monaco-editor';
+
+/* eslint-disable no-useless-escape */
+/* eslint-disable @gitlab/require-i18n-strings */
+
+const EMPTY_ELEMENTS = [
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'keygen',
+ 'link',
+ 'menuitem',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+];
+
+const conf = {
+ wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
+
+ comments: {
+ blockComment: ['{{!--', '--}}'],
+ },
+
+ brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']],
+
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ surroundingPairs: [
+ { open: '<', close: '>' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ onEnterRules: [
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ afterText: /^<\/(\w[\w\d]*)\s*>$/i,
+ action: { indentAction: languages.IndentAction.IndentOutdent },
+ },
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ action: { indentAction: languages.IndentAction.Indent },
+ },
+ ],
+};
+
+const language = {
+ defaultToken: '',
+ tokenPostfix: '',
+ // ignoreCase: true,
+
+ // The main tokenizer for our languages
+ tokenizer: {
+ root: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }],
+ [/<!DOCTYPE/, 'metatag.html', '@doctype'],
+ [/<!--/, 'comment.html', '@comment'],
+ [/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']],
+ [/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]],
+ [/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]],
+ [/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/</, 'delimiter.html'],
+ [/\{/, 'delimiter.html'],
+ [/[^<{]+/], // text
+ ],
+
+ doctype: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/[^>]+/, 'metatag.content.html'],
+ [/>/, 'metatag.html', '@pop'],
+ ],
+
+ comment: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/-->/, 'comment.html', '@pop'],
+ [/[^-]+/, 'comment.content.html'],
+ [/./, 'comment.content.html'],
+ ],
+
+ otherTag: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }],
+ [/\/?>/, 'delimiter.html', '@pop'],
+
+ // -- BEGIN vuejs template attributes
+ [/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'],
+ [/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'],
+
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+
+ [/[\w\-\.\:\[\]]+/, 'attribute.name'],
+ // -- END vuejs template attributes
+
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ ],
+
+ // -- BEGIN <script> tags handling
+
+ // After <script
+ script: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }],
+ [/type/, 'attribute.name', '@scriptAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(script\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <script ... type
+ scriptAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }],
+ [/=/, 'delimiter', '@scriptAfterTypeEquals'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type =
+ scriptAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type = $S2
+ scriptWithCustomType: [
+ [
+ /\{\{/,
+ { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' },
+ ],
+ [/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ scriptEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <script> tags handling
+
+ // -- BEGIN <style> tags handling
+
+ // After <style
+ style: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }],
+ [/type/, 'attribute.name', '@styleAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(style\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <style ... type
+ styleAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }],
+ [/=/, 'delimiter', '@styleAfterTypeEquals'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type =
+ styleAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type = $S2
+ styleWithCustomType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ styleEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <style> tags handling
+
+ handlebarsInSimpleState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsInEmbeddedState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsRoot: [
+ [/"[^"]*"/, 'string.handlebars'],
+ [/[#/][^\s}]+/, 'keyword.helper.handlebars'],
+ [/else\b/, 'keyword.helper.handlebars'],
+ [/[\s]+/],
+ [/[^}]/, 'variable.parameter.handlebars'],
+ ],
+ },
+};
+
+export default {
+ id: 'vue',
+ extensions: ['.vue'],
+ aliases: ['Vue', 'vue'],
+ mimetypes: ['text/x-vue-template'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/lib/themes/index.js b/app/assets/javascripts/ide/lib/themes/index.js
index 6ed9f6679a4..bb5be50576c 100644
--- a/app/assets/javascripts/ide/lib/themes/index.js
+++ b/app/assets/javascripts/ide/lib/themes/index.js
@@ -1,5 +1,9 @@
import white from './white';
import dark from './dark';
+import monokai from './monokai';
+import solarizedLight from './solarized_light';
+import solarizedDark from './solarized_dark';
+import none from './none';
export const themes = [
{
@@ -10,6 +14,22 @@ export const themes = [
name: 'dark',
data: dark,
},
+ {
+ name: 'solarized-light',
+ data: solarizedLight,
+ },
+ {
+ name: 'solarized-dark',
+ data: solarizedDark,
+ },
+ {
+ name: 'monokai',
+ data: monokai,
+ },
+ {
+ name: 'none',
+ data: none,
+ },
];
export const DEFAULT_THEME = 'white';
diff --git a/app/assets/javascripts/ide/lib/themes/monokai.js b/app/assets/javascripts/ide/lib/themes/monokai.js
new file mode 100644
index 00000000000..d7636574754
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/monokai.js
@@ -0,0 +1,169 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Tomorrow-Night.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+
+export default {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ foreground: '75715e',
+ token: 'comment',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'string',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'ae81ff',
+ token: 'constant.other',
+ },
+ {
+ foreground: 'f92672',
+ token: 'keyword',
+ },
+ {
+ foreground: 'f92672',
+ token: 'storage',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'storage.type',
+ },
+ {
+ foreground: 'a6e22e',
+ fontStyle: 'underline',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: 'a6e22e',
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ fontStyle: 'italic underline',
+ token: 'entity.other.inherited-class',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: 'fd971f',
+ fontStyle: 'italic',
+ token: 'variable.parameter',
+ },
+ {
+ foreground: 'f92672',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '66d9ef',
+ token: 'support.function',
+ },
+ {
+ foreground: '66d9ef',
+ token: 'support.constant',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'support.type',
+ },
+ {
+ foreground: '66d9ef',
+ fontStyle: 'italic',
+ token: 'support.class',
+ },
+ {
+ foreground: 'f8f8f0',
+ background: 'f92672',
+ token: 'invalid',
+ },
+ {
+ foreground: 'f8f8f0',
+ background: 'ae81ff',
+ token: 'invalid.deprecated',
+ },
+ {
+ foreground: 'cfcfc2',
+ token: 'meta.structure.dictionary.json string.quoted.double.json',
+ },
+ {
+ foreground: '75715e',
+ token: 'meta.diff',
+ },
+ {
+ foreground: '75715e',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'f92672',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'a6e22e',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'markup.changed',
+ },
+ {
+ foreground: 'ae81ffa0',
+ token: 'constant.numeric.line-number.find-in-files - match',
+ },
+ {
+ foreground: 'e6db74',
+ token: 'entity.name.filename.find-in-files',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#F8F8F2',
+ 'editor.background': '#272822',
+ 'editor.selectionBackground': '#49483E',
+ 'editor.lineHighlightBackground': '#3E3D32',
+ 'editorCursor.foreground': '#F8F8F0',
+ 'editorWhitespace.foreground': '#3B3A32',
+ 'editorIndentGuide.activeBackground': '#9D550FB0',
+ 'editor.selectionHighlightBorder': '#222218',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/none.js b/app/assets/javascripts/ide/lib/themes/none.js
new file mode 100644
index 00000000000..8e722c4ff88
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/none.js
@@ -0,0 +1,17 @@
+export default {
+ base: 'vs',
+ inherit: false,
+ rules: [],
+ colors: {
+ 'editor.foreground': '#2e2e2e',
+ 'editor.selectionBackground': '#aad6f8',
+ 'editor.lineHighlightBackground': '#fffeeb',
+ 'editorCursor.foreground': '#666666',
+ 'editorWhitespace.foreground': '#bbbbbb',
+
+ 'editorLineNumber.foreground': '#cccccc',
+ 'diffEditor.insertedTextBackground': '#a0f5b420',
+ 'diffEditor.removedTextBackground': '#f9d7dc20',
+ 'editorIndentGuide.activeBackground': '#cccccc',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_dark.js b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
new file mode 100644
index 00000000000..3c9414b9dc9
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/solarized_dark.js
@@ -0,0 +1,1110 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+export default {
+ base: 'vs-dark',
+ inherit: true,
+ rules: [
+ {
+ foreground: '586e75',
+ token: 'comment',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string',
+ },
+ {
+ foreground: '586e75',
+ token: 'string',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'string.regexp',
+ },
+ {
+ foreground: 'd33682',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.language',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.other',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword',
+ },
+ {
+ foreground: '859900',
+ token: 'storage',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.type.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.variable',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.preprocessor',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'support.function.construct',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.new',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.other',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.html',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.begin',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.tag.end',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '268bd2',
+ token: 'support.function',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.separator.continuation',
+ },
+ {
+ foreground: '859900',
+ token: 'support.type',
+ },
+ {
+ foreground: '859900',
+ token: 'support.class',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.type.exception',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.double',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.single',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.property-name.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.property-name.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'source.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.selector.css',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.section.property-list.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css constant.numeric.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.other.unit.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.color.rgb-value.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.important.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.constant.color',
+ },
+ {
+ foreground: '859900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.separator.key-value.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.terminator.rule.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.class.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-element.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-class.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.id.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.dom.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.function.js',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.square.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'meta.brace.round',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.begin.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.end.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.curly.js',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'entity.name.tag.doctype.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'meta.tag.sgml.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'string.quoted.double.doctype.identifiers-and-DTDs.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'entity.name.tag.script.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'source.css.embedded.html string.quoted.double.html',
+ },
+ {
+ foreground: 'cb4b16',
+ fontStyle: 'bold',
+ token: 'text.html.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.other.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.block.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.inline.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.structure.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'punctuation.separator.key-value.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic entity.other.attribute-name.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.html',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag.block.any.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html entity.name.tag.style.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'punctuation.definition.variable.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'meta.function.method.with-arguments.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'variable.language.ruby',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.def.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'meta.class.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.type.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.language.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.constant.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.symbol.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.php',
+ },
+ {
+ foreground: '839496',
+ token: 'text.html.ruby meta.tag.inline.any.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.begin',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'support.class.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-start.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-end.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.array.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.empty.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.end',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.php',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.new.php',
+ },
+ {
+ foreground: '839496',
+ token: 'keyword.operator.class',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'variable.other.property.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.modifier.extends.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.class.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.terminator.expression.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.other.inherited-class.php',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.name.function.php',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: '839496',
+ token: 'entity.name.type.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.static.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.object.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'keyword.other.phpdoc',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'source.php.embedded.block.html',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'storage.type.function.php',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.c.include',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.macro.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.define.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.name.function.preprocessor.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.end.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.C99.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.any-method.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'entity.name.function.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.c',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.c',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'dc322f',
+ background: 'eee8d5',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'cb4b16',
+ background: 'eee8d5',
+ token: 'markup.changed',
+ },
+ {
+ foreground: '219186',
+ background: 'eee8d5',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ token: 'text.html.markdown meta.dummy.line-break',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.markdown markup.raw.inline',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.restructuredtext markup.raw',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.package.exclude',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.remove',
+ },
+ {
+ foreground: '2aa198',
+ token: 'other.add',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.group.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.begin.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.end.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.latex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.group.braces.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.parameter.function.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.constant.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.character.math.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.label.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.general.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'variable.parameter.definition.label.latex',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.be.latex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.function.section.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.function.general.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'punctuation.definition.comment.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.percentage.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.ref.latex',
+ },
+ {
+ foreground: '586e75',
+ token: 'string.quoted.double.block.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.class.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.function.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.modifier.global.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.from.python',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.exception.python',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.builtin.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.other.normal.shell',
+ },
+ {
+ foreground: '268bd2',
+ token: 'source.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.for-in-loop.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'variable.other.loop.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.end.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.begin.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-block.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-body.shell',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.logical-expression.shell',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.import.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'storage.modifier.import.java',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.class.java storage.modifier.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'source.java comment.block',
+ },
+ {
+ foreground: '586e75',
+ token:
+ 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc',
+ },
+ {
+ foreground: 'b58900',
+ token: 'punctuation.definition.variable.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.readwrite.global.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.predefined.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.comparison.perl',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.perl',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.perl',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'constant.character.escape.perl',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.1.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.2.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.3.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.4.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.5.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.6.markdown',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'bold',
+ token: 'markup.bold.markdown',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'markup.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.bold.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.raw.markdown',
+ },
+ {
+ foreground: 'b58900',
+ token: 'markup.list.unnumbered.markdown',
+ },
+ {
+ foreground: '859900',
+ token: 'markup.list.numbered.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.block.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.inline.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'markup.quote.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.definition.blockquote.markdown',
+ },
+ {
+ foreground: 'd33682',
+ token: 'meta.separator.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'meta.image.inline.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'string.other.link.title.markdown',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'string.other.link.description.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.link.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.metadata.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.string.begin.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.string.end.markdown',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.definition.constant.markdown',
+ },
+ {
+ foreground: 'eee8d5',
+ background: 'eee8d5',
+ token: 'sublimelinter.notes',
+ },
+ {
+ foreground: '93a1a1',
+ background: '93a1a1',
+ token: 'sublimelinter.outline.illegal',
+ },
+ {
+ background: 'dc322f',
+ token: 'sublimelinter.underline.illegal',
+ },
+ {
+ foreground: '839496',
+ background: '839496',
+ token: 'sublimelinter.outline.warning',
+ },
+ {
+ background: 'b58900',
+ token: 'sublimelinter.underline.warning',
+ },
+ {
+ foreground: '657b83',
+ background: '657b83',
+ token: 'sublimelinter.outline.violation',
+ },
+ {
+ background: 'cb4b16',
+ token: 'sublimelinter.underline.violation',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#839496',
+ 'editor.background': '#002B36',
+ 'editor.selectionBackground': '#073642',
+ 'editor.lineHighlightBackground': '#073642',
+ 'editorCursor.foreground': '#819090',
+ 'editorWhitespace.foreground': '#073642',
+ },
+};
diff --git a/app/assets/javascripts/ide/lib/themes/solarized_light.js b/app/assets/javascripts/ide/lib/themes/solarized_light.js
new file mode 100644
index 00000000000..b7bfcf33b0f
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/themes/solarized_light.js
@@ -0,0 +1,1101 @@
+/*
+
+https://github.com/brijeshb42/monaco-themes/blob/master/themes/Solarized-dark.json
+
+The MIT License (MIT)
+
+Copyright (c) Brijesh Bittu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+export default {
+ base: 'vs',
+ inherit: true,
+ rules: [
+ {
+ foreground: '93a1a1',
+ token: 'comment',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string',
+ },
+ {
+ foreground: '586e75',
+ token: 'string',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'string.regexp',
+ },
+ {
+ foreground: 'd33682',
+ token: 'constant.numeric',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.language',
+ },
+ {
+ foreground: '268bd2',
+ token: 'variable.other',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword',
+ },
+ {
+ foreground: '073642',
+ fontStyle: 'bold',
+ token: 'storage',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.type.class',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.variable',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'constant.language',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.preprocessor',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'support.function.construct',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.new',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.character',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'constant.other',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.html',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.begin',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.tag.end',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.other.attribute-name',
+ },
+ {
+ foreground: '268bd2',
+ token: 'support.function',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.separator.continuation',
+ },
+ {
+ foreground: '859900',
+ token: 'support.type',
+ },
+ {
+ foreground: '859900',
+ token: 'support.class',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.type.exception',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.double',
+ },
+ {
+ foreground: '2aa198',
+ token: 'string.quoted.single',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.property-name.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.property-name.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'source.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.selector.css',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.section.property-list.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css constant.numeric.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.other.unit.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.color.rgb-value.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.property-value.css',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.other.important.css',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.constant.color',
+ },
+ {
+ foreground: '859900',
+ token: 'entity.name.tag.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.separator.key-value.css',
+ },
+ {
+ foreground: '586e75',
+ token: 'punctuation.terminator.rule.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.class.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-element.css',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.other.attribute-name.pseudo-class.css',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.other.attribute-name.id.css',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.function.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.dom.js',
+ },
+ {
+ foreground: 'b58900',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.function.js',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.square.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'storage.type.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'meta.brace.round',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.begin.js',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'punctuation.definition.parameters.end.js',
+ },
+ {
+ foreground: '268bd2',
+ token: 'meta.brace.curly.js',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'entity.name.tag.doctype.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'meta.tag.sgml.html',
+ },
+ {
+ foreground: '93a1a1',
+ fontStyle: 'italic',
+ token: 'string.quoted.double.doctype.identifiers-and-DTDs.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'entity.name.tag.script.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'source.css.embedded.html string.quoted.double.html',
+ },
+ {
+ foreground: 'cb4b16',
+ fontStyle: 'bold',
+ token: 'text.html.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.other.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.block.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.inline.any',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic meta.tag.structure.any.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic source.js.embedded.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'punctuation.separator.key-value.html',
+ },
+ {
+ foreground: '657b83',
+ token: 'text.html.basic entity.other.attribute-name.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.basic meta.tag.structure.any.html punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.html',
+ },
+ {
+ foreground: '268bd2',
+ fontStyle: 'bold',
+ token: 'entity.name.tag.block.any.html',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html entity.name.tag.style.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'source.css.embedded.html',
+ },
+ {
+ foreground: '839496',
+ fontStyle: 'italic',
+ token: 'comment.block.html',
+ },
+ {
+ foreground: '268bd2',
+ token: 'punctuation.definition.variable.ruby',
+ },
+ {
+ foreground: '657b83',
+ token: 'meta.function.method.with-arguments.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'variable.language.ruby',
+ },
+ {
+ foreground: '268bd2',
+ token: 'entity.name.function.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: '859900',
+ fontStyle: 'bold',
+ token: 'keyword.control.def.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'meta.class.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'entity.name.type.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.control.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.class.ruby',
+ },
+ {
+ foreground: '859900',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.language.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.ruby',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.constant.ruby',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.symbol.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.embedded.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.ruby',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.special-method.ruby',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.php',
+ },
+ {
+ foreground: '839496',
+ token: 'text.html.ruby meta.tag.inline.any.html',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.begin',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.ruby punctuation.definition.string.end',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.begin',
+ },
+ {
+ foreground: '839496',
+ token: 'punctuation.definition.string.end',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-start.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'keyword.operator.index-end.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.array.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.array.empty.php support.function.construct.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.begin',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.array.end',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.php',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.new.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.class.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'keyword.operator.class',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'variable.other.property.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.modifier.extends.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.class.php',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.class.php',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.other.inherited-class.php',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'entity.name.function.php',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.construct.php',
+ },
+ {
+ foreground: '839496',
+ token: 'entity.name.type.class.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.static.php',
+ },
+ {
+ foreground: '839496',
+ token: 'meta.function-call.object.php',
+ },
+ {
+ foreground: '93a1a1',
+ token: 'keyword.other.phpdoc',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'source.php.embedded.block.html',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'storage.type.function.php',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.numeric.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.c.include',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'meta.preprocessor.macro.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.define.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.include.c',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'entity.name.function.preprocessor.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include string.quoted.other.lt-gt.include.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'meta.preprocessor.c.include punctuation.definition.string.end.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.C99.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'support.function.any-method.c',
+ },
+ {
+ foreground: '586e75',
+ token: 'entity.name.function.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.c',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.c',
+ },
+ {
+ foreground: 'b58900',
+ token: 'storage.type.c',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'b58900',
+ fontStyle: 'italic',
+ token: 'meta.diff.header',
+ },
+ {
+ foreground: 'dc322f',
+ background: 'eee8d5',
+ token: 'markup.deleted',
+ },
+ {
+ foreground: 'cb4b16',
+ background: 'eee8d5',
+ token: 'markup.changed',
+ },
+ {
+ foreground: '219186',
+ background: 'eee8d5',
+ token: 'markup.inserted',
+ },
+ {
+ foreground: 'e0eddd',
+ background: 'a57706',
+ token: 'text.html.markdown meta.dummy.line-break',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.html.markdown markup.raw.inline',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.restructuredtext markup.raw',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.package.exclude',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'other.remove',
+ },
+ {
+ foreground: '2aa198',
+ token: 'other.add',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.section.group.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.begin.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.end.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.arguments.latex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.group.braces.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.parameter.function.latex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.constant.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.other.general.math.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'constant.character.math.tex',
+ },
+ {
+ foreground: 'b58900',
+ token: 'string.other.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.label.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'text.tex.latex constant.other.general.math.tex',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'variable.parameter.definition.label.latex',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.be.latex',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'support.function.section.latex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'support.function.general.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'punctuation.definition.comment.tex',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.percentage.tex',
+ },
+ {
+ foreground: '2aa198',
+ token: 'keyword.control.ref.latex',
+ },
+ {
+ foreground: '586e75',
+ token: 'string.quoted.double.block.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.class.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.type.function.python',
+ },
+ {
+ foreground: '859900',
+ token: 'storage.modifier.global.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.python',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.control.import.from.python',
+ },
+ {
+ foreground: 'b58900',
+ token: 'support.type.exception.python',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.builtin.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'variable.other.normal.shell',
+ },
+ {
+ foreground: '268bd2',
+ token: 'source.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.for-in-loop.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'variable.other.loop.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.end.shell',
+ },
+ {
+ foreground: '859900',
+ token: 'punctuation.definition.string.begin.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-block.shell',
+ },
+ {
+ foreground: '586e75',
+ token: 'meta.scope.case-body.shell',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.logical-expression.shell',
+ },
+ {
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.shell',
+ },
+ {
+ foreground: 'cb4b16',
+ token: 'keyword.other.import.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'storage.modifier.import.java',
+ },
+ {
+ foreground: 'b58900',
+ token: 'meta.class.java storage.modifier.java',
+ },
+ {
+ foreground: '586e75',
+ token: 'source.java comment.block',
+ },
+ {
+ foreground: '586e75',
+ token:
+ 'comment.block meta.documentation.tag.param.javadoc keyword.other.documentation.param.javadoc',
+ },
+ {
+ foreground: 'b58900',
+ token: 'punctuation.definition.variable.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.readwrite.global.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'variable.other.predefined.perl',
+ },
+ {
+ foreground: 'b58900',
+ token: 'keyword.operator.comparison.perl',
+ },
+ {
+ foreground: '859900',
+ token: 'support.function.perl',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'comment.line.number-sign.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.begin.perl',
+ },
+ {
+ foreground: '2aa198',
+ token: 'punctuation.definition.string.end.perl',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'constant.character.escape.perl',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.1.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.2.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.3.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.4.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.5.markdown',
+ },
+ {
+ foreground: '268bd2',
+ token: 'markup.heading.6.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'bold',
+ token: 'markup.bold.markdown',
+ },
+ {
+ foreground: '586e75',
+ fontStyle: 'italic',
+ token: 'markup.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.bold.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.italic.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.raw.markdown',
+ },
+ {
+ foreground: 'b58900',
+ token: 'markup.list.unnumbered.markdown',
+ },
+ {
+ foreground: '859900',
+ token: 'markup.list.numbered.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.block.markdown',
+ },
+ {
+ foreground: '2aa198',
+ token: 'markup.raw.inline.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'markup.quote.markdown',
+ },
+ {
+ foreground: '6c71c4',
+ token: 'punctuation.definition.blockquote.markdown',
+ },
+ {
+ foreground: 'd33682',
+ token: 'meta.separator.markdown',
+ },
+ {
+ foreground: '839496',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: '839496',
+ token: 'markup.underline.link.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'meta.link.inet.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'meta.link.email.lt-gt.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.begin.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.string.end.markdown',
+ },
+ {
+ foreground: 'dc322f',
+ token: 'punctuation.definition.link.markdown',
+ },
+ {
+ foreground: '6a8187',
+ token: 'text.plain',
+ },
+ {
+ foreground: 'eee8d5',
+ background: 'eee8d5',
+ token: 'sublimelinter.notes',
+ },
+ {
+ foreground: '93a1a1',
+ background: '93a1a1',
+ token: 'sublimelinter.outline.illegal',
+ },
+ {
+ background: 'dc322f',
+ token: 'sublimelinter.underline.illegal',
+ },
+ {
+ foreground: '839496',
+ background: '839496',
+ token: 'sublimelinter.outline.warning',
+ },
+ {
+ background: 'b58900',
+ token: 'sublimelinter.underline.warning',
+ },
+ {
+ foreground: '657b83',
+ background: '657b83',
+ token: 'sublimelinter.outline.violation',
+ },
+ {
+ background: 'cb4b16',
+ token: 'sublimelinter.underline.violation',
+ },
+ ],
+ colors: {
+ 'editor.foreground': '#586E75',
+ 'editor.background': '#FDF6E3',
+ 'editor.selectionBackground': '#EEE8D5',
+ 'editor.lineHighlightBackground': '#EEE8D5',
+ 'editorCursor.foreground': '#000000',
+ 'editorWhitespace.foreground': '#EAE3C9',
+ },
+};
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 3adf0cf073f..1767d961259 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -88,12 +88,16 @@ export default {
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
- getFiles(projectUrl, ref) {
- const url = `${projectUrl}/-/files/${ref}`;
+ getFiles(projectPath, ref) {
+ const url = `${gon.relative_url_root}/${projectPath}/-/files/${ref}`;
return axios.get(url, { params: { format: 'json' } });
},
lastCommitPipelines({ getters }) {
const commitSha = getters.lastCommit.id;
return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha);
},
+ pingUsage(projectPath) {
+ const url = `${gon.relative_url_root}/${projectPath}/usage_ping/web_ide_pipelines_count`;
+ return axios.post(url);
+ },
};
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 04cf0ad53d5..e32b5ac7bdc 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -1,6 +1,5 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
@@ -25,14 +24,6 @@ export const closeAllFiles = ({ state, dispatch }) => {
state.openFiles.forEach(file => dispatch('closeFile', file));
};
-export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
- if (side === 'left') {
- commit(types.SET_LEFT_PANEL_COLLAPSED, collapsed);
- } else {
- commit(types.SET_RIGHT_PANEL_COLLAPSED, collapsed);
- }
-};
-
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
@@ -176,13 +167,6 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) =>
commit(types.SET_ERROR_MESSAGE, errorMessage);
-export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
- commit(types.OPEN_NEW_ENTRY_MODAL, { type, path });
-
- // open the modal manually so we don't mess around with dropdown/rows
- $('#ide-new-entry').modal('show');
-};
-
export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path];
const { prevPath, prevName, prevParentPath } = entry;
@@ -296,7 +280,7 @@ export const getBranchData = ({ commit, state }, { projectId, branchId, force =
sprintf(
__('Branch not loaded - %{branchId}'),
{
- branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`,
+ branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`,
},
false,
),
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index ae3829dc35e..6c8fb9f90aa 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -1,4 +1,4 @@
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import flash from '~/flash';
import { __, sprintf } from '~/locale';
import service from '../../services';
@@ -73,7 +73,7 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
text: sprintf(
__("Branch %{branchName} was not found in this project's repository."),
{
- branchName: `<strong>${esc(branchId)}</strong>`,
+ branchName: `<strong>${escape(branchId)}</strong>`,
},
false,
),
@@ -162,7 +162,7 @@ export const openBranch = ({ dispatch }, { projectId, branchId, basePath }) => {
sprintf(
__('An error occurred while getting files for - %{branchId}'),
{
- branchId: `<strong>${esc(projectId)}/${esc(branchId)}</strong>`,
+ branchId: `<strong>${escape(projectId)}/${escape(branchId)}</strong>`,
},
false,
),
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 7d48f0adc4c..1ca608f1287 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -59,7 +59,7 @@ export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
service
- .getFiles(selectedProject.web_url, ref)
+ .getFiles(selectedProject.path_with_namespace, ref)
.then(({ data }) => {
const { entries, treeList } = decorateFiles({
data,
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 505daa8834d..592c7e15918 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -1,6 +1,6 @@
-import $ from 'jquery';
import { sprintf, __ } from '~/locale';
import flash from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
import * as rootTypes from '../../mutation_types';
import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import router from '../../../ide_router';
@@ -215,25 +215,23 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
);
})
.catch(err => {
- if (err.response.status === 400) {
- $('#ide-create-branch-modal').modal('show');
- } else {
- dispatch(
- 'setErrorMessage',
- {
- text: __('An error occurred while committing your changes.'),
- action: () =>
- dispatch('commitChanges').then(() =>
- dispatch('setErrorMessage', null, { root: true }),
- ),
- actionText: __('Please try again'),
- },
- { root: true },
- );
- window.dispatchEvent(new Event('resize'));
- }
-
commit(types.UPDATE_LOADING, false);
+
+ // don't catch bad request errors, let the view handle them
+ if (err.response.status === httpStatusCodes.BAD_REQUEST) throw err;
+
+ dispatch(
+ 'setErrorMessage',
+ {
+ text: __('An error occurred while committing your changes.'),
+ action: () =>
+ dispatch('commitChanges').then(() => dispatch('setErrorMessage', null, { root: true })),
+ actionText: __('Please try again'),
+ },
+ { root: true },
+ );
+
+ window.dispatchEvent(new Event('resize'));
});
};
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 78831bdf022..5c78bfefa04 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -2,8 +2,6 @@ export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
export const TOGGLE_LOADING = 'TOGGLE_LOADING';
export const SET_LAST_COMMIT_DATA = 'SET_LAST_COMMIT_DATA';
export const SET_LAST_COMMIT_MSG = 'SET_LAST_COMMIT_MSG';
-export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
-export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
export const SET_LINKS = 'SET_LINKS';
@@ -73,7 +71,6 @@ export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
-export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';
export const DELETE_ENTRY = 'DELETE_ENTRY';
export const RENAME_ENTRY = 'RENAME_ENTRY';
export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 5d567d9b169..12ac10df206 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -29,16 +29,6 @@ export default {
});
}
},
- [types.SET_LEFT_PANEL_COLLAPSED](state, collapsed) {
- Object.assign(state, {
- leftPanelCollapsed: collapsed,
- });
- },
- [types.SET_RIGHT_PANEL_COLLAPSED](state, collapsed) {
- Object.assign(state, {
- rightPanelCollapsed: collapsed,
- });
- },
[types.SET_RESIZING_STATUS](state, resizing) {
Object.assign(state, {
panelResizing: resizing,
@@ -192,15 +182,6 @@ export default {
[types.SET_ERROR_MESSAGE](state, errorMessage) {
Object.assign(state, { errorMessage });
},
- [types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
- Object.assign(state, {
- entryModal: {
- type,
- path,
- entry: { ...state.entries[path] },
- },
- });
- },
[types.DELETE_ENTRY](state, path) {
const entry = state.entries[path];
const { tempFile = false } = entry;
diff --git a/app/assets/javascripts/ide/stores/mutations/project.js b/app/assets/javascripts/ide/stores/mutations/project.js
index 9230f3839c1..034fdad4305 100644
--- a/app/assets/javascripts/ide/stores/mutations/project.js
+++ b/app/assets/javascripts/ide/stores/mutations/project.js
@@ -16,9 +16,7 @@ export default {
});
Object.assign(state, {
- projects: Object.assign({}, state.projects, {
- [projectPath]: project,
- }),
+ projects: { ...state.projects, [projectPath]: project },
});
},
[types.TOGGLE_EMPTY_STATE](state, { projectPath, value }) {
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index 359943b4ab7..c8f14a680c2 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -14,12 +14,13 @@ export default {
},
[types.CREATE_TREE](state, { treePath }) {
Object.assign(state, {
- trees: Object.assign({}, state.trees, {
+ trees: {
+ ...state.trees,
[treePath]: {
tree: [],
loading: true,
},
- }),
+ },
});
},
[types.SET_DIRECTORY_DATA](state, { data, treePath }) {
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index 0fd6a448283..0c95c22e8f8 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -15,8 +15,6 @@ export default () => ({
parentTreeUrl: '',
trees: {},
projects: {},
- leftPanelCollapsed: false,
- rightPanelCollapsed: false,
panelResizing: false,
entries: {},
viewer: viewerTypes.edit,
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 4e5b01596d8..56671142bd4 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,4 +1,5 @@
import { commitActionTypes, FILE_VIEW_MODE_EDITOR } from '../constants';
+import { relativePathToAbsolute, isAbsolute, isRootRelative } from '~/lib/utils/url_utility';
export const dataStructure = () => ({
id: '',
@@ -274,3 +275,45 @@ export const pathsAreEqual = (a, b) => {
// if the contents of a file dont end with a newline, this function adds a newline
export const addFinalNewlineIfNeeded = content =>
content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content;
+
+export function extractMarkdownImagesFromEntries(mdFile, entries) {
+ /**
+ * Regex to identify an image tag in markdown, like:
+ *
+ * ![img alt goes here](/img.png)
+ * ![img alt](../img 1/img.png "my image title")
+ * ![img alt](https://gitlab.com/assets/logo.svg "title here")
+ *
+ */
+ const reMdImage = /!\[([^\]]*)\]\((.*?)(?:(?="|\))"([^"]*)")?\)/gi;
+ const prefix = 'gl_md_img_';
+ const images = {};
+
+ let content = mdFile.content || mdFile.raw;
+ let i = 0;
+
+ content = content.replace(reMdImage, (_, alt, path, title) => {
+ const imagePath = (isRootRelative(path) ? path : relativePathToAbsolute(path, mdFile.path))
+ .substr(1)
+ .trim();
+
+ const imageContent = entries[imagePath]?.content || entries[imagePath]?.raw;
+
+ if (!isAbsolute(path) && imageContent) {
+ const ext = path.includes('.')
+ ? path
+ .split('.')
+ .pop()
+ .trim()
+ : 'jpeg';
+ const src = `data:image/${ext};base64,${imageContent}`;
+ i += 1;
+ const key = `{{${prefix}${i}}}`;
+ images[key] = { alt, src, title };
+ return key;
+ }
+ return title ? `![${alt}](${path}"${title}")` : `![${alt}](${path})`;
+ });
+
+ return { content, images };
+}
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 64ac539a4ff..1ea2b199237 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -68,3 +68,19 @@ export const createPathWithExt = p => {
return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
};
+
+export const trimPathComponents = path =>
+ path
+ .split('/')
+ .map(s => s.trim())
+ .join('/');
+
+export function registerLanguages(def, ...defs) {
+ if (defs.length) defs.forEach(lang => registerLanguages(lang));
+
+ const languageId = def.id;
+
+ languages.register(def);
+ languages.setMonarchTokensProvider(languageId, def.language);
+ languages.setLanguageConfiguration(languageId, def.conf);
+}
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index 7921650e8a0..229e0a62c51 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -15,7 +15,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
- buttonEl.innerText = badgeText;
+ buttonEl.textContent = badgeText;
containerEl.appendChild(buttonEl);
}
@@ -32,6 +32,6 @@ export function addAvatarBadge(el, event) {
// Add badge to new comment
const avatarBadgeEl = el.querySelector(`#${noteId} .badge`);
- avatarBadgeEl.innerText = badgeNumber;
+ avatarBadgeEl.textContent = badgeNumber;
avatarBadgeEl.classList.remove('hidden');
}
diff --git a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
index df3d90cff68..deaef686f59 100644
--- a/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/comment_indicator_helper.js
@@ -32,9 +32,7 @@ export function removeCommentIndicator(imageFrameEl) {
commentIndicatorEl.remove();
}
- return Object.assign({}, meta, {
- removed: willRemove,
- });
+ return { ...meta, removed: willRemove };
}
export function showCommentIndicator(imageFrameEl, coordinate) {
diff --git a/app/assets/javascripts/image_diff/helpers/dom_helper.js b/app/assets/javascripts/image_diff/helpers/dom_helper.js
index a319bcccb8f..a61e5f01f9b 100644
--- a/app/assets/javascripts/image_diff/helpers/dom_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/dom_helper.js
@@ -4,24 +4,19 @@ export function setPositionDataAttribute(el, options) {
const { x, y, width, height } = options;
const { position } = el.dataset;
- const positionObject = Object.assign({}, JSON.parse(position), {
- x,
- y,
- width,
- height,
- });
+ const positionObject = { ...JSON.parse(position), x, y, width, height };
el.setAttribute('data-position', JSON.stringify(positionObject));
}
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge');
- avatarBadgeEl.innerText = newBadgeNumber;
+ avatarBadgeEl.textContent = newBadgeNumber;
}
export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
const discussionBadgeEl = discussionEl.querySelector('.badge');
- discussionBadgeEl.innerText = newBadgeNumber;
+ discussionBadgeEl.textContent = newBadgeNumber;
}
export function toggleCollapsed(event) {
diff --git a/app/assets/javascripts/image_diff/image_diff.js b/app/assets/javascripts/image_diff/image_diff.js
index 26c1b0ec7be..079f4a63f6e 100644
--- a/app/assets/javascripts/image_diff/image_diff.js
+++ b/app/assets/javascripts/image_diff/image_diff.js
@@ -75,9 +75,7 @@ export default class ImageDiff {
if (this.renderCommentBadge) {
imageDiffHelper.addImageCommentBadge(this.imageFrameEl, options);
} else {
- const numberBadgeOptions = Object.assign({}, options, {
- badgeText: index + 1,
- });
+ const numberBadgeOptions = { ...options, badgeText: index + 1 };
imageDiffHelper.addImageBadge(this.imageFrameEl, numberBadgeOptions);
}
@@ -130,7 +128,7 @@ export default class ImageDiff {
const updatedBadgeNumber = index;
const discussionEl = this.el.querySelector(`#discussion_${discussionId}`);
- imageBadgeEls[index].innerText = updatedBadgeNumber;
+ imageBadgeEls[index].textContent = updatedBadgeNumber;
imageDiffHelper.updateDiscussionBadgeNumber(discussionEl, updatedBadgeNumber);
imageDiffHelper.updateDiscussionAvatarBadgeNumber(discussionEl, updatedBadgeNumber);
diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/import_projects/event_hub.js
+++ b/app/assets/javascripts/import_projects/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js
index 1ffd5c61282..d6b519f7eac 100644
--- a/app/assets/javascripts/importer_status.js
+++ b/app/assets/javascripts/importer_status.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import _ from 'underscore';
+import { escape } from 'lodash';
import { __, sprintf } from './locale';
import axios from './lib/utils/axios_utils';
import flash from './flash';
@@ -73,9 +73,9 @@ class ImporterStatus {
const connectingVerb = this.ciCdOnly ? __('connecting') : __('importing');
job.find('.import-actions').html(
sprintf(
- _.escape(__('%{loadingIcon} Started')),
+ escape(__('%{loadingIcon} Started')),
{
- loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${_.escape(
+ loadingIcon: `<i class="fa fa-spinner fa-spin" aria-label="${escape(
connectingVerb,
)}"></i>`,
},
diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
index 2b0aa2586e4..8b95b04d93c 100644
--- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue
+++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue
@@ -12,10 +12,6 @@ export default {
type: Boolean,
required: true,
},
- disabled: {
- type: Boolean,
- required: true,
- },
},
data() {
return {
@@ -41,12 +37,7 @@ export default {
<div class="form-group row" role="group">
<label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label>
<div class="col-sm-10 pt-1">
- <gl-toggle
- v-model="activated"
- :disabled="disabled"
- name="service[active]"
- @change="onToggle"
- />
+ <gl-toggle v-model="activated" name="service[active]" @change="onToggle" />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue
new file mode 100644
index 00000000000..fbe58c30b13
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue
@@ -0,0 +1,50 @@
+<script>
+import ActiveToggle from './active_toggle.vue';
+import JiraTriggerFields from './jira_trigger_fields.vue';
+import TriggerFields from './trigger_fields.vue';
+
+export default {
+ name: 'IntegrationForm',
+ components: {
+ ActiveToggle,
+ JiraTriggerFields,
+ TriggerFields,
+ },
+ props: {
+ activeToggleProps: {
+ type: Object,
+ required: true,
+ },
+ showActive: {
+ type: Boolean,
+ required: true,
+ },
+ triggerFieldsProps: {
+ type: Object,
+ required: true,
+ },
+ triggerEvents: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ isJira() {
+ return this.type === 'jira';
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <active-toggle v-if="showActive" v-bind="activeToggleProps" />
+ <jira-trigger-fields v-if="isJira" v-bind="triggerFieldsProps" />
+ <trigger-fields v-else-if="triggerEvents.length" :events="triggerEvents" :type="type" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
new file mode 100644
index 00000000000..70278e401ce
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlFormCheckbox, GlFormRadio } from '@gitlab/ui';
+
+export default {
+ name: 'JiraTriggerFields',
+ components: {
+ GlFormCheckbox,
+ GlFormRadio,
+ },
+ props: {
+ initialTriggerCommit: {
+ type: Boolean,
+ required: true,
+ },
+ initialTriggerMergeRequest: {
+ type: Boolean,
+ required: true,
+ },
+ initialEnableComments: {
+ type: Boolean,
+ required: true,
+ },
+ initialCommentDetail: {
+ type: String,
+ required: false,
+ default: 'standard',
+ },
+ },
+ data() {
+ return {
+ triggerCommit: this.initialTriggerCommit,
+ triggerMergeRequest: this.initialTriggerMergeRequest,
+ enableComments: this.initialEnableComments,
+ commentDetail: this.initialCommentDetail,
+ };
+ },
+};
+</script>
+
+<template>
+ <div class="form-group row pt-2" role="group">
+ <label for="service[trigger]" class="col-form-label col-sm-2 pt-0">{{ __('Trigger') }}</label>
+ <div class="col-sm-10">
+ <label class="weight-normal mb-2">
+ {{
+ s__(
+ 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.',
+ )
+ }}
+ </label>
+
+ <input name="service[commit_events]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="triggerCommit" name="service[commit_events]">
+ {{ __('Commit') }}
+ </gl-form-checkbox>
+
+ <input name="service[merge_requests_events]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="triggerMergeRequest" name="service[merge_requests_events]">
+ {{ __('Merge request') }}
+ </gl-form-checkbox>
+
+ <div
+ v-show="triggerCommit || triggerMergeRequest"
+ class="mt-4"
+ data-testid="comment-settings"
+ >
+ <label>
+ {{ s__('Integrations|Comment settings:') }}
+ </label>
+ <input name="service[comment_on_event_enabled]" type="hidden" value="false" />
+ <gl-form-checkbox v-model="enableComments" name="service[comment_on_event_enabled]">
+ {{ s__('Integrations|Enable comments') }}
+ </gl-form-checkbox>
+
+ <div v-show="enableComments" class="mt-4" data-testid="comment-detail">
+ <label>
+ {{ s__('Integrations|Comment detail:') }}
+ </label>
+ <gl-form-radio v-model="commentDetail" value="standard" name="service[comment_detail]">
+ {{ s__('Integrations|Standard') }}
+ <template #help>
+ {{ s__('Integrations|Includes commit title and branch') }}
+ </template>
+ </gl-form-radio>
+ <gl-form-radio v-model="commentDetail" value="all_details" name="service[comment_detail]">
+ {{ s__('Integrations|All details') }}
+ <template #help>
+ {{
+ s__(
+ 'Integrations|Includes Standard plus entire commit message, commit hash, and issue IDs',
+ )
+ }}
+ </template>
+ </gl-form-radio>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
new file mode 100644
index 00000000000..531490ae40c
--- /dev/null
+++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue
@@ -0,0 +1,73 @@
+<script>
+import { startCase } from 'lodash';
+import { __ } from '~/locale';
+import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui';
+
+const typeWithPlaceholder = {
+ SLACK: 'slack',
+ MATTERMOST: 'mattermost',
+};
+
+const placeholderForType = {
+ [typeWithPlaceholder.SLACK]: __('Slack channels (e.g. general, development)'),
+ [typeWithPlaceholder.MATTERMOST]: __('Channel handle (e.g. town-square)'),
+};
+
+export default {
+ name: 'TriggerFields',
+ components: {
+ GlFormGroup,
+ GlFormCheckbox,
+ GlFormInput,
+ },
+ props: {
+ events: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ placeholder() {
+ return placeholderForType[this.type];
+ },
+ },
+ methods: {
+ checkboxName(name) {
+ return `service[${name}]`;
+ },
+ fieldName(name) {
+ return `service[${name}]`;
+ },
+ startCase,
+ },
+};
+</script>
+
+<template>
+ <gl-form-group
+ class="gl-pt-3"
+ :label="__('Trigger')"
+ label-for="trigger-fields"
+ data-testid="trigger-fields-group"
+ >
+ <div id="trigger-fields" class="gl-pt-3">
+ <gl-form-group v-for="event in events" :key="event.title" :description="event.description">
+ <input :name="checkboxName(event.name)" type="hidden" value="false" />
+ <gl-form-checkbox v-model="event.value" :name="checkboxName(event.name)">
+ {{ startCase(event.title) }}
+ </gl-form-checkbox>
+ <gl-form-input
+ v-if="event.field"
+ v-model="event.field.value"
+ :name="fieldName(event.field.name)"
+ :placeholder="placeholder"
+ />
+ </gl-form-group>
+ </div>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/integrations/edit/event_hub.js b/app/assets/javascripts/integrations/edit/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/integrations/edit/event_hub.js
+++ b/app/assets/javascripts/integrations/edit/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/integrations/edit/index.js b/app/assets/javascripts/integrations/edit/index.js
index a2ba581d429..2ae1342a558 100644
--- a/app/assets/javascripts/integrations/edit/index.js
+++ b/app/assets/javascripts/integrations/edit/index.js
@@ -1,28 +1,46 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import ActiveToggle from './components/active_toggle.vue';
+import IntegrationForm from './components/integration_form.vue';
export default el => {
if (!el) {
return null;
}
- const { showActive: showActiveStr, activated: activatedStr, disabled: disabledStr } = el.dataset;
- const showActive = parseBoolean(showActiveStr);
- const activated = parseBoolean(activatedStr);
- const disabled = parseBoolean(disabledStr);
-
- if (!showActive) {
- return null;
+ function parseBooleanInData(data) {
+ const result = {};
+ Object.entries(data).forEach(([key, value]) => {
+ result[key] = parseBoolean(value);
+ });
+ return result;
}
+ const { type, commentDetail, triggerEvents, ...booleanAttributes } = el.dataset;
+ const {
+ showActive,
+ activated,
+ commitEvents,
+ mergeRequestEvents,
+ enableComments,
+ } = parseBooleanInData(booleanAttributes);
+
return new Vue({
el,
render(createElement) {
- return createElement(ActiveToggle, {
+ return createElement(IntegrationForm, {
props: {
- initialActivated: activated,
- disabled,
+ activeToggleProps: {
+ initialActivated: activated,
+ },
+ showActive,
+ type,
+ triggerFieldsProps: {
+ initialTriggerCommit: commitEvents,
+ initialTriggerMergeRequest: mergeRequestEvents,
+ initialEnableComments: enableComments,
+ initialCommentDetail: commentDetail,
+ },
+ triggerEvents: JSON.parse(triggerEvents),
},
});
},
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index 45de287d44d..95e10cc75cc 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -1,7 +1,7 @@
/* eslint-disable consistent-return, func-names, array-callback-return */
import $ from 'jquery';
-import _ from 'underscore';
+import { intersection } from 'lodash';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
import { __ } from './locale';
@@ -111,7 +111,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -120,7 +120,7 @@ export default {
this.getElement('.selected-issuable:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
- return _.intersection.apply(this, labelIds);
+ return intersection.apply(this, labelIds);
},
// From issuable's initial bulk selection
@@ -144,7 +144,7 @@ export default {
// Add uniqueIds to add it as argument for _.intersection
labelIds.unshift(uniqueIds);
// Return IDs that are present but not in all selected issueables
- return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
+ return uniqueIds.filter(x => !intersection.apply(this, labelIds).includes(x));
},
getElement(selector) {
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index bd6e8433544..50562688c53 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,7 +1,7 @@
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
-import { property } from 'underscore';
+import { property } from 'lodash';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
new file mode 100644
index 00000000000..fe01d2c2e78
--- /dev/null
+++ b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql
@@ -0,0 +1,15 @@
+#import "~/graphql_shared/fragments/author.fragment.graphql"
+
+query getProjectIssue($iid: String!, $fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ issue(iid: $iid) {
+ assignees {
+ nodes {
+ ...Author
+ id
+ state
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
new file mode 100644
index 00000000000..27a04da9541
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/components/issuable_list_root_app.vue
@@ -0,0 +1,96 @@
+<script>
+import { GlAlert, GlLabel } from '@gitlab/ui';
+import getIssuesListDetailsQuery from '../queries/get_issues_list_details.query.graphql';
+import { calculateJiraImportLabel, isFinished, isInProgress } from '~/jira_import/utils';
+
+export default {
+ name: 'IssuableListRoot',
+ components: {
+ GlAlert,
+ GlLabel,
+ },
+ props: {
+ canEdit: {
+ type: Boolean,
+ required: true,
+ },
+ isJiraConfigured: {
+ type: Boolean,
+ required: true,
+ },
+ issuesPath: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isFinishedAlertShowing: true,
+ isInProgressAlertShowing: true,
+ jiraImport: {},
+ };
+ },
+ apollo: {
+ jiraImport: {
+ query: getIssuesListDetailsQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ };
+ },
+ update: ({ project }) => ({
+ isInProgress: isInProgress(project.jiraImportStatus),
+ isFinished: isFinished(project.jiraImportStatus),
+ label: calculateJiraImportLabel(
+ project.jiraImports.nodes,
+ project.issues.nodes.flatMap(({ labels }) => labels.nodes),
+ ),
+ }),
+ skip() {
+ return !this.isJiraConfigured || !this.canEdit;
+ },
+ },
+ },
+ computed: {
+ labelTarget() {
+ return `${this.issuesPath}?label_name[]=${encodeURIComponent(this.jiraImport.label.title)}`;
+ },
+ shouldShowFinishedAlert() {
+ return this.isFinishedAlertShowing && this.jiraImport.isFinished;
+ },
+ shouldShowInProgressAlert() {
+ return this.isInProgressAlertShowing && this.jiraImport.isInProgress;
+ },
+ },
+ methods: {
+ hideFinishedAlert() {
+ this.isFinishedAlertShowing = false;
+ },
+ hideInProgressAlert() {
+ this.isInProgressAlertShowing = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="issuable-list-root">
+ <gl-alert v-if="shouldShowInProgressAlert" @dismiss="hideInProgressAlert">
+ {{ __('Import in progress. Refresh page to see newly added issues.') }}
+ </gl-alert>
+ <gl-alert v-if="shouldShowFinishedAlert" variant="success" @dismiss="hideFinishedAlert">
+ {{ __('Issues successfully imported with the label') }}
+ <gl-label
+ :background-color="jiraImport.label.color"
+ scoped
+ size="sm"
+ :target="labelTarget"
+ :title="jiraImport.label.title"
+ />
+ </gl-alert>
+ </div>
+</template>
diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js
index d1601a7d8f3..e31806ad199 100644
--- a/app/assets/javascripts/issuables_list/eventhub.js
+++ b/app/assets/javascripts/issuables_list/eventhub.js
@@ -1,5 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-const issueablesEventBus = new Vue();
-
-export default issueablesEventBus;
+export default createEventHub();
diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js
index 9fc7fa837ff..6bfb885a8af 100644
--- a/app/assets/javascripts/issuables_list/index.js
+++ b/app/assets/javascripts/issuables_list/index.js
@@ -1,24 +1,63 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import IssuableListRootApp from './components/issuable_list_root_app.vue';
import IssuablesListApp from './components/issuables_list_app.vue';
-export default function initIssuablesList() {
- if (!gon.features || !gon.features.vueIssuablesList) {
+function mountIssuableListRootApp() {
+ const el = document.querySelector('.js-projects-issues-root');
+
+ if (!el) {
+ return false;
+ }
+
+ Vue.use(VueApollo);
+
+ const defaultClient = createDefaultClient();
+ const apolloProvider = new VueApollo({
+ defaultClient,
+ });
+
+ return new Vue({
+ el,
+ apolloProvider,
+ render(createComponent) {
+ return createComponent(IssuableListRootApp, {
+ props: {
+ canEdit: parseBoolean(el.dataset.canEdit),
+ isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
+ issuesPath: el.dataset.issuesPath,
+ projectPath: el.dataset.projectPath,
+ },
+ });
+ },
+ });
+}
+
+function mountIssuablesListApp() {
+ if (!gon.features?.vueIssuablesList) {
return;
}
document.querySelectorAll('.js-issuables-list').forEach(el => {
const { canBulkEdit, ...data } = el.dataset;
- const props = {
- ...data,
- canBulkEdit: Boolean(canBulkEdit),
- };
-
return new Vue({
el,
render(createElement) {
- return createElement(IssuablesListApp, { props });
+ return createElement(IssuablesListApp, {
+ props: {
+ ...data,
+ canBulkEdit: Boolean(canBulkEdit),
+ },
+ });
},
});
});
}
+
+export default function initIssuablesList() {
+ mountIssuableListRootApp();
+ mountIssuablesListApp();
+}
diff --git a/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
new file mode 100644
index 00000000000..b62b9b2af60
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/queries/get_issues_list_details.query.graphql
@@ -0,0 +1,22 @@
+#import "~/jira_import/queries/jira_import.fragment.graphql"
+
+query($fullPath: ID!) {
+ project(fullPath: $fullPath) {
+ issues {
+ nodes {
+ labels {
+ nodes {
+ title
+ color
+ }
+ }
+ }
+ }
+ jiraImportStatus
+ jiraImports {
+ nodes {
+ ...JiraImport
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 9136a47d542..f0967e77faf 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -12,6 +12,8 @@ export default class Issue {
constructor() {
if ($('a.btn-close').length) this.initIssueBtnEventListeners();
+ if ($('.js-close-blocked-issue-warning').length) this.initIssueWarningBtnEventListener();
+
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
@@ -89,7 +91,7 @@ export default class Issue {
return $(document).on(
'click',
- '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
+ '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen, a.btn-close-anyway',
e => {
e.preventDefault();
e.stopImmediatePropagation();
@@ -99,19 +101,30 @@ export default class Issue {
Issue.submitNoteForm($button.closest('form'));
}
- this.disableCloseReopenButton($button);
-
- const url = $button.attr('href');
- return axios
- .put(url)
- .then(({ data }) => {
- const isClosed = $button.hasClass('btn-close');
- this.updateTopState(isClosed, data);
- })
- .catch(() => flash(issueFailMessage))
- .then(() => {
- this.disableCloseReopenButton($button, false);
- });
+ const shouldDisplayBlockedWarning = $button.hasClass('btn-issue-blocked');
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ if (shouldDisplayBlockedWarning) {
+ this.toggleWarningAndCloseButton();
+ } else {
+ this.disableCloseReopenButton($button);
+
+ const url = $button.attr('href');
+ return axios
+ .put(url)
+ .then(({ data }) => {
+ const isClosed = $button.is('.btn-close, .btn-close-anyway');
+ this.updateTopState(isClosed, data);
+ if ($button.hasClass('btn-close-anyway')) {
+ warningBanner.addClass('hidden');
+ if (this.closeReopenReportToggle)
+ $('.js-issuable-close-dropdown').removeClass('hidden');
+ }
+ })
+ .catch(() => flash(issueFailMessage))
+ .then(() => {
+ this.disableCloseReopenButton($button, false);
+ });
+ }
},
);
}
@@ -137,6 +150,23 @@ export default class Issue {
this.reopenButtons.toggleClass('hidden', !isClosed);
}
+ toggleWarningAndCloseButton() {
+ const warningBanner = $('.js-close-blocked-issue-warning');
+ warningBanner.toggleClass('hidden');
+ $('.btn-close').toggleClass('hidden');
+ if (this.closeReopenReportToggle) {
+ $('.js-issuable-close-dropdown').toggleClass('hidden');
+ }
+ }
+
+ initIssueWarningBtnEventListener() {
+ return $(document).on('click', '.js-close-blocked-issue-warning button.btn-secondary', e => {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ this.toggleWarningAndCloseButton();
+ });
+ }
+
static submitNoteForm(form) {
const noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) {
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index b8b3a4f44fd..8cf2cda64a4 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -295,7 +295,7 @@ export default {
.then(res => res.data)
.then(data => this.checkForSpam(data))
.then(data => {
- if (window.location.pathname !== data.web_url) {
+ if (!window.location.pathname.includes(data.web_url)) {
visitUrl(data.web_url);
}
})
@@ -329,7 +329,7 @@ export default {
},
deleteIssuable(payload) {
- this.service
+ return this.service
.deleteIssuable(payload)
.then(res => res.data)
.then(data => {
@@ -340,7 +340,7 @@ export default {
})
.catch(() => {
createFlash(
- sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
+ sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
);
});
},
@@ -365,7 +365,12 @@ export default {
:issuable-type="issuableType"
/>
- <recaptcha-modal v-show="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptchaModal" />
+ <recaptcha-modal
+ v-show="showRecaptcha"
+ ref="recaptchaModal"
+ :html="recaptchaHTML"
+ @close="closeRecaptchaModal"
+ />
</div>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 447d7bf21a5..35165c9b481 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -45,22 +45,24 @@ export default {
:markdown-docs-path="markdownDocsPath"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
+ :textarea-value="formState.description"
>
- <textarea
- id="issue-description"
- ref="textarea"
- slot="textarea"
- v-model="formState.description"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-textarea"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Description')"
- :placeholder="__('Write a comment or drag your files here…')"
- @keydown.meta.enter="updateIssuable"
- @keydown.ctrl.enter="updateIssuable"
- >
- </textarea>
+ <template #textarea>
+ <textarea
+ id="issue-description"
+ ref="textarea"
+ v-model="formState.description"
+ class="note-textarea js-gfm-input js-autosize markdown-area
+ qa-description-textarea"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
+ @keydown.meta.enter="updateIssuable"
+ @keydown.ctrl.enter="updateIssuable"
+ >
+ </textarea>
+ </template>
</markdown-field>
</div>
</template>
diff --git a/app/assets/javascripts/issue_show/event_hub.js b/app/assets/javascripts/issue_show/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/issue_show/event_hub.js
+++ b/app/assets/javascripts/issue_show/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue
index b71c06e4217..d1570f52c8c 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_app.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue
@@ -1,5 +1,6 @@
<script>
-import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
+import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
+import { last } from 'lodash';
import { __ } from '~/locale';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
@@ -13,6 +14,7 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GlSprintf,
JiraImportForm,
JiraImportProgress,
JiraImportSetup,
@@ -30,6 +32,10 @@ export default {
type: String,
required: true,
},
+ jiraIntegrationPath: {
+ type: String,
+ required: true,
+ },
jiraProjects: {
type: Array,
required: true,
@@ -47,6 +53,7 @@ export default {
return {
errorMessage: '',
showAlert: false,
+ selectedProject: undefined,
};
},
apollo: {
@@ -59,7 +66,7 @@ export default {
},
update: ({ project }) => ({
status: project.jiraImportStatus,
- import: project.jiraImports.nodes[0],
+ imports: project.jiraImports.nodes,
}),
skip() {
return !this.isJiraConfigured;
@@ -73,6 +80,24 @@ export default {
jiraProjectsOptions() {
return this.jiraProjects.map(([text, value]) => ({ text, value }));
},
+ mostRecentImport() {
+ // The backend returns JiraImports ordered by created_at asc in app/models/project.rb
+ return last(this.jiraImportDetails?.imports);
+ },
+ numberOfPreviousImportsForProject() {
+ return this.jiraImportDetails?.imports?.reduce?.(
+ (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc),
+ 0,
+ );
+ },
+ importLabel() {
+ return this.selectedProject
+ ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImportsForProject + 1}`
+ : 'jira-import::KEY-1';
+ },
+ hasPreviousImports() {
+ return this.numberOfPreviousImportsForProject > 0;
+ },
},
methods: {
dismissAlert() {
@@ -93,6 +118,13 @@ export default {
return;
}
+ const cacheData = store.readQuery({
+ query: getJiraImportDetailsQuery,
+ variables: {
+ fullPath: this.projectPath,
+ },
+ });
+
store.writeQuery({
query: getJiraImportDetailsQuery,
variables: {
@@ -102,7 +134,10 @@ export default {
project: {
jiraImportStatus: IMPORT_STATE.SCHEDULED,
jiraImports: {
- nodes: [data.jiraImportStart.jiraImport],
+ nodes: [
+ ...cacheData.project.jiraImports.nodes,
+ data.jiraImportStart.jiraImport,
+ ],
__typename: 'JiraImportConnection',
},
// eslint-disable-next-line @gitlab/require-i18n-strings
@@ -115,6 +150,8 @@ export default {
.then(({ data }) => {
if (data.jiraImportStart.errors.length) {
this.setAlertMessage(data.jiraImportStart.errors.join('. '));
+ } else {
+ this.selectedProject = undefined;
}
})
.catch(() => this.setAlertMessage(__('There was an error importing the Jira project.')));
@@ -132,19 +169,38 @@ export default {
<gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert">
{{ errorMessage }}
</gl-alert>
+ <gl-alert v-if="hasPreviousImports" variant="warning" :dismissible="false">
+ <gl-sprintf
+ :message="
+ __(
+ 'You have imported from this project %{numberOfPreviousImportsForProject} times before. Each new import will create duplicate issues.',
+ )
+ "
+ >
+ <template #numberOfPreviousImportsForProject>{{
+ numberOfPreviousImportsForProject
+ }}</template>
+ </gl-sprintf>
+ </gl-alert>
- <jira-import-setup v-if="!isJiraConfigured" :illustration="setupIllustration" />
+ <jira-import-setup
+ v-if="!isJiraConfigured"
+ :illustration="setupIllustration"
+ :jira-integration-path="jiraIntegrationPath"
+ />
<gl-loading-icon v-else-if="$apollo.loading" size="md" class="mt-3" />
<jira-import-progress
v-else-if="isImportInProgress"
:illustration="inProgressIllustration"
- :import-initiator="jiraImportDetails.import.scheduledBy.name"
- :import-project="jiraImportDetails.import.jiraProjectKey"
- :import-time="jiraImportDetails.import.scheduledAt"
+ :import-initiator="mostRecentImport.scheduledBy.name"
+ :import-project="mostRecentImport.jiraProjectKey"
+ :import-time="mostRecentImport.scheduledAt"
:issues-path="issuesPath"
/>
<jira-import-form
v-else
+ v-model="selectedProject"
+ :import-label="importLabel"
:issues-path="issuesPath"
:jira-projects="jiraProjectsOptions"
@initiateJiraImport="initiateJiraImport"
diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue
index 0146f564260..c2fe7b29c28 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_form.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue
@@ -13,6 +13,10 @@ export default {
currentUserAvatarUrl: gon.current_user_avatar_url,
currentUsername: gon.current_username,
props: {
+ importLabel: {
+ type: String,
+ required: true,
+ },
issuesPath: {
type: String,
required: true,
@@ -21,21 +25,25 @@ export default {
type: Array,
required: true,
},
+ value: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
},
data() {
return {
- selectedOption: null,
selectState: null,
};
},
methods: {
initiateJiraImport(event) {
event.preventDefault();
- if (!this.selectedOption) {
- this.showValidationError();
- } else {
+ if (this.value) {
this.hideValidationError();
- this.$emit('initiateJiraImport', this.selectedOption);
+ this.$emit('initiateJiraImport', this.value);
+ } else {
+ this.showValidationError();
}
},
hideValidationError() {
@@ -62,10 +70,11 @@ export default {
>
<gl-form-select
id="jira-project-select"
- v-model="selectedOption"
class="mb-2"
:options="jiraProjects"
:state="selectState"
+ :value="value"
+ @change="$emit('input', $event)"
/>
</gl-form-group>
@@ -79,7 +88,7 @@ export default {
id="jira-project-label"
class="mb-2"
background-color="#428BCA"
- title="jira-import::KEY-1"
+ :title="importLabel"
scoped
/>
</gl-form-group>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_progress.vue b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
index 2d610224658..78f10decd31 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_progress.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_progress.vue
@@ -46,6 +46,9 @@ export default {
importTime: formatDate(this.importTime),
});
},
+ issuesLink() {
+ return `${this.issuesPath}?search=${this.importProject}`;
+ },
},
};
</script>
@@ -55,7 +58,7 @@ export default {
:svg-path="illustration"
:title="__('Import in progress')"
:primary-button-text="__('View issues')"
- :primary-button-link="issuesPath"
+ :primary-button-link="issuesLink"
>
<template #description>
<p class="mb-0">{{ importInitiatorText }}</p>
diff --git a/app/assets/javascripts/jira_import/components/jira_import_setup.vue b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
index 44773a773d5..285c5c815ac 100644
--- a/app/assets/javascripts/jira_import/components/jira_import_setup.vue
+++ b/app/assets/javascripts/jira_import/components/jira_import_setup.vue
@@ -11,6 +11,10 @@ export default {
type: String,
required: true,
},
+ jiraIntegrationPath: {
+ type: String,
+ required: true,
+ },
},
};
</script>
@@ -21,6 +25,6 @@ export default {
title=""
:description="__('You will first need to set up Jira Integration to use this feature.')"
:primary-button-text="__('Set up Jira Integration')"
- primary-button-link="../services/jira/edit"
+ :primary-button-link="jiraIntegrationPath"
/>
</template>
diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js
index 8bd70e4e277..b576668fe7c 100644
--- a/app/assets/javascripts/jira_import/index.js
+++ b/app/assets/javascripts/jira_import/index.js
@@ -27,6 +27,7 @@ export default function mountJiraImportApp() {
inProgressIllustration: el.dataset.inProgressIllustration,
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
issuesPath: el.dataset.issuesPath,
+ jiraIntegrationPath: el.dataset.jiraIntegrationPath,
jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [],
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
index 0eaaad580fc..aa8d03c7f17 100644
--- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
+++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql
@@ -3,7 +3,7 @@
query($fullPath: ID!) {
project(fullPath: $fullPath) {
jiraImportStatus
- jiraImports(last: 1) {
+ jiraImports {
nodes {
...JiraImport
}
diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js
index 504cf19e44e..aa10dfc8099 100644
--- a/app/assets/javascripts/jira_import/utils.js
+++ b/app/assets/javascripts/jira_import/utils.js
@@ -1,3 +1,5 @@
+import { last } from 'lodash';
+
export const IMPORT_STATE = {
FAILED: 'failed',
FINISHED: 'finished',
@@ -8,3 +10,50 @@ export const IMPORT_STATE = {
export const isInProgress = state =>
state === IMPORT_STATE.SCHEDULED || state === IMPORT_STATE.STARTED;
+
+export const isFinished = state => state === IMPORT_STATE.FINISHED;
+
+/**
+ * Calculates the label title for the most recent Jira import.
+ *
+ * @param {Object[]} jiraImports - List of Jira imports
+ * @param {string} jiraImports[].jiraProjectKey - Jira project key
+ * @returns {string} - A label title
+ */
+const calculateJiraImportLabelTitle = jiraImports => {
+ const mostRecentJiraProjectKey = last(jiraImports)?.jiraProjectKey;
+ const jiraProjectImportCount = jiraImports.filter(
+ jiraImport => jiraImport.jiraProjectKey === mostRecentJiraProjectKey,
+ ).length;
+ return `jira-import::${mostRecentJiraProjectKey}-${jiraProjectImportCount}`;
+};
+
+/**
+ * Finds the label color from a list of labels.
+ *
+ * @param {string} labelTitle - Label title
+ * @param {Object[]} labels - List of labels
+ * @param {string} labels[].title - Label title
+ * @param {string} labels[].color - Label color
+ * @returns {string} - The label color associated with the given labelTitle
+ */
+const calculateJiraImportLabelColor = (labelTitle, labels) =>
+ labels.find(label => label.title === labelTitle)?.color;
+
+/**
+ * Calculates the label for the most recent Jira import.
+ *
+ * @param {Object[]} jiraImports - List of Jira imports
+ * @param {string} jiraImports[].jiraProjectKey - Jira project key
+ * @param {Object[]} labels - List of labels
+ * @param {string} labels[].title - Label title
+ * @param {string} labels[].color - Label color
+ * @returns {{color: string, title: string}} - A label object containing a label color and title
+ */
+export const calculateJiraImportLabel = (jiraImports, labels) => {
+ const title = calculateJiraImportLabelTitle(jiraImports);
+ return {
+ color: calculateJiraImportLabelColor(title, labels),
+ title,
+ };
+};
diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue
index d9168f57cc7..28cc03c88cb 100644
--- a/app/assets/javascripts/jobs/components/environments_block.vue
+++ b/app/assets/javascripts/jobs/components/environments_block.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc, isEmpty } from 'lodash';
+import { escape, isEmpty } from 'lodash';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { sprintf, __ } from '../../locale';
@@ -43,7 +43,7 @@ export default {
'%{startLink}%{name}%{endLink}',
{
startLink: `<a href="${this.deploymentStatus.environment.environment_path}" class="js-environment-link">`,
- name: esc(this.deploymentStatus.environment.name),
+ name: escape(this.deploymentStatus.environment.name),
endLink: '</a>',
},
false,
@@ -74,8 +74,8 @@ export default {
}
const { name, path } = this.deploymentCluster;
- const escapedName = esc(name);
- const escapedPath = esc(path);
+ const escapedName = escape(name);
+ const escapedPath = escape(path);
if (!escapedPath) {
return escapedName;
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index f4030939f2c..0ce8dfe4442 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -220,7 +220,7 @@ export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
},
})
.then(({ data }) => {
- const retriedJobs = data.retried.map(job => Object.assign({}, job, { retried: true }));
+ const retriedJobs = data.retried.map(job => ({ ...job, retried: true }));
const jobs = data.latest_statuses.concat(retriedJobs);
dispatch('receiveJobsForStageSuccess', jobs);
@@ -236,7 +236,7 @@ export const receiveJobsForStageError = ({ commit }) => {
export const triggerManualJob = ({ state }, variables) => {
const parsedVariables = variables.map(variable => {
- const copyVar = Object.assign({}, variable);
+ const copyVar = { ...variable };
delete copyVar.id;
return copyVar;
});
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 5a61828ec6d..d76828ad19b 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -1,4 +1,4 @@
-import { isNewJobLogActive } from '../store/utils';
+import { isNewJobLogActive } from './utils';
export default () => ({
jobEndpoint: null,
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 7107c970457..65d8866fcc3 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,9 +1,9 @@
-/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */
+/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
import $ from 'jquery';
-import _ from 'underscore';
+import { isEqual, escape, sortBy, template } from 'lodash';
import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -55,7 +55,6 @@ export default class LabelsSelect {
})
.get();
const scopedLabels = $dropdown.data('scopedLabels');
- const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
$sidebarLabelTooltip.tooltip();
@@ -76,7 +75,7 @@ export default class LabelsSelect {
})
.get();
- if (_.isEqual(initialSelected, selected)) return;
+ if (isEqual(initialSelected, selected)) return;
initialSelected = selected;
const data = {};
@@ -101,10 +100,9 @@ export default class LabelsSelect {
let labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
- labels: _.sortBy(data.labels, 'title'),
+ labels: sortBy(data.labels, 'title'),
issueUpdateURL,
enableScopedLabels: scopedLabels,
- scopedLabelsDocumentationLink,
});
labelCount = data.labels.length;
@@ -188,13 +186,13 @@ export default class LabelsSelect {
if (showNo) {
extraData.unshift({
id: 0,
- title: __('No Label'),
+ title: __('No label'),
});
}
if (showAny) {
extraData.unshift({
isAny: true,
- title: __('Any Label'),
+ title: __('Any label'),
});
}
if (extraData.length) {
@@ -269,7 +267,7 @@ export default class LabelsSelect {
}
linkEl.className = selectedClass.join(' ');
- linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
+ linkEl.innerHTML = `${colorEl} ${escape(label.title)}`;
const listItemEl = document.createElement('li');
listItemEl.appendChild(linkEl);
@@ -296,7 +294,7 @@ export default class LabelsSelect {
if (selected && selected.id === 0) {
this.selected = [];
- return __('No Label');
+ return __('No label');
} else if (isSelected) {
this.selected.push(title);
} else if (!isSelected && title) {
@@ -311,9 +309,8 @@ export default class LabelsSelect {
firstLabel: selectedLabels[0],
labelCount: selectedLabels.length - 1,
});
- } else {
- return defaultLabel;
}
+ return defaultLabel;
},
fieldName: $dropdown.data('fieldName'),
id(label) {
@@ -325,9 +322,8 @@ export default class LabelsSelect {
if ($dropdown.hasClass('js-filter-submit') && label.isAny == null) {
return label.title;
- } else {
- return label.id;
}
+ return label.id;
},
hidden() {
const page = $('body').attr('data-page');
@@ -436,7 +432,7 @@ export default class LabelsSelect {
if (isScopedLabel(label)) {
const prevIds = oldLabels.map(label => label.id);
const newIds = boardsStore.detail.issue.labels.map(label => label.id);
- const differentIds = _.difference(prevIds, newIds);
+ const differentIds = prevIds.filter(x => !newIds.includes(x));
$dropdown.data('marked', newIds);
$dropdownMenu
.find(differentIds.map(id => `[data-label-id="${id}"]`).join(','))
@@ -483,7 +479,7 @@ export default class LabelsSelect {
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">';
const spanOpenTag =
'<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">';
- const labelTemplate = _.template(
+ const labelTemplate = template(
[
'<span class="gl-label">',
linkOpenTag,
@@ -499,15 +495,7 @@ export default class LabelsSelect {
return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color);
};
- const infoIconTemplate = _.template(
- [
- '<a href="<%= scopedLabelsDocumentationLink %>" class="gl-link gl-label-icon" target="_blank" rel="noopener">',
- '<i class="fa fa-question-circle"></i>',
- '</a>',
- ].join(''),
- );
-
- const scopedLabelTemplate = _.template(
+ const scopedLabelTemplate = template(
[
'<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>;">',
linkOpenTag,
@@ -518,12 +506,11 @@ export default class LabelsSelect {
'<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>',
'</span>',
'</a>',
- '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
'</span>',
].join(''),
);
- const tooltipTitleTemplate = _.template(
+ const tooltipTitleTemplate = template(
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
@@ -535,12 +522,12 @@ export default class LabelsSelect {
].join(''),
);
- const tpl = _.template(
+ const tpl = template(
[
- '<% _.each(labels, function(label){ %>',
+ '<% labels.forEach(function(label){ %>',
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
'<span class="d-inline-block position-relative scoped-label-wrapper">',
- '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, infoIconTemplate, scopedLabelsDocumentationLink, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
+ '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
'</span>',
'<% } else { %>',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
@@ -553,11 +540,10 @@ export default class LabelsSelect {
...tplData,
labelTemplate,
rightLabelTextColor,
- infoIconTemplate,
scopedLabelTemplate,
tooltipTitleTemplate,
isScopedLabel,
- escapeStr: _.escape,
+ escapeStr: escape,
});
}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 8d3b87d5cc0..b6c41ffa7ab 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -33,6 +33,7 @@ export default (resolvers = {}, config = {}) => {
};
return new ApolloClient({
+ typeDefs: config.typeDefs,
link: ApolloLink.split(
operation => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index f6077673ad5..6b69d2febe0 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -57,6 +57,19 @@ export const getMonthNames = abbreviated => {
export const pad = (val, len = 2) => `0${val}`.slice(-len);
/**
+ * Returns i18n weekday names array.
+ */
+export const getWeekdayNames = () => [
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+];
+
+/**
* Given a date object returns the day of the week in English
* @param {date} date
* @returns {String}
diff --git a/app/assets/javascripts/lib/utils/downloader.js b/app/assets/javascripts/lib/utils/downloader.js
new file mode 100644
index 00000000000..2297f5f90ce
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/downloader.js
@@ -0,0 +1,20 @@
+/**
+ * Helper function to trigger a download.
+ *
+ * - If the `fileName` is `_blank` it will open the file in a new tab.
+ * - If `fileData` is provided, it will inline the content and use data URLs to
+ * download the file. In this case the `url` property will be ignored. Please
+ * note that `fileData` needs to be Base64 encoded.
+ */
+export default ({ fileName, url, fileData }) => {
+ let href = url;
+
+ if (fileData) {
+ href = `data:text/plain;base64,${fileData}`;
+ }
+
+ const anchor = document.createElement('a');
+ anchor.download = fileName;
+ anchor.href = href;
+ anchor.click();
+};
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 16bffc5c2cf..618266f7a09 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -1,3 +1,6 @@
+// `e.keyCode` is deprecated, these values should be migrated
+// See: https://gitlab.com/gitlab-org/gitlab/-/issues/216102
+
export const BACKSPACE_KEY_CODE = 8;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js
new file mode 100644
index 00000000000..8e5420e87ea
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/keys.js
@@ -0,0 +1,4 @@
+/* eslint-disable @gitlab/require-i18n-strings */
+
+export const ESC_KEY = 'Escape';
+export const ESC_KEY_IE11 = 'Esc'; // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index cccf9ad311c..0dfc144c363 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-param-reassign, operator-assignment, no-else-return, consistent-return */
+/* eslint-disable func-names, no-param-reassign, operator-assignment, consistent-return */
import $ from 'jquery';
import { insertText } from '~/lib/utils/common_utils';
@@ -217,16 +217,15 @@ export function insertMarkdownText({
}
if (val.indexOf(tag) === 0) {
return String(val.replace(tag, ''));
- } else {
- return String(tag) + val;
}
+ return String(tag) + val;
})
.join('\n');
}
} else if (tag.indexOf(textPlaceholder) > -1) {
textToInsert = tag.replace(textPlaceholder, selected);
} else {
- textToInsert = String(startChar) + tag + selected + (wrap ? tag : ' ');
+ textToInsert = String(startChar) + tag + selected + (wrap ? tag : '');
}
if (removedFirstNewLine) {
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index a495d2040d3..966e6d42b80 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -63,15 +63,22 @@ export function getParameterValues(sParam, url = window.location) {
}, []);
}
-// @param {Object} params - url keys and value to merge
-// @param {String} url
+/**
+ * Merges a URL to a set of params replacing value for
+ * those already present.
+ *
+ * Also removes `null` param values from the resulting URL.
+ *
+ * @param {Object} params - url keys and value to merge
+ * @param {String} url
+ */
export function mergeUrlParams(params, url) {
const re = /^([^?#]*)(\?[^#]*)?(.*)/;
const merged = {};
- const urlparts = url.match(re);
+ const [, fullpath, query, fragment] = url.match(re);
- if (urlparts[2]) {
- urlparts[2]
+ if (query) {
+ query
.substr(1)
.split('&')
.forEach(part => {
@@ -84,11 +91,15 @@ export function mergeUrlParams(params, url) {
Object.assign(merged, params);
- const query = Object.keys(merged)
+ const newQuery = Object.keys(merged)
+ .filter(key => merged[key] !== null)
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`)
.join('&');
- return `${urlparts[1]}?${query}${urlparts[3]}`;
+ if (newQuery) {
+ return `${fullpath}?${newQuery}${fragment}`;
+ }
+ return `${fullpath}${fragment}`;
}
/**
@@ -213,12 +224,45 @@ export function getBaseURL() {
}
/**
+ * Returns true if url is an absolute URL
+ *
+ * @param {String} url
+ */
+export function isAbsolute(url) {
+ return /^https?:\/\//.test(url);
+}
+
+/**
+ * Returns true if url is a root-relative URL
+ *
+ * @param {String} url
+ */
+export function isRootRelative(url) {
+ return /^\//.test(url);
+}
+
+/**
* Returns true if url is an absolute or root-relative URL
*
* @param {String} url
*/
export function isAbsoluteOrRootRelative(url) {
- return /^(https?:)?\//.test(url);
+ return isAbsolute(url) || isRootRelative(url);
+}
+
+/**
+ * Converts a relative path to an absolute or a root relative path depending
+ * on what is passed as a basePath.
+ *
+ * @param {String} path Relative path, eg. ../img/img.png
+ * @param {String} basePath Absolute or root relative path, eg. /user/project or
+ * https://gitlab.com/user/project
+ */
+export function relativePathToAbsolute(path, basePath) {
+ const absolute = isAbsolute(basePath);
+ const base = absolute ? basePath : `file:///${basePath}`;
+ const url = new URL(path, base);
+ return absolute ? url.href : decodeURIComponent(url.pathname);
}
/**
@@ -259,8 +303,10 @@ export function getWebSocketUrl(path) {
export function queryToObject(query) {
const removeQuestionMarkFromQuery = String(query).startsWith('?') ? query.slice(1) : query;
return removeQuestionMarkFromQuery.split('&').reduce((accumulator, curr) => {
- const p = curr.split('=');
- accumulator[decodeURIComponent(p[0])] = decodeURIComponent(p[1]);
+ const [key, value] = curr.split('=');
+ if (value !== undefined) {
+ accumulator[decodeURIComponent(key)] = decodeURIComponent(value);
+ }
return accumulator;
}, {});
}
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index dd868bb9f4c..ed10c7646a8 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */
+/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return */
import $ from 'jquery';
@@ -54,6 +54,7 @@ LineHighlighter.prototype.bindEvents = function() {
$fileHolder.on('click', 'a[data-line-number]', this.clickHandler);
$fileHolder.on('highlight:line', this.highlightHash);
+ window.addEventListener('hashchange', e => this.highlightHash(e.target.location.hash));
};
LineHighlighter.prototype.highlightHash = function(newHash) {
@@ -127,9 +128,8 @@ LineHighlighter.prototype.hashToRange = function(hash) {
const first = parseInt(matches[1], 10);
const last = matches[2] ? parseInt(matches[2], 10) : null;
return [first, last];
- } else {
- return [null, null];
}
+ return [null, null];
};
// Highlight a single line
@@ -152,9 +152,8 @@ LineHighlighter.prototype.highlightRange = function(range) {
}
return results;
- } else {
- return this.highlightLine(range[0]);
}
+ return this.highlightLine(range[0]);
};
// Set the URL hash string
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index 7ab4e725d99..b4658a159d7 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -5,7 +5,7 @@ import { escape } from 'lodash';
@param input (translated) text with parameters (e.g. '%{num_users} users use us')
@param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 })
- @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
+ @param {Boolean} escapeParameters whether parameter values should be escaped (see https://lodash.com/docs/4.17.15#escape)
@returns {String} the text with parameters replaces (e.g. '5 users use us')
@see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 6c8f6372795..713f57a2b27 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -106,12 +106,14 @@ function deferredInitialisation() {
initLogoAnimation();
initUsagePingConsent();
initUserPopovers();
- initUserTracking();
initBroadcastNotifications();
const recoverySettingsCallout = document.querySelector('.js-recovery-settings-callout');
PersistentUserCallout.factory(recoverySettingsCallout);
+ const usersOverLicenseCallout = document.querySelector('.js-users-over-license-callout');
+ PersistentUserCallout.factory(usersOverLicenseCallout);
+
if (document.querySelector('.search')) initSearchAutocomplete();
addSelectOnFocusBehaviour('.js-select-on-focus');
@@ -187,6 +189,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
+ initUserTracking();
initLayoutNav();
// Set the default path for all cookies to GitLab's root directory
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index 0dabb28ea66..ef7d8cc9efe 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -29,8 +29,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
onSelect(dateText) {
$input.val(calendar.toString(dateText));
- $input.trigger('change');
-
toggleClearInput.call($input);
},
firstDay: gon.first_day_of_week,
@@ -49,7 +47,6 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
const calendar = input.data('pikaday');
calendar.setDate(null);
- input.trigger('change');
toggleClearInput.call(input);
});
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 87de58443e0..1795a0dbdf8 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -1,9 +1,9 @@
/* eslint-disable no-new, class-methods-use-this */
import $ from 'jquery';
-import Vue from 'vue';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
+import createEventHub from '~/helpers/event_hub_factory';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
@@ -93,7 +93,7 @@ export default class MergeRequestTabs {
this.pipelinesLoaded = false;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
- this.eventHub = new Vue();
+ this.eventHub = createEventHub();
this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this);
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index d15e4ecb537..e14212254a8 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -1,9 +1,9 @@
-/* eslint-disable one-var, no-else-return, no-self-compare, consistent-return, no-param-reassign, no-shadow */
+/* eslint-disable one-var, no-self-compare, consistent-return, no-param-reassign, no-shadow */
/* global Issuable */
/* global ListMilestone */
import $ from 'jquery';
-import _ from 'underscore';
+import { template, escape } from 'lodash';
import { __ } from '~/locale';
import '~/gl_dropdown';
import axios from './lib/utils/axios_utils';
@@ -56,11 +56,11 @@ export default class MilestoneSelect {
const $loading = $block.find('.block-loading').fadeOut();
selectedMilestoneDefault = showAny ? '' : null;
selectedMilestoneDefault =
- showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault;
+ showNo && defaultNo ? __('No milestone') : selectedMilestoneDefault;
selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
- milestoneLinkTemplate = _.template(
+ milestoneLinkTemplate = template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
@@ -74,14 +74,14 @@ export default class MilestoneSelect {
extraOptions.push({
id: null,
name: null,
- title: __('Any Milestone'),
+ title: __('Any milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
- name: __('No Milestone'),
- title: __('No Milestone'),
+ name: __('No milestone'),
+ title: __('No milestone'),
});
}
if (showUpcoming) {
@@ -106,12 +106,12 @@ export default class MilestoneSelect {
if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove();
}
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`).addClass('is-active');
+ $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`).addClass('is-active');
}),
renderRow: milestone => `
- <li data-milestone-id="${_.escape(milestone.name)}">
+ <li data-milestone-id="${escape(milestone.name)}">
<a href='#' class='dropdown-menu-milestone-link'>
- ${_.escape(milestone.title)}
+ ${escape(milestone.title)}
</a>
</li>
`,
@@ -123,19 +123,17 @@ export default class MilestoneSelect {
toggleLabel: (selected, el) => {
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
return selected.title;
- } else {
- return defaultLabel;
}
+ return defaultLabel;
},
defaultLabel,
fieldName: $dropdown.data('fieldName'),
- text: milestone => _.escape(milestone.title),
+ text: milestone => escape(milestone.title),
id: milestone => {
if (!useId && !$dropdown.is('.js-issuable-form-dropdown')) {
return milestone.name;
- } else {
- return milestone.id;
}
+ return milestone.id;
},
hidden: () => {
$selectBox.hide();
@@ -148,7 +146,7 @@ export default class MilestoneSelect {
selectedMilestone = $dropdown[0].dataset.selected || selectedMilestoneDefault;
}
$('a.is-active', $el).removeClass('is-active');
- $(`[data-milestone-id="${_.escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
+ $(`[data-milestone-id="${escape(selectedMilestone)}"] > a`, $el).addClass('is-active');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: clickEvent => {
@@ -244,13 +242,12 @@ export default class MilestoneSelect {
)
.find('span')
.text(data.milestone.title);
- } else {
- $value.html(milestoneLinkNoneTemplate);
- return $sidebarCollapsedValue
- .attr('data-original-title', __('Milestone'))
- .find('span')
- .text(__('None'));
}
+ $value.html(milestoneLinkNoneTemplate);
+ return $sidebarCollapsedValue
+ .attr('data-original-title', __('Milestone'))
+ .find('span')
+ .text(__('None'));
})
.catch(() => {
// eslint-disable-next-line no-jquery/no-fade
diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue
new file mode 100644
index 00000000000..19148d6184f
--- /dev/null
+++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue
@@ -0,0 +1,228 @@
+<script>
+import {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlNewDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+} from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { intersection, debounce } from 'lodash';
+
+export default {
+ components: {
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownHeader,
+ GlNewDropdownItem,
+ GlLoadingIcon,
+ GlSearchBoxByType,
+ GlIcon,
+ },
+ model: {
+ prop: 'preselectedMilestones',
+ event: 'change',
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ preselectedMilestones: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ extraLinks: {
+ type: Array,
+ default: () => [],
+ required: false,
+ },
+ },
+ data() {
+ return {
+ searchQuery: '',
+ projectMilestones: [],
+ searchResults: [],
+ selectedMilestones: [],
+ requestCount: 0,
+ };
+ },
+ translations: {
+ milestone: __('Milestone'),
+ selectMilestone: __('Select milestone'),
+ noMilestone: __('No milestone'),
+ noResultsLabel: __('No matching results'),
+ searchMilestones: __('Search Milestones'),
+ },
+ computed: {
+ selectedMilestonesLabel() {
+ if (this.milestoneTitles.length === 1) {
+ return this.milestoneTitles[0];
+ }
+
+ if (this.milestoneTitles.length > 1) {
+ const firstMilestoneName = this.milestoneTitles[0];
+ const numberOfOtherMilestones = this.milestoneTitles.length - 1;
+ return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), {
+ firstMilestoneName,
+ numberOfOtherMilestones,
+ });
+ }
+
+ return this.$options.translations.noMilestone;
+ },
+ milestoneTitles() {
+ return this.preselectedMilestones.map(milestone => milestone.title);
+ },
+ dropdownItems() {
+ return this.searchResults.length ? this.searchResults : this.projectMilestones;
+ },
+ noResults() {
+ return this.searchQuery.length > 2 && this.searchResults.length === 0;
+ },
+ isLoading() {
+ return this.requestCount !== 0;
+ },
+ },
+ mounted() {
+ this.fetchMilestones();
+ },
+ methods: {
+ fetchMilestones() {
+ this.requestCount += 1;
+
+ Api.projectMilestones(this.projectId)
+ .then(({ data }) => {
+ this.projectMilestones = this.getTitles(data);
+ this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles);
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while loading milestones'));
+ })
+ .finally(() => {
+ this.requestCount -= 1;
+ });
+ },
+ searchMilestones: debounce(function searchMilestones() {
+ this.requestCount += 1;
+ const options = {
+ search: this.searchQuery,
+ scope: 'milestones',
+ };
+
+ if (this.searchQuery.length < 3) {
+ this.requestCount -= 1;
+ this.searchResults = [];
+ return;
+ }
+
+ Api.projectSearch(this.projectId, options)
+ .then(({ data }) => {
+ const searchResults = this.getTitles(data);
+
+ this.searchResults = searchResults.length ? searchResults : [];
+ })
+ .catch(() => {
+ createFlash(__('An error occurred while searching for milestones'));
+ })
+ .finally(() => {
+ this.requestCount -= 1;
+ });
+ }, 100),
+ toggleMilestoneSelection(clickedMilestone) {
+ if (!clickedMilestone) return [];
+
+ let milestones = [...this.preselectedMilestones];
+ const hasMilestone = this.milestoneTitles.includes(clickedMilestone);
+
+ if (hasMilestone) {
+ milestones = milestones.filter(({ title }) => title !== clickedMilestone);
+ } else {
+ milestones.push({ title: clickedMilestone });
+ }
+
+ return milestones;
+ },
+ onMilestoneClicked(clickedMilestone) {
+ const milestones = this.toggleMilestoneSelection(clickedMilestone);
+ this.$emit('change', milestones);
+
+ this.selectedMilestones = intersection(
+ this.projectMilestones,
+ milestones.map(milestone => milestone.title),
+ );
+ },
+ isSelectedMilestone(milestoneTitle) {
+ return this.selectedMilestones.includes(milestoneTitle);
+ },
+ getTitles(milestones) {
+ return milestones.filter(({ state }) => state === 'active').map(({ title }) => title);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-new-dropdown>
+ <template slot="button-content">
+ <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{
+ selectedMilestonesLabel
+ }}</span>
+ <gl-icon name="chevron-down" />
+ </template>
+
+ <gl-new-dropdown-header>
+ <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span>
+ </gl-new-dropdown-header>
+
+ <gl-new-dropdown-divider />
+
+ <gl-search-box-by-type
+ v-model.trim="searchQuery"
+ class="m-2"
+ :placeholder="this.$options.translations.searchMilestones"
+ @input="searchMilestones"
+ />
+
+ <gl-new-dropdown-item @click="onMilestoneClicked(null)">
+ <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }">
+ {{ $options.translations.noMilestone }}
+ </span>
+ </gl-new-dropdown-item>
+
+ <gl-new-dropdown-divider />
+
+ <template v-if="isLoading">
+ <gl-loading-icon />
+ <gl-new-dropdown-divider />
+ </template>
+ <template v-else-if="noResults">
+ <div class="dropdown-item-space">
+ <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span>
+ </div>
+ <gl-new-dropdown-divider />
+ </template>
+ <template v-else-if="dropdownItems.length">
+ <gl-new-dropdown-item
+ v-for="item in dropdownItems"
+ :key="item"
+ role="milestone option"
+ @click="onMilestoneClicked(item)"
+ >
+ <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }">
+ {{ item }}
+ </span>
+ </gl-new-dropdown-item>
+ <gl-new-dropdown-divider />
+ </template>
+
+ <gl-new-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url">
+ <span class="pl-4">{{ item.text }}</span>
+ </gl-new-dropdown-item>
+ </gl-new-dropdown>
+</template>
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 2276a723326..986785fdfbe 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import Flash from '~/flash';
@@ -161,7 +161,7 @@ export default class SSHMirror {
const $fingerprintsList = this.$hostKeysInformation.find('.js-fingerprints-list');
let fingerprints = '';
sshHostKeys.fingerprints.forEach(fingerprint => {
- const escFingerprints = esc(fingerprint.fingerprint);
+ const escFingerprints = escape(fingerprint.fingerprint);
fingerprints += `<code>${escFingerprints}</code>`;
});
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue
new file mode 100644
index 00000000000..86a793c854e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget.vue
@@ -0,0 +1,286 @@
+<script>
+import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import AlertWidgetForm from './alert_widget_form.vue';
+import AlertsService from '../services/alerts_service';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+import { values, get } from 'lodash';
+
+export default {
+ components: {
+ AlertWidgetForm,
+ GlBadge,
+ GlLoadingIcon,
+ GlIcon,
+ GlTooltip,
+ GlSprintf,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ alertsEndpoint: {
+ type: String,
+ required: true,
+ },
+ showLoadingState: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls.
+ // Includes only the metrics/alerts to be managed by this widget.
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ // [{ metric+query_attributes }]. Represents queries (and alerts) we know about
+ // on intial fetch. Essentially used for reference.
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ service: null,
+ errorMessage: null,
+ isLoading: false,
+ apiAction: 'create',
+ };
+ },
+ i18n: {
+ alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'),
+ singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'),
+ multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'),
+ firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'),
+ },
+ computed: {
+ singleAlertSummary() {
+ return {
+ message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0],
+ alert: this.thresholds[0],
+ };
+ },
+ multipleAlertsSummary() {
+ return {
+ message: this.isFiring
+ ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}`
+ : this.$options.i18n.alertsCountMsg,
+ count: this.thresholds.length,
+ firingCount: this.firingAlerts.length,
+ };
+ },
+ shouldShowLoadingIcon() {
+ return this.showLoadingState && this.isLoading;
+ },
+ thresholds() {
+ const alertsToManage = Object.keys(this.alertsToManage);
+ return alertsToManage.map(this.formatAlertSummary);
+ },
+ hasAlerts() {
+ return Boolean(Object.keys(this.alertsToManage).length);
+ },
+ hasMultipleAlerts() {
+ return this.thresholds.length > 1;
+ },
+ isFiring() {
+ return Boolean(this.firingAlerts.length);
+ },
+ firingAlerts() {
+ return values(this.alertsToManage).filter(alert =>
+ this.passedAlertThreshold(this.getQueryData(alert), alert),
+ );
+ },
+ formattedFiringAlerts() {
+ return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path));
+ },
+ configuredAlert() {
+ return this.hasAlerts ? values(this.alertsToManage)[0].metricId : '';
+ },
+ },
+ created() {
+ this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
+ this.fetchAlertData();
+ },
+ methods: {
+ fetchAlertData() {
+ this.isLoading = true;
+
+ const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path);
+
+ return Promise.all(
+ queriesWithAlerts.map(query =>
+ this.service
+ .readAlert(query.alert_path)
+ .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)),
+ ),
+ )
+ .then(() => {
+ this.isLoading = false;
+ })
+ .catch(() => {
+ createFlash(s__('PrometheusAlerts|Error fetching alert'));
+ this.isLoading = false;
+ });
+ },
+ setAlert(alertAttributes, metricId) {
+ this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId });
+ },
+ removeAlert(alertPath) {
+ this.$emit('setAlerts', alertPath, null);
+ },
+ formatAlertSummary(alertPath) {
+ const alert = this.alertsToManage[alertPath];
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return `${alertQuery.label} ${alert.operator} ${alert.threshold}`;
+ },
+ passedAlertThreshold(data, alert) {
+ const { threshold, operator } = alert;
+
+ switch (operator) {
+ case OPERATORS.greaterThan:
+ return data.some(value => value > threshold);
+ case OPERATORS.lessThan:
+ return data.some(value => value < threshold);
+ case OPERATORS.equalTo:
+ return data.some(value => value === threshold);
+ default:
+ return false;
+ }
+ },
+ getQueryData(alert) {
+ const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId);
+
+ return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null));
+ },
+ showModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ hideModal() {
+ this.errorMessage = null;
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ handleSetApiAction(apiAction) {
+ this.apiAction = apiAction;
+ },
+ handleCreate({ operator, threshold, prometheus_metric_id }) {
+ const newAlert = { operator, threshold, prometheus_metric_id };
+ this.isLoading = true;
+ this.service
+ .createAlert(newAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, prometheus_metric_id);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error creating alert');
+ this.isLoading = false;
+ });
+ },
+ handleUpdate({ alert, operator, threshold }) {
+ const updatedAlert = { operator, threshold };
+ this.isLoading = true;
+ this.service
+ .updateAlert(alert, updatedAlert)
+ .then(alertAttributes => {
+ this.setAlert(alertAttributes, this.alertsToManage[alert].metricId);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error saving alert');
+ this.isLoading = false;
+ });
+ },
+ handleDelete({ alert }) {
+ this.isLoading = true;
+ this.service
+ .deleteAlert(alert)
+ .then(() => {
+ this.removeAlert(alert);
+ this.isLoading = false;
+ this.hideModal();
+ })
+ .catch(() => {
+ this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
+ this.isLoading = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden">
+ <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" />
+ <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{
+ errorMessage
+ }}</span>
+ <span
+ v-else-if="hasAlerts"
+ ref="alertCurrentSetting"
+ class="alert-current-setting cursor-pointer d-flex"
+ @click="showModal"
+ >
+ <gl-badge
+ :variant="isFiring ? 'danger' : 'secondary'"
+ pill
+ class="d-flex-center text-truncate"
+ >
+ <gl-icon name="warning" :size="16" class="flex-shrink-0" />
+ <span class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me">
+ <gl-sprintf
+ :message="
+ hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message
+ "
+ >
+ <template #alert>
+ {{ singleAlertSummary.alert }}
+ </template>
+ <template #count>
+ {{ multipleAlertsSummary.count }}
+ </template>
+ <template #firingCount>
+ {{ multipleAlertsSummary.firingCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-badge>
+ <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting">
+ <gl-sprintf :message="$options.i18n.firingAlertsTooltip">
+ <template #alerts>
+ <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path">
+ {{ alert }}
+ </div>
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+ </span>
+ <alert-widget-form
+ ref="widgetForm"
+ :disabled="isLoading"
+ :alerts-to-manage="alertsToManage"
+ :relevant-queries="relevantQueries"
+ :error-message="errorMessage"
+ :configured-alert="configuredAlert"
+ :modal-id="modalId"
+ @create="handleCreate"
+ @update="handleUpdate"
+ @delete="handleDelete"
+ @cancel="hideModal"
+ @setAction="handleSetApiAction"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
new file mode 100644
index 00000000000..74324daa1e3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue
@@ -0,0 +1,307 @@
+<script>
+import { isEmpty, findKey } from 'lodash';
+import Vue from 'vue';
+import {
+ GlLink,
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import Translate from '~/vue_shared/translate';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import Icon from '~/vue_shared/components/icon.vue';
+import { alertsValidator, queriesValidator } from '../validators';
+import { OPERATORS } from '../constants';
+
+Vue.use(Translate);
+
+const SUBMIT_ACTION_TEXT = {
+ create: __('Add'),
+ update: __('Save'),
+ delete: __('Delete'),
+};
+
+const SUBMIT_BUTTON_CLASS = {
+ create: 'btn-success',
+ update: 'btn-success',
+ delete: 'btn-remove',
+};
+
+export default {
+ components: {
+ GlDeprecatedButton,
+ GlButtonGroup,
+ GlFormGroup,
+ GlFormInput,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ TrackEvent: TrackEventDirective,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ disabled: {
+ type: Boolean,
+ required: true,
+ },
+ errorMessage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ configuredAlert: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ alertsToManage: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ validator: alertsValidator,
+ },
+ relevantQueries: {
+ type: Array,
+ required: true,
+ validator: queriesValidator,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ operators: OPERATORS,
+ operator: null,
+ threshold: null,
+ prometheusMetricId: null,
+ selectedAlert: {},
+ alertQuery: '',
+ };
+ },
+ computed: {
+ isValidQuery() {
+ // TODO: Add query validation check (most likely via http request)
+ return this.alertQuery.length ? true : null;
+ },
+ currentQuery() {
+ return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {};
+ },
+ formDisabled() {
+ // We need a prometheusMetricId to determine whether we're
+ // creating/updating/deleting
+ return this.disabled || !(this.prometheusMetricId || this.isValidQuery);
+ },
+ supportsComputedAlerts() {
+ return this.glFeatures.prometheusComputedAlerts;
+ },
+ queryDropdownLabel() {
+ return this.currentQuery.label || s__('PrometheusAlerts|Select query');
+ },
+ haveValuesChanged() {
+ return (
+ this.operator &&
+ this.threshold === Number(this.threshold) &&
+ (this.operator !== this.selectedAlert.operator ||
+ this.threshold !== this.selectedAlert.threshold)
+ );
+ },
+ submitAction() {
+ if (isEmpty(this.selectedAlert)) return 'create';
+ if (this.haveValuesChanged) return 'update';
+ return 'delete';
+ },
+ submitActionText() {
+ return SUBMIT_ACTION_TEXT[this.submitAction];
+ },
+ submitButtonClass() {
+ return SUBMIT_BUTTON_CLASS[this.submitAction];
+ },
+ isSubmitDisabled() {
+ return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
+ },
+ dropdownTitle() {
+ return this.submitAction === 'create'
+ ? s__('PrometheusAlerts|Add alert')
+ : s__('PrometheusAlerts|Edit alert');
+ },
+ },
+ watch: {
+ alertsToManage() {
+ this.resetAlertData();
+ },
+ submitAction() {
+ this.$emit('setAction', this.submitAction);
+ },
+ },
+ methods: {
+ selectQuery(queryId) {
+ const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId);
+ const existingAlert = this.alertsToManage[existingAlertPath];
+
+ if (existingAlert) {
+ this.selectedAlert = existingAlert;
+ this.operator = existingAlert.operator;
+ this.threshold = existingAlert.threshold;
+ } else {
+ this.selectedAlert = {};
+ this.operator = this.operators.greaterThan;
+ this.threshold = null;
+ }
+
+ this.prometheusMetricId = queryId;
+ },
+ handleHidden() {
+ this.resetAlertData();
+ this.$emit('cancel');
+ },
+ handleSubmit(e) {
+ e.preventDefault();
+ this.$emit(this.submitAction, {
+ alert: this.selectedAlert.alert_path,
+ operator: this.operator,
+ threshold: this.threshold,
+ prometheus_metric_id: this.prometheusMetricId,
+ });
+ },
+ handleShown() {
+ if (this.configuredAlert) {
+ this.selectQuery(this.configuredAlert);
+ } else if (this.relevantQueries.length === 1) {
+ this.selectQuery(this.relevantQueries[0].metricId);
+ }
+ },
+ resetAlertData() {
+ this.operator = null;
+ this.threshold = null;
+ this.prometheusMetricId = null;
+ this.selectedAlert = {};
+ },
+ getAlertFormActionTrackingOption() {
+ const label = `${this.submitAction}_alert`;
+ return {
+ category: document.body.dataset.page,
+ action: 'click_button',
+ label,
+ };
+ },
+ },
+ alertQueryText: {
+ label: __('Query'),
+ validFeedback: __('Query is valid'),
+ invalidFeedback: __('Invalid query'),
+ descriptionTooltip: __(
+ 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="alertModal"
+ :title="dropdownTitle"
+ :modal-id="modalId"
+ :ok-variant="submitAction === 'delete' ? 'danger' : 'success'"
+ :ok-disabled="formDisabled"
+ @ok="handleSubmit"
+ @hidden="handleHidden"
+ @shown="handleShown"
+ >
+ <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div>
+ <div class="alert-form">
+ <gl-form-group
+ v-if="supportsComputedAlerts"
+ :label="$options.alertQueryText.label"
+ label-for="alert-query-input"
+ :valid-feedback="$options.alertQueryText.validFeedback"
+ :invalid-feedback="$options.alertQueryText.invalidFeedback"
+ :state="isValidQuery"
+ >
+ <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" />
+ <template #description>
+ <div class="d-flex align-items-center">
+ {{ __('Single or combined queries') }}
+ <icon
+ v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
+ name="question"
+ class="prepend-left-4"
+ />
+ </div>
+ </template>
+ </gl-form-group>
+ <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label">
+ <gl-dropdown
+ id="alert-query-dropdown"
+ :text="queryDropdownLabel"
+ toggle-class="dropdown-menu-toggle qa-alert-query-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="query in relevantQueries"
+ :key="query.metricId"
+ data-qa-selector="alert_query_option"
+ @click="selectQuery(query.metricId)"
+ >
+ {{ query.label }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </gl-form-group>
+ <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')">
+ <gl-deprecated-button
+ :class="{ active: operator === operators.greaterThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.greaterThan"
+ >
+ {{ operators.greaterThan }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.equalTo }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.equalTo"
+ >
+ {{ operators.equalTo }}
+ </gl-deprecated-button>
+ <gl-deprecated-button
+ :class="{ active: operator === operators.lessThan }"
+ :disabled="formDisabled"
+ type="button"
+ @click="operator = operators.lessThan"
+ >
+ {{ operators.lessThan }}
+ </gl-deprecated-button>
+ </gl-button-group>
+ <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold">
+ <gl-form-input
+ id="alerts-threshold"
+ v-model.number="threshold"
+ :disabled="formDisabled"
+ type="number"
+ data-qa-selector="alert_threshold_field"
+ />
+ </gl-form-group>
+ </div>
+ <template #modal-ok>
+ <gl-link
+ v-track-event="getAlertFormActionTrackingOption()"
+ class="text-reset text-decoration-none"
+ >
+ {{ submitActionText }}
+ </gl-link>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 447f8845506..34da5885c97 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash';
import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
-import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
+import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants';
import { graphDataValidatorForAnomalyValues } from '../../utils';
import MonitorTimeSeriesChart from './time_series.vue';
@@ -91,7 +91,7 @@ export default {
]);
return {
...this.graphData,
- type: 'line-chart',
+ type: panelTypes.LINE_CHART,
metrics: [metricQuery],
};
},
@@ -209,7 +209,7 @@ export default {
:series-config="metricSeriesConfig"
>
<slot></slot>
- <template v-slot:tooltipContent="slotProps">
+ <template #tooltip-content="slotProps">
<div
v-for="(content, seriesIndex) in slotProps.tooltip.content"
:key="seriesIndex"
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index 0a0165a113e..55a25ee09fd 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -63,7 +63,7 @@ export default {
};
</script>
<template>
- <div v-gl-resize-observer-directive="onResize" class="col-12 col-lg-6">
+ <div v-gl-resize-observer-directive="onResize">
<gl-heatmap
ref="heatmapChart"
v-bind="$attrs"
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index bf40e8f448e..8f37a12af75 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -6,7 +6,7 @@ import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
+import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
@@ -64,10 +64,10 @@ export default {
required: false,
default: '',
},
- singleEmbed: {
- type: Boolean,
+ height: {
+ type: Number,
required: false,
- default: false,
+ default: chartHeight,
},
thresholds: {
type: Array,
@@ -100,7 +100,6 @@ export default {
sha: '',
},
width: 0,
- height: chartHeight,
svgs: {},
primaryColor: null,
throttledDatazoom: null,
@@ -211,8 +210,8 @@ export default {
},
glChartComponent() {
const chartTypes = {
- 'area-chart': GlAreaChart,
- 'line-chart': GlLineChart,
+ [panelTypes.AREA_CHART]: GlAreaChart,
+ [panelTypes.LINE_CHART]: GlLineChart,
};
return chartTypes[this.graphData.type] || GlAreaChart;
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 4d60b02d0df..2018c706b11 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,8 +1,10 @@
<script>
-import { debounce, pickBy } from 'lodash';
+import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
+ GlIcon,
+ GlButton,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
@@ -14,10 +16,10 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from './dashboard_panel.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue';
import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
@@ -28,17 +30,27 @@ import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
+import VariablesSection from './variables_section.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
+import {
+ getAddMetricTrackingOptions,
+ timeRangeToUrl,
+ timeRangeFromUrl,
+ panelToUrl,
+ expandedPanelPayloadFromUrl,
+ convertVariablesForURL,
+} from '../utils';
import { metricStates } from '../constants';
import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
export default {
components: {
VueDraggable,
- PanelType,
+ DashboardPanel,
Icon,
+ GlIcon,
+ GlButton,
GlDeprecatedButton,
GlDropdown,
GlLoadingIcon,
@@ -54,13 +66,14 @@ export default {
EmptyState,
GroupEmptyState,
DashboardsDropdown,
+
+ VariablesSection,
},
directives: {
GlModal: GlModalDirective,
GlTooltip: GlTooltipDirective,
TrackEvent: TrackEventDirective,
},
- mixins: [glFeatureFlagsMixin()],
props: {
externalDashboardUrl: {
type: String,
@@ -197,7 +210,6 @@ export default {
},
data() {
return {
- state: 'gettingStarted',
formIsValid: null,
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
hasValidDates: true,
@@ -212,16 +224,16 @@ export default {
'showEmptyState',
'useDashboardEndpoint',
'allDashboards',
- 'additionalPanelTypesEnabled',
'environmentsLoading',
+ 'expandedPanel',
+ 'promVariables',
+ 'isUpdatingStarredValue',
+ ]),
+ ...mapGetters('monitoringDashboard', [
+ 'selectedDashboard',
+ 'getMetricStates',
+ 'filteredEnvironments',
]),
- ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']),
- firstDashboard() {
- return this.allDashboards.length > 0 ? this.allDashboards[0] : {};
- },
- selectedDashboard() {
- return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
- },
showRearrangePanelsBtn() {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
@@ -229,20 +241,44 @@ export default {
return (
this.customMetricsAvailable &&
!this.showEmptyState &&
- this.firstDashboard === this.selectedDashboard
- );
- },
- hasHeaderButtons() {
- return (
- this.addingMetricsAvailable ||
- this.showRearrangePanelsBtn ||
- this.selectedDashboard.can_edit ||
- this.externalDashboardUrl.length
+ // Custom metrics only avaialble on system dashboards because
+ // they are stored in the database. This can be improved. See:
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/28241
+ this.selectedDashboard?.system_dashboard
);
},
shouldShowEnvironmentsDropdownNoMatchedMsg() {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
+ shouldShowVariablesSection() {
+ return Object.keys(this.promVariables).length > 0;
+ },
+ },
+ watch: {
+ dashboard(newDashboard) {
+ try {
+ const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
+ if (expandedPanel) {
+ this.setExpandedPanel(expandedPanel);
+ }
+ } catch {
+ createFlash(
+ s__(
+ 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
+ ),
+ );
+ }
+ },
+ expandedPanel: {
+ handler({ group, panel }) {
+ const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
+ updateHistory({
+ url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel),
+ title: document.title,
+ });
+ },
+ deep: true,
+ },
},
created() {
this.setInitialState({
@@ -255,6 +291,10 @@ export default {
logsPath: this.logsPath,
currentEnvironmentName: this.currentEnvironmentName,
});
+ window.addEventListener('keyup', this.onKeyup);
+ },
+ destroyed() {
+ window.removeEventListener('keyup', this.onKeyup);
},
mounted() {
if (!this.hasMetrics) {
@@ -273,6 +313,9 @@ export default {
'setInitialState',
'setPanelGroupMetrics',
'filterEnvironments',
+ 'setExpandedPanel',
+ 'clearExpandedPanel',
+ 'toggleStarredValue',
]),
updatePanels(key, panels) {
this.setPanelGroupMetrics({
@@ -299,11 +342,9 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
-
- generateLink(group, title, yLabel) {
- const dashboard = this.currentDashboard || this.firstDashboard.path;
- const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null);
- return mergeUrlParams(params, window.location.href);
+ generatePanelUrl(groupKey, panel) {
+ const dashboardPath = this.currentDashboard || this.selectedDashboard?.path;
+ return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel);
},
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
@@ -366,11 +407,28 @@ export default {
});
this.selectedTimeRange = { start, end };
},
+ onExpandPanel(group, panel) {
+ this.setExpandedPanel({ group, panel });
+ },
+ onGoBack() {
+ this.clearExpandedPanel();
+ },
+ onKeyup(event) {
+ const { key } = event;
+ if (key === ESC_KEY || key === ESC_KEY_IE11) {
+ this.clearExpandedPanel();
+ }
+ },
},
addMetric: {
title: s__('Metrics|Add metric'),
modalId: 'add-metric',
},
+ i18n: {
+ goBackLabel: s__('Metrics|Go back (Esc)'),
+ starDashboard: s__('Metrics|Star dashboard'),
+ unstarDashboard: s__('Metrics|Unstar dashboard'),
+ },
};
</script>
@@ -388,7 +446,6 @@ export default {
class="flex-grow-1"
toggle-class="dropdown-menu-toggle"
:default-branch="defaultBranch"
- :selected-dashboard="selectedDashboard"
@selectDashboard="selectDashboard($event)"
/>
</div>
@@ -443,7 +500,7 @@ export default {
<date-time-picker
ref="dateTimePicker"
class="flex-grow-1 show-last-dropdown"
- data-qa-selector="show_last_dropdown"
+ data-qa-selector="range_picker_dropdown"
:value="selectedTimeRange"
:options="timeRanges"
@input="onDateTimePickerInput"
@@ -467,6 +524,32 @@ export default {
<div class="flex-grow-1"></div>
<div class="d-sm-flex">
+ <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex">
+ <!--
+ wrapper for tooltip as button can be `disabled`
+ https://bootstrap-vue.org/docs/components/tooltip#disabled-elements
+ -->
+ <div
+ v-gl-tooltip
+ class="flex-grow-1"
+ :title="
+ selectedDashboard.starred
+ ? $options.i18n.unstarDashboard
+ : $options.i18n.starDashboard
+ "
+ >
+ <gl-deprecated-button
+ ref="toggleStarBtn"
+ class="w-100"
+ :disabled="isUpdatingStarredValue"
+ variant="default"
+ @click="toggleStarredValue()"
+ >
+ <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" />
+ </gl-deprecated-button>
+ </div>
+ </div>
+
<div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex">
<gl-deprecated-button
:pressed="isRearrangingPanels"
@@ -516,7 +599,10 @@ export default {
</gl-modal>
</div>
- <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block">
+ <div
+ v-if="selectedDashboard && selectedDashboard.can_edit"
+ class="mb-2 mr-2 d-flex d-sm-block"
+ >
<gl-deprecated-button
class="flex-grow-1 js-edit-link"
:href="selectedDashboard.project_blob_path"
@@ -539,61 +625,92 @@ export default {
</div>
</div>
</div>
-
+ <variables-section v-if="shouldShowVariablesSection && !showEmptyState" />
<div v-if="!showEmptyState">
- <graph-group
- v-for="(groupData, index) in dashboard.panelGroups"
- :key="`${groupData.group}.${groupData.priority}`"
- :name="groupData.group"
- :show-panels="showPanels"
- :collapse-group="collapseGroup(groupData.key)"
+ <dashboard-panel
+ v-show="expandedPanel.panel"
+ ref="expandedPanel"
+ :settings-path="settingsPath"
+ :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
+ :graph-data="expandedPanel.panel"
+ :alerts-endpoint="alertsEndpoint"
+ :height="600"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ @timerangezoom="onTimeRangeZoom"
>
- <vue-draggable
- v-if="!groupSingleEmptyState(groupData.key)"
- :value="groupData.panels"
- group="metrics-dashboard"
- :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
- :disabled="!isRearrangingPanels"
- @input="updatePanels(groupData.key, $event)"
+ <template #topLeft>
+ <gl-button
+ ref="goBackBtn"
+ v-gl-tooltip
+ class="mr-3 my-3"
+ :title="$options.i18n.goBackLabel"
+ @click="onGoBack"
+ >
+ <gl-icon
+ name="arrow-left"
+ :aria-label="$options.i18n.goBackLabel"
+ class="text-secondary"
+ />
+ </gl-button>
+ </template>
+ </dashboard-panel>
+
+ <div v-show="!expandedPanel.panel">
+ <graph-group
+ v-for="groupData in dashboard.panelGroups"
+ :key="`${groupData.group}.${groupData.priority}`"
+ :name="groupData.group"
+ :show-panels="showPanels"
+ :collapse-group="collapseGroup(groupData.key)"
>
- <div
- v-for="(graphData, graphIndex) in groupData.panels"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 px-2 mb-2 draggable"
- :class="{ 'draggable-enabled': isRearrangingPanels }"
+ <vue-draggable
+ v-if="!groupSingleEmptyState(groupData.key)"
+ :value="groupData.panels"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ @input="updatePanels(groupData.key, $event)"
>
- <div class="position-relative draggable-panel js-draggable-panel">
- <div
- v-if="isRearrangingPanels"
- class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
- @click="removePanel(groupData.key, groupData.panels, graphIndex)"
- >
- <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
- <icon name="close" />
- </a>
- </div>
+ <div
+ v-for="(graphData, graphIndex) in groupData.panels"
+ :key="`dashboard-panel-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
+ >
+ <div class="position-relative draggable-panel js-draggable-panel">
+ <div
+ v-if="isRearrangingPanels"
+ class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
+ @click="removePanel(groupData.key, groupData.panels, graphIndex)"
+ >
+ <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')">
+ <icon name="close" />
+ </a>
+ </div>
- <panel-type
- :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
- :graph-data="graphData"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- @timerangezoom="onTimeRangeZoom"
- />
+ <dashboard-panel
+ :settings-path="settingsPath"
+ :clipboard-text="generatePanelUrl(groupData.group, graphData)"
+ :graph-data="graphData"
+ :alerts-endpoint="alertsEndpoint"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ @timerangezoom="onTimeRangeZoom"
+ @expand="onExpandPanel(groupData.group, graphData)"
+ />
+ </div>
</div>
+ </vue-draggable>
+ <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
+ <group-empty-state
+ ref="empty-group"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ :selected-state="groupSingleEmptyState(groupData.key)"
+ :svg-path="emptyNoDataSmallSvgPath"
+ />
</div>
- </vue-draggable>
- <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6">
- <group-empty-state
- ref="empty-group"
- :documentation-path="documentationPath"
- :settings-path="settingsPath"
- :selected-state="groupSingleEmptyState(groupData.key)"
- :svg-path="emptyNoDataSmallSvgPath"
- />
- </div>
- </graph-group>
+ </graph-group>
+ </div>
</div>
<empty-state
v-else
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 2beae0d9540..48825fda5c8 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -14,6 +14,9 @@ import {
GlTooltipDirective,
} from '@gitlab/ui';
import { __, n__ } from '~/locale';
+import { panelTypes } from '../constants';
+
+import MonitorEmptyChart from './charts/empty_chart.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
@@ -21,22 +24,20 @@ import MonitorHeatmapChart from './charts/heatmap.vue';
import MonitorColumnChart from './charts/column.vue';
import MonitorBarChart from './charts/bar.vue';
import MonitorStackedColumnChart from './charts/stacked_column.vue';
-import MonitorEmptyChart from './charts/empty_chart.vue';
+
import TrackEventDirective from '~/vue_shared/directives/track_event';
+import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
const events = {
timeRangeZoom: 'timerangezoom',
+ expand: 'expand',
};
export default {
components: {
- MonitorSingleStatChart,
- MonitorColumnChart,
- MonitorBarChart,
- MonitorHeatmapChart,
- MonitorStackedColumnChart,
MonitorEmptyChart,
+ AlertWidget,
GlIcon,
GlLoadingIcon,
GlTooltip,
@@ -58,28 +59,41 @@ export default {
},
graphData: {
type: Object,
- required: true,
- },
- index: {
- type: String,
required: false,
- default: '',
+ default: null,
},
groupId: {
type: String,
required: false,
- default: 'panel-type-chart',
+ default: 'dashboard-panel',
},
namespace: {
type: String,
required: false,
default: 'monitoringDashboard',
},
+ alertsEndpoint: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ prometheusAlertsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ settingsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
data() {
return {
showTitleTooltip: false,
zoomedTimeRange: null,
+ allAlerts: {},
+ expandBtnAvailable: Boolean(this.$listeners[events.expand]),
};
},
computed: {
@@ -101,23 +115,18 @@ export default {
timeRange(state) {
return state[this.namespace].timeRange;
},
+ metricsSavedToDb(state, getters) {
+ return getters[`${this.namespace}/metricsSavedToDb`];
+ },
}),
title() {
- return this.graphData.title || '';
- },
- alertWidgetAvailable() {
- // This method is extended by ee functionality
- return false;
+ return this.graphData?.title || '';
},
graphDataHasResult() {
- return (
- this.graphData.metrics &&
- this.graphData.metrics[0].result &&
- this.graphData.metrics[0].result.length > 0
- );
+ return this.graphData?.metrics?.[0]?.result?.length > 0;
},
graphDataIsLoading() {
- const { metrics = [] } = this.graphData;
+ const metrics = this.graphData?.metrics || [];
return metrics.some(({ loading }) => loading);
},
logsPathWithTimeRange() {
@@ -129,7 +138,7 @@ export default {
return null;
},
csvText() {
- const chartData = this.graphData.metrics[0].result[0].values;
+ const chartData = this.graphData?.metrics[0].result[0].values || [];
const yLabel = this.graphData.y_label;
const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings
return chartData.reduce((csv, data) => {
@@ -141,27 +150,77 @@ export default {
const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data);
},
- timeChartComponent() {
- if (this.isPanelType('anomaly-chart')) {
+
+ /**
+ * A chart is "basic" if it doesn't support
+ * the same features as the TimeSeries based components
+ * such as "annotations".
+ *
+ * @returns Vue Component wrapping a basic visualization
+ */
+ basicChartComponent() {
+ if (this.isPanelType(panelTypes.SINGLE_STAT)) {
+ return MonitorSingleStatChart;
+ }
+ if (this.isPanelType(panelTypes.HEATMAP)) {
+ return MonitorHeatmapChart;
+ }
+ if (this.isPanelType(panelTypes.BAR)) {
+ return MonitorBarChart;
+ }
+ if (this.isPanelType(panelTypes.COLUMN)) {
+ return MonitorColumnChart;
+ }
+ if (this.isPanelType(panelTypes.STACKED_COLUMN)) {
+ return MonitorStackedColumnChart;
+ }
+ if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
+ return MonitorAnomalyChart;
+ }
+ return null;
+ },
+
+ /**
+ * In monitoring, Time Series charts typically support
+ * a larger feature set like "annotations", "deployment
+ * data", alert "thresholds" and "datazoom".
+ *
+ * This is intentional as Time Series are more frequently
+ * used.
+ *
+ * @returns Vue Component wrapping a time series visualization,
+ * Area Charts are rendered by default.
+ */
+ timeSeriesChartComponent() {
+ if (this.isPanelType(panelTypes.ANOMALY_CHART)) {
return MonitorAnomalyChart;
}
return MonitorTimeSeriesChart;
},
isContextualMenuShown() {
- return (
- this.graphDataHasResult &&
- !this.isPanelType('single-stat') &&
- !this.isPanelType('heatmap') &&
- !this.isPanelType('column') &&
- !this.isPanelType('stacked-column')
- );
+ return Boolean(this.graphDataHasResult && !this.basicChartComponent);
},
editCustomMetricLink() {
+ if (this.graphData.metrics.length > 1) {
+ return this.settingsPath;
+ }
return this.graphData?.metrics[0].edit_path;
},
editCustomMetricLinkText() {
return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length);
},
+ hasMetricsInDb() {
+ const { metrics = [] } = this.graphData;
+ return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
+ },
+ alertWidgetAvailable() {
+ return (
+ this.prometheusAlertsAvailable &&
+ this.alertsEndpoint &&
+ this.graphData &&
+ this.hasMetricsInDb
+ );
+ },
},
mounted() {
this.refreshTitleTooltip();
@@ -176,7 +235,7 @@ export default {
return Object.values(this.getGraphAlerts(queries));
},
isPanelType(type) {
- return this.graphData.type && this.graphData.type === type;
+ return this.graphData?.type === type;
},
showToast() {
this.$toast.show(__('Link copied'));
@@ -197,15 +256,27 @@ export default {
this.zoomedTimeRange = { start, end };
this.$emit(events.timeRangeZoom, { start, end });
},
+ onExpand() {
+ this.$emit(events.expand);
+ },
+ setAlerts(alertPath, alertAttributes) {
+ if (alertAttributes) {
+ this.$set(this.allAlerts, alertPath, alertAttributes);
+ } else {
+ this.$delete(this.allAlerts, alertPath);
+ }
+ },
},
+ panelTypes,
};
</script>
<template>
<div v-gl-resize-observer="onResize" class="prometheus-graph">
<div class="d-flex align-items-center mr-3">
+ <slot name="topLeft"></slot>
<h5
ref="graphTitle"
- class="prometheus-graph-title gl-font-size-large font-weight-bold text-truncate append-right-8"
+ class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8"
>
{{ title }}
</h5>
@@ -215,7 +286,7 @@ export default {
<alert-widget
v-if="isContextualMenuShown && alertWidgetAvailable"
class="mx-1"
- :modal-id="`alert-modal-${index}`"
+ :modal-id="`alert-modal-${graphData.id}`"
:alerts-endpoint="alertsEndpoint"
:relevant-queries="graphData.metrics"
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
@@ -243,6 +314,14 @@ export default {
<gl-icon name="ellipsis_v" class="text-secondary" />
</template>
<gl-dropdown-item
+ v-if="expandBtnAvailable"
+ ref="expandBtn"
+ :href="clipboardText"
+ @click.prevent="onExpand"
+ >
+ {{ s__('Metrics|Expand panel') }}
+ </gl-dropdown-item>
+ <gl-dropdown-item
v-if="editCustomMetricLink"
ref="editMetricLink"
:href="editCustomMetricLink"
@@ -271,13 +350,14 @@ export default {
ref="copyChartLink"
v-track-event="generateLinkToChartOptions(clipboardText)"
:data-clipboard-text="clipboardText"
+ data-qa-selector="generate_chart_link_menu_item"
@click="showToast(clipboardText)"
>
{{ __('Copy link to chart') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="alertWidgetAvailable"
- v-gl-modal="`alert-modal-${index}`"
+ v-gl-modal="`alert-modal-${graphData.id}`"
data-qa-selector="alert_widget_menu_item"
>
{{ __('Alerts') }}
@@ -287,38 +367,27 @@ export default {
</div>
</div>
- <monitor-single-stat-chart
- v-if="isPanelType('single-stat') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-heatmap-chart
- v-else-if="isPanelType('heatmap') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-bar-chart
- v-else-if="isPanelType('bar') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-column-chart
- v-else-if="isPanelType('column') && graphDataHasResult"
- :graph-data="graphData"
- />
- <monitor-stacked-column-chart
- v-else-if="isPanelType('stacked-column') && graphDataHasResult"
+ <monitor-empty-chart v-if="!graphDataHasResult" />
+ <component
+ :is="basicChartComponent"
+ v-else-if="basicChartComponent"
:graph-data="graphData"
+ v-bind="$attrs"
+ v-on="$listeners"
/>
<component
- :is="timeChartComponent"
- v-else-if="graphDataHasResult"
- ref="timeChart"
+ :is="timeSeriesChartComponent"
+ v-else
+ ref="timeSeriesChart"
:graph-data="graphData"
:deployment-data="deploymentData"
:annotations="annotations"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.metrics)"
:group-id="groupId"
+ v-bind="$attrs"
+ v-on="$listeners"
@datazoom="onDatazoom"
/>
- <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 8f3e0a6ec75..8b86890715f 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -1,7 +1,8 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlAlert,
+ GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
@@ -21,6 +22,7 @@ const events = {
export default {
components: {
GlAlert,
+ GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
@@ -34,11 +36,6 @@ export default {
GlModal: GlModalDirective,
},
props: {
- selectedDashboard: {
- type: Object,
- required: false,
- default: () => ({}),
- },
defaultBranch: {
type: String,
required: true,
@@ -54,26 +51,41 @@ export default {
},
computed: {
...mapState('monitoringDashboard', ['allDashboards']),
+ ...mapGetters('monitoringDashboard', ['selectedDashboard']),
isSystemDashboard() {
- return this.selectedDashboard.system_dashboard;
+ return this.selectedDashboard?.system_dashboard;
},
selectedDashboardText() {
- return this.selectedDashboard.display_name;
+ return this.selectedDashboard?.display_name;
+ },
+ selectedDashboardPath() {
+ return this.selectedDashboard?.path;
},
+
filteredDashboards() {
- return this.allDashboards.filter(({ display_name }) =>
+ return this.allDashboards.filter(({ display_name = '' }) =>
display_name.toLowerCase().includes(this.searchTerm.toLowerCase()),
);
},
shouldShowNoMsgContainer() {
return this.filteredDashboards.length === 0;
},
+ starredDashboards() {
+ return this.filteredDashboards.filter(({ starred }) => starred);
+ },
+ nonStarredDashboards() {
+ return this.filteredDashboards.filter(({ starred }) => !starred);
+ },
+
okButtonText() {
return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
},
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
+ dashboardDisplayName(dashboard) {
+ return dashboard.display_name || dashboard.path || '';
+ },
selectDashboard(dashboard) {
this.$emit(events.selectDashboard, dashboard);
},
@@ -127,15 +139,34 @@ export default {
v-model="searchTerm"
class="m-2"
/>
+
<div class="flex-fill overflow-auto">
<gl-dropdown-item
- v-for="dashboard in filteredDashboards"
+ v-for="dashboard in starredDashboards"
+ :key="dashboard.path"
+ :active="dashboard.path === selectedDashboardPath"
+ active-class="is-active"
+ @click="selectDashboard(dashboard)"
+ >
+ <div class="d-flex">
+ {{ dashboardDisplayName(dashboard) }}
+ <gl-icon class="text-muted ml-auto" name="star" />
+ </div>
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider
+ v-if="starredDashboards.length && nonStarredDashboards.length"
+ ref="starredListDivider"
+ />
+
+ <gl-dropdown-item
+ v-for="dashboard in nonStarredDashboards"
:key="dashboard.path"
- :active="dashboard.path === selectedDashboard.path"
+ :active="dashboard.path === selectedDashboardPath"
active-class="is-active"
@click="selectDashboard(dashboard)"
>
- {{ dashboard.display_name || dashboard.path }}
+ {{ dashboardDisplayName(dashboard) }}
</gl-dropdown-item>
</div>
diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
index 3f8b0f76997..1557a49137e 100644
--- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
+++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions } from 'vuex';
-import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
+import DashboardPanel from '~/monitoring/components/dashboard_panel.vue';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { defaultTimeRange } from '~/vue_shared/constants';
import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils';
@@ -10,7 +10,7 @@ let sidebarMutationObserver;
export default {
components: {
- PanelType,
+ DashboardPanel,
},
props: {
containerClass: {
@@ -113,9 +113,9 @@ export default {
</script>
<template>
<div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass">
- <panel-type
+ <dashboard-panel
v-for="(graphData, graphIndex) in charts"
- :key="`panel-type-${graphIndex}`"
+ :key="`dashboard-panel-${graphIndex}`"
:class="panelClass"
:graph-data="graphData"
:group-id="dashboardUrl"
diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
new file mode 100644
index 00000000000..0ac7c0b80df
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ options: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ defaultText() {
+ const selectedOpt = this.options.find(opt => opt.value === this.value);
+ return selectedOpt?.text || this.value;
+ },
+ },
+ methods: {
+ onUpdate(value) {
+ this.$emit('onUpdate', this.name, value);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label">
+ <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText">
+ <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{
+ opt.text
+ }}</gl-dropdown-item>
+ </gl-dropdown>
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_variable.vue
new file mode 100644
index 00000000000..ce0d19760e2
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables/text_variable.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlFormGroup, GlFormInput } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ },
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ onUpdate(event) {
+ this.$emit('onUpdate', this.name, event.target.value);
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-group :label="label">
+ <gl-form-input
+ :value="value"
+ :name="name"
+ @keyup.native.enter="onUpdate"
+ @blur.native="onUpdate"
+ />
+ </gl-form-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
new file mode 100644
index 00000000000..e054c9d8e26
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/variables_section.vue
@@ -0,0 +1,56 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import CustomVariable from './variables/custom_variable.vue';
+import TextVariable from './variables/text_variable.vue';
+import { setPromCustomVariablesFromUrl } from '../utils';
+
+export default {
+ components: {
+ CustomVariable,
+ TextVariable,
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['promVariables']),
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']),
+ refreshDashboard(variable, value) {
+ if (this.promVariables[variable].value !== value) {
+ const changedVariable = { key: variable, value };
+ // update the Vuex store
+ this.updateVariableValues(changedVariable);
+ // the below calls can ideally be moved out of the
+ // component and into the actions and let the
+ // mutation respond directly.
+ // This can be further investigate in
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/217713
+ setPromCustomVariablesFromUrl(this.promVariables);
+ // fetch data
+ this.fetchDashboardData();
+ }
+ },
+ variableComponent(type) {
+ const types = {
+ text: TextVariable,
+ custom: CustomVariable,
+ };
+ return types[type] || TextVariable;
+ },
+ },
+};
+</script>
+<template>
+ <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section">
+ <div v-for="(variable, key) in promVariables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
+ <component
+ :is="variableComponent(variable.type)"
+ class="mb-0 flex-grow-1"
+ :label="variable.label"
+ :value="variable.value"
+ :name="key"
+ :options="variable.options"
+ @onUpdate="refreshDashboard"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 0b393f19789..0c2eafeed54 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -48,6 +48,55 @@ export const metricStates = {
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
};
+/**
+ * Supported panel types in dashboards, values of `panel.type`.
+ *
+ * Values should not be changed as they correspond to
+ * values in users the `.yml` dashboard definition.
+ */
+export const panelTypes = {
+ /**
+ * Area Chart
+ *
+ * Time Series chart with an area
+ */
+ AREA_CHART: 'area-chart',
+ /**
+ * Line Chart
+ *
+ * Time Series chart with a line
+ */
+ LINE_CHART: 'line-chart',
+ /**
+ * Anomaly Chart
+ *
+ * Time Series chart with 3 metrics
+ */
+ ANOMALY_CHART: 'anomaly-chart',
+ /**
+ * Single Stat
+ *
+ * Single data point visualization
+ */
+ SINGLE_STAT: 'single-stat',
+ /**
+ * Heatmap
+ */
+ HEATMAP: 'heatmap',
+ /**
+ * Bar chart
+ */
+ BAR: 'bar',
+ /**
+ * Column chart
+ */
+ COLUMN: 'column',
+ /**
+ * Stacked column chart
+ */
+ STACKED_COLUMN: 'stacked-column',
+};
+
export const sidebarAnimationDuration = 300; // milliseconds.
export const chartHeight = 300;
@@ -143,3 +192,38 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
* https://gitlab.com/gitlab-org/gitlab/-/issues/214671
*/
export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
+
+export const OPERATORS = {
+ greaterThan: '>',
+ equalTo: '==',
+ lessThan: '<',
+};
+
+/**
+ * Dashboard yml files support custom user-defined variables that
+ * are rendered as input elements in the monitoring dashboard.
+ * These values can be edited by the user and are passed on to the
+ * the backend and eventually to Prometheus API proxy.
+ *
+ * As of 13.0, the supported types are:
+ * simple custom -> dropdown elements
+ * advanced custom -> dropdown elements
+ * text -> text input elements
+ *
+ * Custom variables have a simple and a advanced variant.
+ */
+export const VARIABLE_TYPES = {
+ custom: 'custom',
+ text: 'text',
+};
+
+/**
+ * The names of templating variables defined in the dashboard yml
+ * file are prefixed with a constant so that it doesn't collide with
+ * other URL params that the monitoring dashboard relies on for
+ * features like panel fullscreen etc.
+ *
+ * The prefix is added before it is appended to the URL and removed
+ * before passing the data to the backend.
+ */
+export const VARIABLE_PREFIX = 'var-';
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index d296f5b7a66..2bbf9ef9d78 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
+import Dashboard from '~/monitoring/components/dashboard.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { getParameterValues } from '~/lib/utils/url_utility';
import store from './stores';
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
new file mode 100644
index 00000000000..afe5ee0938d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js
@@ -0,0 +1,13 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+import initCeBundle from '~/monitoring/monitoring_bundle';
+
+export default () => {
+ const el = document.getElementById('prometheus-graphs');
+
+ if (el && el.dataset) {
+ initCeBundle({
+ customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable),
+ prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable),
+ });
+ }
+};
diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js
new file mode 100644
index 00000000000..4b7337972fe
--- /dev/null
+++ b/app/assets/javascripts/monitoring/services/alerts_service.js
@@ -0,0 +1,32 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default class AlertsService {
+ constructor({ alertsEndpoint }) {
+ this.alertsEndpoint = alertsEndpoint;
+ }
+
+ getAlerts() {
+ return axios.get(this.alertsEndpoint).then(resp => resp.data);
+ }
+
+ createAlert({ prometheus_metric_id, operator, threshold }) {
+ return axios
+ .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold })
+ .then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ readAlert(alertPath) {
+ return axios.get(alertPath).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ updateAlert(alertPath, { operator, threshold }) {
+ return axios.put(alertPath, { operator, threshold }).then(resp => resp.data);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ deleteAlert(alertPath) {
+ return axios.delete(alertPath).then(resp => resp.data);
+ }
+}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index f04f775761c..b057afa2264 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -3,6 +3,8 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
+import { parseTemplatingVariables } from './variable_mapping';
+import { mergeURLVariables } from '../utils';
import {
gqClient,
parseEnvironmentsResponse,
@@ -13,11 +15,7 @@ import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
import statusCodes from '../../lib/utils/http_status';
-import {
- backOff,
- convertObjectPropsToCamelCase,
- isFeatureFlagEnabled,
-} from '../../lib/utils/common_utils';
+import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
import {
@@ -80,6 +78,10 @@ export const setTimeRange = ({ commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
};
+export const setVariables = ({ commit }, variables) => {
+ commit(types.SET_VARIABLES, variables);
+};
+
export const filterEnvironments = ({ commit, dispatch }, searchTerm) => {
commit(types.SET_ENVIRONMENTS_FILTER, searchTerm);
dispatch('fetchEnvironmentsData');
@@ -89,19 +91,30 @@ export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
+export const setExpandedPanel = ({ commit }, { group, panel }) => {
+ commit(types.SET_EXPANDED_PANEL, { group, panel });
+};
+
+export const clearExpandedPanel = ({ commit }) => {
+ commit(types.SET_EXPANDED_PANEL, {
+ group: null,
+ panel: null,
+ });
+};
+
// All Data
+/**
+ * Fetch all dashboard data.
+ *
+ * @param {Object} store
+ * @returns A promise that resolves when the dashboard
+ * skeleton has been loaded.
+ */
export const fetchData = ({ dispatch }) => {
dispatch('fetchEnvironmentsData');
dispatch('fetchDashboard');
- /**
- * Annotations data is not yet fetched. This will be
- * ready after the BE piece is implemented.
- * https://gitlab.com/gitlab-org/gitlab/-/issues/211330
- */
- if (isFeatureFlagEnabled('metricsDashboardAnnotations')) {
- dispatch('fetchAnnotations');
- }
+ dispatch('fetchAnnotations');
};
// Metrics dashboard
@@ -148,6 +161,7 @@ export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response
commit(types.SET_ALL_DASHBOARDS, all_dashboards);
commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard);
+ commit(types.SET_VARIABLES, mergeURLVariables(parseTemplatingVariables(dashboard.templating)));
commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data));
return dispatch('fetchDashboardData');
@@ -200,12 +214,19 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
*
* @param {metric} metric
*/
-export const fetchPrometheusMetric = ({ commit }, { metric, defaultQueryParams }) => {
+export const fetchPrometheusMetric = (
+ { commit, state, getters },
+ { metric, defaultQueryParams },
+) => {
const queryParams = { ...defaultQueryParams };
if (metric.step) {
queryParams.step = metric.step;
}
+ if (Object.keys(state.promVariables).length > 0) {
+ queryParams.variables = getters.getCustomVariablesArray;
+ }
+
commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId });
return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams)
@@ -327,6 +348,35 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN
// Dashboard manipulation
+export const toggleStarredValue = ({ commit, state, getters }) => {
+ const { selectedDashboard } = getters;
+
+ if (state.isUpdatingStarredValue) {
+ // Prevent repeating requests for the same change
+ return;
+ }
+ if (!selectedDashboard) {
+ return;
+ }
+
+ const method = selectedDashboard.starred ? 'DELETE' : 'POST';
+ const url = selectedDashboard.user_starred_path;
+ const newStarredValue = !selectedDashboard.starred;
+
+ commit(types.REQUEST_DASHBOARD_STARRING);
+
+ axios({
+ url,
+ method,
+ })
+ .then(() => {
+ commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue);
+ })
+ .catch(() => {
+ commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE);
+ });
+};
+
/**
* Set a new array of metrics to a panel group
* @param {*} data An object containing
@@ -364,5 +414,11 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
});
};
+// Variables manipulation
+
+export const updateVariableValues = ({ commit }, updatedVariable) => {
+ commit(types.UPDATE_VARIABLE_VALUES, updatedVariable);
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js
index a6d80c5063e..ae3ff5596e1 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,9 +1,25 @@
+import { flatMap } from 'lodash';
import { NOT_IN_DB_PREFIX } from '../constants';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
/**
+ * Returns a reference to the currently selected dashboard
+ * from the list of dashboards.
+ *
+ * @param {Object} state
+ */
+export const selectedDashboard = state => {
+ const { allDashboards } = state;
+ return (
+ allDashboards.find(d => d.path === state.currentDashboard) ||
+ allDashboards.find(d => d.default) ||
+ null
+ );
+};
+
+/**
* Get all state for metric in the dashboard or a group. The
* states are not repeated so the dashboard or group can show
* a global state.
@@ -96,5 +112,17 @@ export const filteredEnvironments = state =>
env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()),
);
+/**
+ * Maps an variables object to an array along with stripping
+ * the variable prefix.
+ *
+ * @param {Object} variables - Custom variables provided by the user
+ * @returns {Array} The custom variables array to be send to the API
+ * in the format of [variable1, variable1_value]
+ */
+
+export const getCustomVariablesArray = state =>
+ flatMap(state.promVariables, (variable, key) => [key, variable.value]);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 27a9a67edaa..d60334609fd 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -1,7 +1,13 @@
-// Dashboard "skeleton", groups, panels and metrics
+// Dashboard "skeleton", groups, panels, metrics, query variables
export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD';
export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
+export const SET_VARIABLES = 'SET_VARIABLES';
+export const UPDATE_VARIABLE_VALUES = 'UPDATE_VARIABLE_VALUES';
+
+export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
+export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
+export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE';
// Annotations
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
@@ -31,5 +37,5 @@ 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';
export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
-
export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER';
+export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index aa31b6642d7..f41cf3fc477 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,5 +1,7 @@
-import pick from 'lodash/pick';
+import Vue from 'vue';
+import { pick } from 'lodash';
import * as types from './mutation_types';
+import { selectedDashboard } from './getters';
import { mapToDashboardViewModel, normalizeQueryResult } from './utils';
import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
import { endpointKeys, initialStateKeys, metricStates } from '../constants';
@@ -71,6 +73,23 @@ export default {
state.showEmptyState = true;
},
+ [types.REQUEST_DASHBOARD_STARRING](state) {
+ state.isUpdatingStarredValue = true;
+ },
+ [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, newStarredValue) {
+ const dashboard = selectedDashboard(state);
+ const index = state.allDashboards.findIndex(d => d === dashboard);
+
+ state.isUpdatingStarredValue = false;
+
+ // Trigger state updates in the reactivity system for this change
+ // https://vuejs.org/v2/guide/reactivity.html#For-Arrays
+ Vue.set(state.allDashboards, index, { ...dashboard, starred: newStarredValue });
+ },
+ [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) {
+ state.isUpdatingStarredValue = false;
+ },
+
/**
* Deployments and environments
*/
@@ -134,6 +153,8 @@ export default {
metric.loading = false;
metric.result = null;
},
+
+ // Parameters and other information
[types.SET_INITIAL_STATE](state, initialState = {}) {
Object.assign(state, pick(initialState, initialStateKeys));
},
@@ -163,4 +184,17 @@ export default {
[types.SET_ENVIRONMENTS_FILTER](state, searchTerm) {
state.environmentsSearchTerm = searchTerm;
},
+ [types.SET_EXPANDED_PANEL](state, { group, panel }) {
+ state.expandedPanel.group = group;
+ state.expandedPanel.panel = panel;
+ },
+ [types.SET_VARIABLES](state, variables) {
+ state.promVariables = variables;
+ },
+ [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) {
+ Object.assign(state.promVariables[updatedVariable.key], {
+ ...state.promVariables[updatedVariable.key],
+ value: updatedVariable.value,
+ });
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index e60510e747b..9ae1da93e5f 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -14,10 +14,27 @@ export default () => ({
emptyState: 'gettingStarted',
showEmptyState: true,
showErrorBanner: true,
+ isUpdatingStarredValue: false,
dashboard: {
panelGroups: [],
},
+ /**
+ * Panel that is currently "zoomed" in as
+ * a single panel in view.
+ */
+ expandedPanel: {
+ /**
+ * {?String} Panel's group name.
+ */
+ group: null,
+ /**
+ * {?Object} Panel content from `dashboard`
+ * null when no panel is expanded.
+ */
+ panel: null,
+ },
allDashboards: [],
+ promVariables: {},
// Other project data
annotations: [],
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 9f06d18c46f..a47e5f598f5 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -144,6 +144,7 @@ const mapYAxisToViewModel = ({
* @returns {Object}
*/
const mapPanelToViewModel = ({
+ id = null,
title = '',
type,
x_axis = {},
@@ -162,6 +163,7 @@ const mapPanelToViewModel = ({
const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase
return {
+ id,
title,
type,
xLabel: xAxis.name,
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
new file mode 100644
index 00000000000..bfb469da19e
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -0,0 +1,167 @@
+import { isString } from 'lodash';
+import { VARIABLE_TYPES } from '../constants';
+
+/**
+ * This file exclusively deals with parsing user-defined variables
+ * in dashboard yml file.
+ *
+ * As of 13.0, simple text, advanced text, simple custom and
+ * advanced custom variables are supported.
+ *
+ * In the future iterations, text and query variables will be
+ * supported
+ *
+ */
+
+/**
+ * Simple text variable is a string value only.
+ * This method parses such variables to a standard format.
+ *
+ * @param {String|Object} simpleTextVar
+ * @returns {Object}
+ */
+const textSimpleVariableParser = simpleTextVar => ({
+ type: VARIABLE_TYPES.text,
+ label: null,
+ value: simpleTextVar,
+});
+
+/**
+ * Advanced text variable is an object.
+ * This method parses such variables to a standard format.
+ *
+ * @param {Object} advTextVar
+ * @returns {Object}
+ */
+const textAdvancedVariableParser = advTextVar => ({
+ type: VARIABLE_TYPES.text,
+ label: advTextVar.label,
+ value: advTextVar.options.default_value,
+});
+
+/**
+ * Normalize simple and advanced custom variable options to a standard
+ * format
+ * @param {Object} custom variable option
+ * @returns {Object} normalized custom variable options
+ */
+const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
+ default: defaultOpt,
+ text,
+ value,
+});
+
+/**
+ * Custom advanced variables are rendered as dropdown elements in the dashboard
+ * header. This method parses advanced custom variables.
+ *
+ * The default value is the option with default set to true or the first option
+ * if none of the options have default prop true.
+ *
+ * @param {Object} advVariable advance custom variable
+ * @returns {Object}
+ */
+const customAdvancedVariableParser = advVariable => {
+ const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions);
+ const defaultOpt = options.find(opt => opt.default === true) || options[0];
+ return {
+ type: VARIABLE_TYPES.custom,
+ label: advVariable.label,
+ value: defaultOpt?.value,
+ options,
+ };
+};
+
+/**
+ * Simple custom variables have an array of values.
+ * This method parses such variables options to a standard format.
+ *
+ * @param {String} opt option from simple custom variable
+ * @returns {Object}
+ */
+const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
+
+/**
+ * Custom simple variables are rendered as dropdown elements in the dashboard
+ * header. This method parses simple custom variables.
+ *
+ * Simple custom variables do not have labels so its set to null here.
+ *
+ * The default value is set to the first option as the user cannot
+ * set a default value for this format
+ *
+ * @param {Array} customVariable array of options
+ * @returns {Object}
+ */
+const customSimpleVariableParser = simpleVar => {
+ const options = (simpleVar || []).map(parseSimpleCustomOptions);
+ return {
+ type: VARIABLE_TYPES.custom,
+ value: options[0].value,
+ label: null,
+ options: options.map(normalizeCustomVariableOptions),
+ };
+};
+
+/**
+ * Utility method to determine if a custom variable is
+ * simple or not. If its not simple, it is advanced.
+ *
+ * @param {Array|Object} customVar Array if simple, object if advanced
+ * @returns {Boolean} true if simple, false if advanced
+ */
+const isSimpleCustomVariable = customVar => Array.isArray(customVar);
+
+/**
+ * This method returns a parser based on the type of the variable.
+ * Currently, the supported variables are simple custom and
+ * advanced custom only. In the future, this method will support
+ * text and query variables.
+ *
+ * @param {Array|Object} variable
+ * @return {Function} parser method
+ */
+const getVariableParser = variable => {
+ if (isSimpleCustomVariable(variable)) {
+ return customSimpleVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.custom) {
+ return customAdvancedVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.text) {
+ return textAdvancedVariableParser;
+ } else if (isString(variable)) {
+ return textSimpleVariableParser;
+ }
+ return () => null;
+};
+
+/**
+ * This method parses the templating property in the dashboard yml file.
+ * The templating property has variables that are rendered as input elements
+ * for the user to edit. The values from input elements are relayed to
+ * backend and eventually Prometheus API.
+ *
+ * This method currently is not used anywhere. Once the issue
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed,
+ * this method will have been used by the monitoring dashboard.
+ *
+ * @param {Object} templating templating variables from the dashboard yml file
+ * @returns {Object} a map of processed templating variables
+ */
+export const parseTemplatingVariables = ({ variables = {} } = {}) =>
+ Object.entries(variables).reduce((acc, [key, variable]) => {
+ // get the parser
+ const parser = getVariableParser(variable);
+ // parse the variable
+ const parsedVar = parser(variable);
+ // for simple custom variable label is null and it should be
+ // replace with key instead
+ if (parsedVar) {
+ acc[key] = {
+ ...parsedVar,
+ label: parsedVar.label || key,
+ };
+ }
+ return acc;
+ }, {});
+
+export default {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 7c6cd19eb7b..1f028ffbcad 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,9 +1,23 @@
-import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import { pickBy, mapKeys } from 'lodash';
+import {
+ queryToObject,
+ mergeUrlParams,
+ removeParams,
+ updateHistory,
+} from '~/lib/utils/url_utility';
import {
timeRangeParamNames,
timeRangeFromParams,
timeRangeToParams,
} from '~/lib/utils/datetime_range';
+import { VARIABLE_PREFIX } from './constants';
+
+/**
+ * List of non time range url parameters
+ * This will be removed once we add support for free text variables
+ * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689
+ */
+export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded'];
/**
* This method is used to validate if the graph data format for a chart component
@@ -28,7 +42,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Checks that element that triggered event is located on cluster health check dashboard
* @param {HTMLElement} element to check against
@@ -36,6 +49,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
+/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -71,6 +85,7 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
+/* eslint-enable @gitlab/require-i18n-strings */
/**
* Generate options for snowplow to track adding a new metric via the dashboard
@@ -113,6 +128,78 @@ export const timeRangeFromUrl = (search = window.location.search) => {
};
/**
+ * Variable labels are used as names for the dropdowns and also
+ * as URL params. Prefixing the name reduces the risk of
+ * collision with other URL params
+ *
+ * @param {String} label label for the template variable
+ * @returns {String}
+ */
+export const addPrefixToLabel = label => `${VARIABLE_PREFIX}${label}`;
+
+/**
+ * Before the templating variables are passed to the backend the
+ * prefix needs to be removed.
+ *
+ * This method removes the prefix at the beginning of the string.
+ *
+ * @param {String} label label to remove prefix from
+ * @returns {String}
+ */
+export const removePrefixFromLabel = label =>
+ (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), '');
+
+/**
+ * Convert parsed template variables to an object
+ * with just keys and values. Prepare the promVariables
+ * to be added to the URL. Keys of the object will
+ * have a prefix so that these params can be
+ * differentiated from other URL params.
+ *
+ * @param {Object} variables
+ * @returns {Object}
+ */
+export const convertVariablesForURL = variables =>
+ Object.keys(variables || {}).reduce((acc, key) => {
+ acc[addPrefixToLabel(key)] = variables[key]?.value;
+ return acc;
+ }, {});
+
+/**
+ * User-defined variables from the URL are extracted. The variables
+ * begin with a constant prefix so that it doesn't collide with
+ * other URL params.
+ *
+ * @param {String} New URL
+ * @returns {Object} The custom variables defined by the user in the URL
+ */
+
+export const getPromCustomVariablesFromUrl = (search = window.location.search) => {
+ const params = queryToObject(search);
+ // pick the params with variable prefix
+ const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX));
+ // remove the prefix before storing in the Vuex store
+ return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key));
+};
+
+/**
+ * Update the URL with promVariables. This usually get triggered when
+ * the user interacts with the dynamic input elements in the monitoring
+ * dashboard header.
+ *
+ * @param {Object} promVariables user defined variables
+ */
+export const setPromCustomVariablesFromUrl = promVariables => {
+ // prep the variables to append to URL
+ const parsedVariables = convertVariablesForURL(promVariables);
+ // update the URL
+ updateHistory({
+ url: mergeUrlParams(parsedVariables, window.location.href),
+ title: document.title,
+ });
+};
+
+/**
* Returns a URL with no time range based on the current URL.
*
* @param {String} New URL
@@ -133,6 +220,81 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => {
};
/**
+ * Locates a panel (and its corresponding group) given a (URL) search query. Returns
+ * it as payload for the store to set the right expandaded panel.
+ *
+ * Params used to locate a panel are:
+ * - group: Group identifier
+ * - title: Panel title
+ * - y_label: Panel y_label
+ *
+ * @param {Object} dashboard - Dashboard reference from the Vuex store
+ * @param {String} search - URL location search query
+ * @returns {Object} payload - Payload for expanded panel to be displayed
+ * @returns {String} payload.group - Group where panel is located
+ * @returns {Object} payload.panel - Dashboard panel (graphData) reference
+ * @throws Will throw an error if Panel cannot be located.
+ */
+export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => {
+ const params = queryToObject(search);
+
+ // Search for the panel if any of the search params is identified
+ if (params.group || params.title || params.y_label) {
+ const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
+ const panel = panelGroup.panels.find(
+ // eslint-disable-next-line babel/camelcase
+ ({ y_label, title }) => y_label === params.y_label && title === params.title,
+ );
+
+ if (!panel) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Panel could no found by URL parameters.');
+ }
+ return { group: panelGroup.group, panel };
+ }
+ return null;
+};
+
+/**
+ * Convert panel information to a URL for the user to
+ * bookmark or share highlighting a specific panel.
+ *
+ * If no group/panel is set, the dashboard URL is returned.
+ *
+ * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard
+ * @param {?Object} promVariables - Custom variables that came from the URL
+ * @param {?String} group - Group Identifier
+ * @param {?Object} panel - Panel object from the dashboard
+ * @param {?String} url - Base URL including current search params
+ * @returns Dashboard URL which expands a panel (chart)
+ */
+export const panelToUrl = (
+ dashboard = null,
+ promVariables,
+ group,
+ panel,
+ url = window.location.href,
+) => {
+ const params = {
+ dashboard,
+ ...promVariables,
+ };
+
+ if (group && panel) {
+ params.group = group;
+ params.title = panel.title;
+ params.y_label = panel.y_label;
+ } else {
+ // Remove existing parameters if any
+ params.group = null;
+ params.title = null;
+ params.y_label = null;
+ }
+
+ return mergeUrlParams(params, url);
+};
+
+/**
* Get the metric value from first data point.
* Currently only used for bar charts
*
@@ -191,4 +353,39 @@ export const barChartsDataParser = (data = []) =>
{},
);
+/**
+ * Custom variables are defined in the dashboard yml file
+ * and their values can be passed through the URL.
+ *
+ * On component load, this method merges variables data
+ * from the yml file with URL data to store in the Vuex store.
+ * Not all params coming from the URL need to be stored. Only
+ * the ones that have a corresponding variable defined in the
+ * yml file.
+ *
+ * This ensures that there is always a single source of truth
+ * for variables
+ *
+ * This method can be improved further. See the below issue
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/217713
+ *
+ * @param {Object} varsFromYML template variables from yml file
+ * @returns {Object}
+ */
+export const mergeURLVariables = (varsFromYML = {}) => {
+ const varsFromURL = getPromCustomVariablesFromUrl();
+ const variables = {};
+ Object.keys(varsFromYML).forEach(key => {
+ if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) {
+ variables[key] = {
+ ...varsFromYML[key],
+ value: varsFromURL[key],
+ };
+ } else {
+ variables[key] = varsFromYML[key];
+ }
+ });
+ return variables;
+};
+
export default {};
diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js
new file mode 100644
index 00000000000..cd426f1a221
--- /dev/null
+++ b/app/assets/javascripts/monitoring/validators.js
@@ -0,0 +1,44 @@
+// Prop validator for alert information, expecting an object like the example below.
+//
+// {
+// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': {
+// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37",
+// metricId: '1',
+// operator: ">",
+// query: "rate(http_requests_total[5m])[30m:1m]",
+// threshold: 0.002,
+// title: "Core Usage (Total)",
+// }
+// }
+export function alertsValidator(value) {
+ return Object.keys(value).every(key => {
+ const alert = value[key];
+ return (
+ alert.alert_path &&
+ key === alert.alert_path &&
+ alert.metricId &&
+ typeof alert.metricId === 'string' &&
+ alert.operator &&
+ typeof alert.threshold === 'number'
+ );
+ });
+}
+
+// Prop validator for query information, expecting an array like the example below.
+//
+// [
+// {
+// metricId: '16',
+// label: 'Total Cores'
+// },
+// {
+// metricId: '17',
+// label: 'Sub-total Cores'
+// }
+// ]
+export function queriesValidator(value) {
+ return value.every(
+ query =>
+ query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string',
+ );
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index 8671f0fd783..b96a111cf13 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -1,5 +1,3 @@
-/* eslint-disable no-else-return */
-
import $ from 'jquery';
import '~/gl_dropdown';
import Api from './api';
@@ -23,9 +21,8 @@ export default class NamespaceSelect {
toggleLabel(selected) {
if (selected.id == null) {
return selected.text;
- } else {
- return `${selected.kind}: ${selected.full_path}`;
}
+ return `${selected.kind}: ${selected.full_path}`;
},
data(term, dataCallback) {
return Api.namespaces(term, namespaces => {
@@ -43,9 +40,8 @@ export default class NamespaceSelect {
text(namespace) {
if (namespace.id == null) {
return namespace.text;
- } else {
- return `${namespace.kind}: ${namespace.full_path}`;
}
+ return `${namespace.kind}: ${namespace.full_path}`;
},
renderRow: this.renderRow,
clicked(options) {
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index be3ea4e680c..9d064894433 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/require-i18n-strings */
+/* eslint-disable func-names, consistent-return, no-return-assign, @gitlab/require-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -76,9 +76,8 @@ export default class NewBranchForm {
const matched = this.name.val().match(restriction.pattern);
if (matched) {
return errors.concat(formatter(matched.reduce(unique, []), restriction));
- } else {
- return errors;
}
+ return errors;
};
const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue
index dab27cf8269..fcb09ea90db 100644
--- a/app/assets/javascripts/notebook/cells/markdown.vue
+++ b/app/assets/javascripts/notebook/cells/markdown.vue
@@ -36,9 +36,9 @@ const katexRegexString = `(
.replace(/\s/g, '')
.trim();
-renderer.paragraph = t => {
+function renderKatex(t) {
let text = t;
- let inline = false;
+ let numInline = 0; // number of successfull converted math formulas
if (typeof katex !== 'undefined') {
const katexString = text
@@ -50,24 +50,40 @@ renderer.paragraph = t => {
const numberOfMatches = katexString.match(regex);
if (numberOfMatches && numberOfMatches.length !== 0) {
+ let matches = regex.exec(katexString);
if (matchLocation > 0) {
- let matches = regex.exec(katexString);
- inline = true;
+ numInline += 1;
while (matches !== null) {
- const renderedKatex = katex.renderToString(matches[0].replace(/\$/g, ''));
- text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ try {
+ const renderedKatex = katex.renderToString(
+ matches[0].replace(/\$/g, '').replace(/&#39;/g, "'"),
+ ); // get the tick ' back again from HTMLified string
+ text = `${text.replace(matches[0], ` ${renderedKatex}`)}`;
+ } catch {
+ numInline -= 1;
+ }
matches = regex.exec(katexString);
}
} else {
- const matches = regex.exec(katexString);
- text = katex.renderToString(matches[2]);
+ try {
+ text = katex.renderToString(matches[2].replace(/&#39;/g, "'"));
+ } catch (error) {
+ numInline -= 1;
+ }
}
}
}
-
+ return [text, numInline > 0];
+}
+renderer.paragraph = t => {
+ const [text, inline] = renderKatex(t);
return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`;
};
+renderer.listitem = t => {
+ const [text, inline] = renderKatex(t);
+ return `<li class="${inline ? 'inline-katex' : ''}">${text}</li>`;
+};
marked.setOptions({
renderer,
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 61626f7aaf5..f2d3796cccf 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -63,6 +63,9 @@ export default {
},
rawCode(output) {
if (output.text) {
+ if (typeof output.text === 'string') {
+ return output.text;
+ }
return output.text.join('');
}
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 9e2231922b7..6e695de447d 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,6 +1,6 @@
/* eslint-disable no-restricted-properties, babel/camelcase,
no-unused-expressions, default-case,
-consistent-return, no-alert, no-param-reassign, no-else-return,
+consistent-return, no-alert, no-param-reassign,
no-shadow, no-useless-escape,
class-methods-use-this */
@@ -256,7 +256,7 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) {
+ if (!window.confirm(__('Your comment will be discarded.'))) {
return;
}
}
@@ -268,7 +268,7 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) {
+ if (!window.confirm(__('Are you sure you want to discard this comment?'))) {
return;
}
}
@@ -964,11 +964,11 @@ export default class Notes {
form
.prepend(
- `<div class="avatar-note-form-holder"><div class="content"><a href="${escape(
+ `<a href="${escape(
gon.current_username,
)}" class="user-avatar-link d-none d-sm-block"><img class="avatar s40" src="${encodeURI(
- gon.current_user_avatar_url,
- )}" alt="${escape(gon.current_user_fullname)}" /></a></div></div>`,
+ gon.current_user_avatar_url || gon.default_avatar_url,
+ )}" alt="${escape(gon.current_user_fullname)}" /></a>`,
)
.append('</div>')
.find('.js-close-discussion-note-form')
@@ -1123,10 +1123,9 @@ export default class Notes {
if (row.is('.js-temp-notes-holder')) {
// remove temporary row for diff lines
return row.remove();
- } else {
- // only remove the form
- return form.remove();
}
+ // only remove the form
+ return form.remove();
}
cancelDiscussionForm(e) {
@@ -1397,7 +1396,7 @@ export default class Notes {
}
/**
- * Check if note does not exists on page
+ * Check if note does not exist on page
*/
static isNewNote(noteEntity, noteIds) {
return $.inArray(noteEntity.id, noteIds) === -1;
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 9a809b71a58..a070cf8866a 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -3,6 +3,7 @@ import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
import { isEmpty } from 'lodash';
import Autosize from 'autosize';
+import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
@@ -34,6 +35,10 @@ export default {
userAvatarLink,
loadingButton,
TimelineEntryItem,
+ GlAlert,
+ GlIntersperse,
+ GlLink,
+ GlSprintf,
},
mixins: [issuableStateMixin],
props: {
@@ -57,8 +62,9 @@ export default {
'getNoteableData',
'getNotesData',
'openState',
+ 'getBlockedByIssues',
]),
- ...mapState(['isToggleStateButtonLoading']),
+ ...mapState(['isToggleStateButtonLoading', 'isToggleBlockedIssueWarning']),
noteableDisplayName() {
return splitCamelCase(this.noteableType).toLowerCase();
},
@@ -159,6 +165,7 @@ export default {
'reopenIssue',
'toggleIssueLocalState',
'toggleStateButtonLoading',
+ 'toggleBlockedIssueWarning',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!isEmpty(note) && !isSubmitting) {
@@ -220,22 +227,17 @@ export default {
this.isSubmitting = false;
},
toggleIssueState() {
+ if (
+ this.noteableType.toLowerCase() === constants.ISSUE_NOTEABLE_TYPE &&
+ this.isOpen &&
+ this.getBlockedByIssues &&
+ this.getBlockedByIssues.length > 0
+ ) {
+ this.toggleBlockedIssueWarning(true);
+ return;
+ }
if (this.isOpen) {
- this.closeIssue()
- .then(() => {
- this.enableButton();
- refreshUserMergeRequestCounts();
- })
- .catch(() => {
- this.enableButton();
- this.toggleStateButtonLoading(false);
- Flash(
- sprintf(
- __('Something went wrong while closing the %{issuable}. Please try again later'),
- { issuable: this.noteableDisplayName },
- ),
- );
- });
+ this.forceCloseIssue();
} else {
this.reopenIssue()
.then(() => {
@@ -258,6 +260,23 @@ export default {
});
}
},
+ forceCloseIssue() {
+ this.closeIssue()
+ .then(() => {
+ this.enableButton();
+ refreshUserMergeRequestCounts();
+ })
+ .catch(() => {
+ this.enableButton();
+ this.toggleStateButtonLoading(false);
+ Flash(
+ sprintf(
+ __('Something went wrong while closing the %{issuable}. Please try again later'),
+ { issuable: this.noteableDisplayName },
+ ),
+ );
+ });
+ },
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
@@ -361,6 +380,36 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
>
</textarea>
</markdown-field>
+ <gl-alert
+ v-if="isToggleBlockedIssueWarning"
+ class="prepend-top-16"
+ :title="__('Are you sure you want to close this blocked issue?')"
+ :primary-button-text="__('Yes, close issue')"
+ :secondary-button-text="__('Cancel')"
+ variant="warning"
+ :dismissible="false"
+ @primaryAction="forceCloseIssue"
+ @secondaryAction="toggleBlockedIssueWarning(false) && enableButton()"
+ >
+ <p>
+ <gl-sprintf
+ :message="
+ __('This issue is currently blocked by the following issues: %{issues}.')
+ "
+ >
+ <template #issues>
+ <gl-intersperse>
+ <gl-link
+ v-for="blockingIssue in getBlockedByIssues"
+ :key="blockingIssue.web_url"
+ :href="blockingIssue.web_url"
+ >#{{ blockingIssue.iid }}</gl-link
+ >
+ </gl-intersperse>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-alert>
<div class="note-form-actions">
<div
class="float-left btn-group
@@ -427,7 +476,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</div>
<loading-button
- v-if="canToggleIssueState"
+ v-if="canToggleIssueState && !isToggleBlockedIssueWarning"
:loading="isToggleStateButtonLoading"
:container-class="[
actionButtonClassNames,
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index 07952f9edd9..4a1a1086329 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -29,9 +29,6 @@ export default {
resolveAllDiscussionsIssuePath() {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
- resolvedDiscussionsCount() {
- return this.resolvableDiscussionsCount - this.unresolvedDiscussionsCount;
- },
toggeableDiscussions() {
return this.discussions.filter(discussion => !discussion.individual_note);
},
@@ -60,15 +57,15 @@ export default {
<div class="full-width-mobile d-flex d-sm-flex">
<div class="line-resolve-all">
<span
- :class="{ 'is-active': allResolved }"
- class="line-resolve-btn is-disabled"
- type="button"
+ :class="{ 'line-resolve-btn is-active': allResolved, 'line-resolve-text': !allResolved }"
>
- <icon :name="allResolved ? 'check-circle-filled' : 'check-circle'" />
- </span>
- <span class="line-resolve-text">
- {{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
- {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }}
+ <template v-if="allResolved">
+ <icon name="check-circle-filled" />
+ {{ __('All threads resolved') }}
+ </template>
+ <template v-else>
+ {{ n__('%d unresolved thread', '%d unresolved threads', unresolvedDiscussionsCount) }}
+ </template>
</span>
</div>
<div
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index b024884bea0..21d0bffdf1c 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -328,7 +328,8 @@ export default {
<button
class="btn note-edit-cancel js-close-discussion-note-form"
type="button"
- @click="cancelHandler()"
+ data-testid="cancelBatchCommentsEnabled"
+ @click="cancelHandler(true)"
>
{{ __('Cancel') }}
</button>
@@ -353,7 +354,8 @@ export default {
<button
class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
type="button"
- @click="cancelHandler()"
+ data-testid="cancel"
+ @click="cancelHandler(true)"
>
{{ __('Cancel') }}
</button>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index f82b3554cac..81812ee2279 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -1,12 +1,17 @@
<script>
import { mapActions } from 'vuex';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import GitlabTeamMemberBadge from '~/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue';
export default {
components: {
timeAgoTooltip,
- GitlabTeamMemberBadge,
+ GitlabTeamMemberBadge: () =>
+ import('ee_component/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue'),
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
author: {
@@ -44,6 +49,18 @@ export default {
required: false,
default: true,
},
+ isConfidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isUsernameLinkHovered: false,
+ emojiTitle: '',
+ authorStatusHasTooltip: false,
+ };
},
computed: {
toggleChevronClass() {
@@ -55,10 +72,29 @@ export default {
hasAuthor() {
return this.author && Object.keys(this.author).length;
},
- showGitlabTeamMemberBadge() {
- return this.author?.is_gitlab_employee;
+ authorLinkClasses() {
+ return {
+ hover: this.isUsernameLinkHovered,
+ 'text-underline': this.isUsernameLinkHovered,
+ 'author-name-link': true,
+ 'js-user-link': true,
+ };
+ },
+ authorStatus() {
+ return this.author.status_tooltip_html;
+ },
+ emojiElement() {
+ return this.$refs?.authorStatus?.querySelector('gl-emoji');
},
},
+ mounted() {
+ this.emojiTitle = this.emojiElement ? this.emojiElement.getAttribute('title') : '';
+
+ const authorStatusTitle = this.$refs?.authorStatus
+ ?.querySelector('.user-status-emoji')
+ ?.getAttribute('title');
+ this.authorStatusHasTooltip = authorStatusTitle && authorStatusTitle !== '';
+ },
methods: {
...mapActions(['setTargetNoteHash']),
handleToggle() {
@@ -69,6 +105,20 @@ export default {
this.setTargetNoteHash(this.noteTimestampLink);
}
},
+ removeEmojiTitle() {
+ this.emojiElement.removeAttribute('title');
+ },
+ addEmojiTitle() {
+ this.emojiElement.setAttribute('title', this.emojiTitle);
+ },
+ handleUsernameMouseEnter() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter'));
+ this.isUsernameLinkHovered = true;
+ },
+ handleUsernameMouseLeave() {
+ this.$refs.authorNameLink.dispatchEvent(new Event('mouseleave'));
+ this.isUsernameLinkHovered = false;
+ },
},
};
</script>
@@ -87,18 +137,34 @@ export default {
</div>
<template v-if="hasAuthor">
<a
- v-once
+ ref="authorNameLink"
:href="author.path"
- class="js-user-link"
+ :class="authorLinkClasses"
:data-user-id="author.id"
:data-username="author.username"
>
<slot name="note-header-info"></slot>
<span class="note-header-author-name bold">{{ author.name }}</span>
- <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light">@{{ author.username }}</span>
</a>
- <gitlab-team-member-badge v-if="showGitlabTeamMemberBadge" />
+ <span
+ v-if="authorStatus"
+ ref="authorStatus"
+ v-on="
+ authorStatusHasTooltip ? { mouseenter: removeEmojiTitle, mouseleave: addEmojiTitle } : {}
+ "
+ v-html="authorStatus"
+ ></span>
+ <span class="text-nowrap author-username">
+ <a
+ ref="authorUsernameLink"
+ class="author-username-link"
+ :href="author.path"
+ @mouseenter="handleUsernameMouseEnter"
+ @mouseleave="handleUsernameMouseLeave"
+ ><span class="note-headline-light">@{{ author.username }}</span>
+ </a>
+ <gitlab-team-member-badge v-if="author && author.is_gitlab_employee" />
+ </span>
</template>
<span v-else>{{ __('A deleted user') }}</span>
<span class="note-headline-light note-headline-meta">
@@ -118,6 +184,15 @@ export default {
</a>
<time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" />
</template>
+ <gl-icon
+ v-if="isConfidential"
+ v-gl-tooltip:tooltipcontainer.bottom
+ data-testid="confidentialIndicator"
+ name="eye-slash"
+ :size="14"
+ :title="s__('Notes|Private comments are accessible by internal staff only')"
+ class="gl-ml-1 gl-text-gray-800 align-middle"
+ />
<slot name="extra-controls"></slot>
<i
v-if="showSpinner"
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index dea782683f2..37675e20b3d 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -255,10 +255,16 @@ export default {
</div>
<div class="timeline-content">
<div class="note-header">
- <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id">
+ <note-header
+ v-once
+ :author="author"
+ :created-at="note.created_at"
+ :note-id="note.id"
+ :is-confidential="note.confidential"
+ >
<slot slot="note-header-info" name="note-header-info"></slot>
<span v-if="commit" v-html="actionText"></span>
- <span v-else class="d-none d-sm-inline">&middot;</span>
+ <span v-else-if="note.created_at" class="d-none d-sm-inline">&middot;</span>
</note-header>
<note-actions
:author-id="author.id"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index c1dd56aedf2..faa6006945d 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -230,10 +230,11 @@ export default {
const defaultConfig = { path: this.getNotesDataByProp('discussionsPath') };
if (doesHashExistInUrl(constants.NOTE_UNDERSCORE)) {
- return Object.assign({}, defaultConfig, {
+ return {
+ ...defaultConfig,
filter: constants.DISCUSSION_FILTERS_DEFAULT_VALUE,
persistFilter: false,
- });
+ };
}
return defaultConfig;
},
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 8f9e2359e0d..ba814649078 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -2,11 +2,9 @@ import Vue from 'vue';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import initSortDiscussions from './sort_discussions';
-import createStore from './stores';
+import { store } from './stores';
document.addEventListener('DOMContentLoaded', () => {
- const store = createStore();
-
// eslint-disable-next-line no-new
new Vue({
el: '#js-vue-notes',
diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js
index 08c7efd69a6..c9026352d18 100644
--- a/app/assets/javascripts/notes/mixins/discussion_navigation.js
+++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js
@@ -1,6 +1,6 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { scrollToElement } from '~/lib/utils/common_utils';
-import eventHub from '../../notes/event_hub';
+import eventHub from '../event_hub';
/**
* @param {string} selector
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 1b80b59621a..0999d0aa7ac 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -185,12 +185,27 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
});
};
+export const toggleBlockedIssueWarning = ({ commit }, value) => {
+ commit(types.TOGGLE_BLOCKED_ISSUE_WARNING, value);
+ // Hides Close issue button at the top of issue page
+ const closeDropdown = document.querySelector('.js-issuable-close-dropdown');
+ if (closeDropdown) {
+ closeDropdown.classList.toggle('d-none');
+ } else {
+ const closeButton = document.querySelector(
+ '.detail-page-header-actions .btn-close.btn-grouped',
+ );
+ closeButton.classList.toggle('d-md-block');
+ }
+};
+
export const closeIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return axios.put(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
+ dispatch('toggleBlockedIssueWarning', false);
});
};
@@ -233,7 +248,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
let methodToDispatch;
- const postData = Object.assign({}, noteData);
+ const postData = { ...noteData };
if (postData.isDraft === true) {
methodToDispatch = replyId
? 'batchComments/addDraftToDiscussion'
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index eb877083bca..85997b44bcc 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -35,6 +35,8 @@ export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const getBlockedByIssues = state => state.noteableData.blocked_by_issues;
+
export const userCanReply = state => Boolean(state.noteableData.current_user.can_create_note);
export const openState = state => state.noteableData.state;
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
index d41b02b4a4b..c4895f58656 100644
--- a/app/assets/javascripts/notes/stores/index.js
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -4,4 +4,8 @@ import notesModule from './modules';
Vue.use(Vuex);
-export default () => new Vuex.Store(notesModule());
+// NOTE: Giving the option to either use a singleton or new instance of notes.
+const notesStore = () => new Vuex.Store(notesModule());
+
+export default notesStore;
+export const store = notesStore();
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 81844ad6e98..25f0f546103 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -14,6 +14,7 @@ export default () => ({
// View layer
isToggleStateButtonLoading: false,
+ isToggleBlockedIssueWarning: false,
isNotesFetched: false,
isLoading: true,
isLoadingDescriptionVersion: false,
@@ -24,6 +25,7 @@ export default () => ({
},
userData: {},
noteableData: {
+ confidential: false, // TODO: Move data like this to Issue Store, should not be apart of notes.
current_user: {},
preview_note_path: 'path/to/preview',
},
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index 5b7225bb3d2..2f7b2788d8a 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -33,6 +33,7 @@ export const SET_DISCUSSIONS_SORT = 'SET_DISCUSSIONS_SORT';
export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
+export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
// Description version
export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index dab09d1d05c..f06874991f0 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -249,6 +249,10 @@ export default {
Object.assign(state, { isToggleStateButtonLoading: value });
},
+ [types.TOGGLE_BLOCKED_ISSUE_WARNING](state, value) {
+ Object.assign(state, { isToggleBlockedIssueWarning: value });
+ },
+
[types.SET_NOTES_FETCHED_STATE](state, value) {
Object.assign(state, { isNotesFetched: value });
},
diff --git a/app/assets/javascripts/pages/admin/application_settings/general/index.js b/app/assets/javascripts/pages/admin/application_settings/general/index.js
index 5ec9688a6e4..8183e81fb02 100644
--- a/app/assets/javascripts/pages/admin/application_settings/general/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/general/index.js
@@ -1,3 +1,3 @@
-import initUserInternalRegexPlaceholder from '../../application_settings/account_and_limits';
+import initUserInternalRegexPlaceholder from '../account_and_limits';
document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder());
diff --git a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
index 78a5c4c27be..ae2209b0292 100644
--- a/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
+++ b/app/assets/javascripts/pages/admin/impersonation_tokens/index.js
@@ -1,3 +1,3 @@
-import DueDateSelectors from '~/due_date_select';
+import initExpiresAtField from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
diff --git a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
index a99fde54981..b22fbf6b833 100644
--- a/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
+++ b/app/assets/javascripts/pages/admin/projects/index/components/delete_project_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { s__, sprintf } from '~/locale';
@@ -34,7 +34,7 @@ export default {
return sprintf(
s__('AdminProjects|Delete Project %{projectName}?'),
{
- projectName: `'${esc(this.projectName)}'`,
+ projectName: `'${escape(this.projectName)}'`,
},
false,
);
@@ -46,7 +46,7 @@ export default {
and all related resources including issues, merge requests, etc.. Once you confirm and press
%{strong_start}Delete project%{strong_end}, it cannot be undone or recovered.`),
{
- projectName: `<strong>${esc(this.projectName)}</strong>`,
+ projectName: `<strong>${escape(this.projectName)}</strong>`,
strong_start: '<strong>',
strong_end: '</strong>',
},
@@ -57,7 +57,7 @@ export default {
return sprintf(
s__('AdminUsers|To confirm, type %{projectName}'),
{
- projectName: `<code>${esc(this.projectName)}</code>`,
+ projectName: `<code>${escape(this.projectName)}</code>`,
},
false,
);
diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
index 5b7c8141084..71df677c7fd 100644
--- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
+++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -56,7 +56,7 @@ export default {
return sprintf(
this.content,
{
- username: `<strong>${esc(this.username)}</strong>`,
+ username: `<strong>${escape(this.username)}</strong>`,
strong_start: '<strong>',
strong_end: '</strong>',
},
@@ -67,7 +67,7 @@ export default {
return sprintf(
s__('AdminUsers|To confirm, type %{username}'),
{
- username: `<code>${esc(this.username)}</code>`,
+ username: `<code>${escape(this.username)}</code>`,
},
false,
);
@@ -121,7 +121,7 @@ export default {
/>
</form>
</template>
- <template slot="modal-footer">
+ <template #modal-footer>
<gl-deprecated-button variant="secondary" @click="onCancel">{{
s__('Cancel')
}}</gl-deprecated-button>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
index 061044eba84..58dba41277d 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue
@@ -123,7 +123,7 @@ Once deleted, it cannot be undone or recovered.`),
kind="danger"
@submit="onSubmit"
>
- <template slot="body" slot-scope="props">
+ <template #body="props">
<p v-html="props.text"></p>
</template>
</deprecated-modal>
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index 26adf4cbbe0..e18732d0fd5 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -68,7 +68,7 @@ export default {
footer-primary-button-variant="warning"
@submit="onSubmit"
>
- <template slot="title">
+ <template #title>
{{ title }}
</template>
<div>
diff --git a/app/assets/javascripts/pages/milestones/shared/event_hub.js b/app/assets/javascripts/pages/milestones/shared/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/pages/milestones/shared/event_hub.js
+++ b/app/assets/javascripts/pages/milestones/shared/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
index 78a5c4c27be..ae2209b0292 100644
--- a/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
+++ b/app/assets/javascripts/pages/profiles/personal_access_tokens/index.js
@@ -1,3 +1,3 @@
-import DueDateSelectors from '~/due_date_select';
+import initExpiresAtField from '~/access_tokens';
-document.addEventListener('DOMContentLoaded', () => new DueDateSelectors());
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
diff --git a/app/assets/javascripts/pages/projects/alert_management/details/index.js b/app/assets/javascripts/pages/projects/alert_management/details/index.js
new file mode 100644
index 00000000000..0124795e1af
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/alert_management/details/index.js
@@ -0,0 +1,5 @@
+import AlertDetails from '~/alert_management/details';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AlertDetails('#js-alert_details');
+});
diff --git a/app/assets/javascripts/pages/projects/alert_management/index/index.js b/app/assets/javascripts/pages/projects/alert_management/index/index.js
new file mode 100644
index 00000000000..1e98bcfd2eb
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/alert_management/index/index.js
@@ -0,0 +1,5 @@
+import AlertManagementList from '~/alert_management/list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ AlertManagementList();
+});
diff --git a/app/assets/javascripts/pages/projects/blob/new/index.js b/app/assets/javascripts/pages/projects/blob/new/index.js
index 720cb249052..189053f3ed7 100644
--- a/app/assets/javascripts/pages/projects/blob/new/index.js
+++ b/app/assets/javascripts/pages/projects/blob/new/index.js
@@ -1,12 +1,3 @@
import initBlobBundle from '~/blob_edit/blob_bundle';
-import initPopover from '~/blob/suggest_gitlab_ci_yml';
-document.addEventListener('DOMContentLoaded', () => {
- initBlobBundle();
-
- const suggestEl = document.querySelector('.js-suggest-gitlab-ci-yml');
-
- if (suggestEl) {
- initPopover(suggestEl);
- }
-});
+document.addEventListener('DOMContentLoaded', initBlobBundle);
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 557aea0c5de..e5e4670a5d7 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -32,9 +32,10 @@ document.addEventListener('DOMContentLoaded', () => {
GpgBadges.fetch();
- if (gon.features?.codeNavigation) {
- const el = document.getElementById('js-code-navigation');
- const { codeNavigationPath, blobPath, definitionPathPrefix } = el.dataset;
+ const codeNavEl = document.getElementById('js-code-navigation');
+
+ if (gon.features?.codeNavigation && codeNavEl) {
+ const { codeNavigationPath, blobPath, definitionPathPrefix } = codeNavEl.dataset;
// eslint-disable-next-line promise/catch-or-return
import('~/code_navigation').then(m =>
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 0d69a689316..31ec4e29ad2 100644
--- a/app/assets/javascripts/pages/projects/environments/metrics/index.js
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -1,3 +1,3 @@
-import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle';
+import monitoringBundle from '~/monitoring/monitoring_bundle_with_alerts';
document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index bf54ca972b2..e8e0cda2139 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -7,6 +7,7 @@ import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+import initIssuablesList from '~/issuables_list';
import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
@@ -16,9 +17,11 @@ document.addEventListener('DOMContentLoaded', () => {
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
- new IssuableIndex(ISSUABLE_INDEX.ISSUE);
+ new IssuableIndex(ISSUABLE_INDEX.ISSUE);
new ShortcutsNavigation();
new UsersSelect();
+
initManualOrdering();
+ initIssuablesList();
});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 75df80a0f6c..46c9b2fe0af 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -12,6 +12,16 @@ export default function() {
initIssueableApp();
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
+
+ // .js-design-management is currently EE-only.
+ // This will be moved to CE as part of https://gitlab.com/gitlab-org/gitlab/-/issues/212566#frontend
+ // at which point this conditional can be removed.
+ if (document.querySelector('.js-design-management')) {
+ import(/* webpackChunkName: 'design_management' */ '~/design_management')
+ .then(module => module.default())
+ .catch(() => {});
+ }
+
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
index 12e16b79d37..3b26047455d 100644
--- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
+++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
@@ -49,7 +49,7 @@ export default {
const label = `<span
class="label color-label"
style="background-color: ${this.labelColor}; color: ${this.labelTextColor};"
- >${esc(this.labelTitle)}</span>`;
+ >${escape(this.labelTitle)}</span>`;
return sprintf(
s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'),
diff --git a/app/assets/javascripts/pages/projects/labels/event_hub.js b/app/assets/javascripts/pages/projects/labels/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/pages/projects/labels/event_hub.js
+++ b/app/assets/javascripts/pages/projects/labels/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index 3a0d9c17228..4efabcb7df3 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -1,5 +1,13 @@
<script>
+import { GlSprintf, GlLink } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+import { getWeekdayNames } from '~/lib/utils/datetime_utility';
+
export default {
+ components: {
+ GlSprintf,
+ GlLink,
+ },
props: {
initialCronInterval: {
type: String,
@@ -9,25 +17,51 @@ export default {
},
data() {
return {
+ isEditingCustom: false,
+ randomHour: this.generateRandomHour(),
+ randomWeekDayIndex: this.generateRandomWeekDayIndex(),
+ randomDay: this.generateRandomDay(),
inputNameAttribute: 'schedule[cron]',
cronInterval: this.initialCronInterval,
- cronIntervalPresets: {
- everyDay: '0 4 * * *',
- everyWeek: '0 4 * * 0',
- everyMonth: '0 4 1 * *',
- },
cronSyntaxUrl: 'https://en.wikipedia.org/wiki/Cron',
- customInputEnabled: false,
};
},
computed: {
+ cronIntervalPresets() {
+ return {
+ everyDay: `0 ${this.randomHour} * * *`,
+ everyWeek: `0 ${this.randomHour} * * ${this.randomWeekDayIndex}`,
+ everyMonth: `0 ${this.randomHour} ${this.randomDay} * *`,
+ };
+ },
intervalIsPreset() {
return Object.values(this.cronIntervalPresets).includes(this.cronInterval);
},
- // The text input is editable when there's a custom interval, or when it's
- // a preset interval and the user clicks the 'custom' radio button
- isEditable() {
- return Boolean(this.customInputEnabled || !this.intervalIsPreset);
+ formattedTime() {
+ if (this.randomHour > 12) {
+ return `${this.randomHour - 12}:00pm`;
+ } else if (this.randomHour === 12) {
+ return `12:00pm`;
+ }
+ return `${this.randomHour}:00am`;
+ },
+ weekday() {
+ return getWeekdayNames()[this.randomWeekDayIndex];
+ },
+ everyDayText() {
+ return sprintf(s__(`Every day (at %{time})`), { time: this.formattedTime });
+ },
+ everyWeekText() {
+ return sprintf(s__('Every week (%{weekday} at %{time})'), {
+ weekday: this.weekday,
+ time: this.formattedTime,
+ });
+ },
+ everyMonthText() {
+ return sprintf(s__('Every month (Day %{day} at %{time})'), {
+ day: this.randomDay,
+ time: this.formattedTime,
+ });
},
},
watch: {
@@ -39,14 +73,31 @@ export default {
});
},
},
- created() {
- if (this.intervalIsPreset) {
- this.enableCustomInput = false;
+ // If at the mounting stage the default is still an empty string, we
+ // know we are not editing an existing field so we update it so
+ // that the default is the first radio option
+ mounted() {
+ if (this.cronInterval === '') {
+ this.cronInterval = this.cronIntervalPresets.everyDay;
}
},
methods: {
+ setCustomInput(e) {
+ if (!this.isEditingCustom) {
+ this.isEditingCustom = true;
+ this.$refs.customInput.click();
+ // Because we need to manually trigger the click on the radio btn,
+ // it will add a space to update the v-model. If the user is typing
+ // and the space is added, it will feel very unituitive so we reset
+ // the value to the original
+ this.cronInterval = e.target.value;
+ }
+ if (this.intervalIsPreset) {
+ this.isEditingCustom = false;
+ }
+ },
toggleCustomInput(shouldEnable) {
- this.customInputEnabled = shouldEnable;
+ this.isEditingCustom = shouldEnable;
if (shouldEnable) {
// We need to change the value so other radios don't remain selected
@@ -54,6 +105,15 @@ export default {
this.cronInterval = `${this.cronInterval} `;
}
},
+ generateRandomHour() {
+ return Math.floor(Math.random() * 23);
+ },
+ generateRandomWeekDayIndex() {
+ return Math.floor(Math.random() * 6);
+ },
+ generateRandomDay() {
+ return Math.floor(Math.random() * 28);
+ },
},
};
</script>
@@ -62,24 +122,6 @@ export default {
<div class="interval-pattern-form-group">
<div class="cron-preset-radio-input">
<input
- id="custom"
- :name="inputNameAttribute"
- :value="cronInterval"
- :checked="isEditable"
- class="label-bold"
- type="radio"
- @click="toggleCustomInput(true)"
- />
-
- <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
-
- <span class="cron-syntax-link-wrap">
- (<a :href="cronSyntaxUrl" target="_blank"> {{ __('Cron syntax') }} </a>)
- </span>
- </div>
-
- <div class="cron-preset-radio-input">
- <input
id="every-day"
v-model="cronInterval"
:name="inputNameAttribute"
@@ -89,7 +131,9 @@ export default {
@click="toggleCustomInput(false)"
/>
- <label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label>
+ <label class="label-bold" for="every-day">
+ {{ everyDayText }}
+ </label>
</div>
<div class="cron-preset-radio-input">
@@ -104,7 +148,7 @@ export default {
/>
<label class="label-bold" for="every-week">
- {{ __('Every week (Sundays at 4:00am)') }}
+ {{ everyWeekText }}
</label>
</div>
@@ -120,20 +164,43 @@ export default {
/>
<label class="label-bold" for="every-month">
- {{ __('Every month (on the 1st at 4:00am)') }}
+ {{ everyMonthText }}
</label>
</div>
+ <div class="cron-preset-radio-input">
+ <input
+ id="custom"
+ ref="customInput"
+ v-model="cronInterval"
+ :name="inputNameAttribute"
+ :value="cronInterval"
+ class="label-bold"
+ type="radio"
+ @click="toggleCustomInput(true)"
+ />
+
+ <label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
+
+ <gl-sprintf :message="__('(%{linkStart}Cron syntax%{linkEnd})')">
+ <template #link="{content}">
+ <gl-link :href="cronSyntaxUrl" target="_blank" class="gl-font-sm">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </div>
+
<div class="cron-interval-input-wrapper">
<input
id="schedule_cron"
v-model="cronInterval"
:placeholder="__('Define a custom pattern with cron syntax')"
:name="inputNameAttribute"
- :disabled="!isEditable"
class="form-control inline cron-interval-input"
type="text"
required="true"
+ @input="setCustomInput"
/>
</div>
</div>
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
index 22512a6f12a..da96e6f36b4 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/pipeline_schedules_callout.vue
@@ -2,7 +2,8 @@
import Vue from 'vue';
import Cookies from 'js-cookie';
import Translate from '../../../../../vue_shared/translate';
-import illustrationSvg from '../icons/intro_illustration.svg';
+// Full path is needed for Jest to be able to correctly mock this file
+import illustrationSvg from '~/pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
import { parseBoolean } from '~/lib/utils/common_utils';
Vue.use(Translate);
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index dc6df27f1c7..497e2c9c0ae 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -11,9 +11,7 @@ Vue.use(Translate);
function initIntervalPatternInput() {
const intervalPatternMount = document.getElementById('interval-pattern-input');
- const initialCronInterval = intervalPatternMount
- ? intervalPatternMount.dataset.initialInterval
- : '';
+ const initialCronInterval = intervalPatternMount?.dataset?.initialInterval;
return new Vue({
el: intervalPatternMount,
diff --git a/app/assets/javascripts/pages/projects/pipelines/dag/index.js b/app/assets/javascripts/pages/projects/pipelines/dag/index.js
new file mode 100644
index 00000000000..d19c22ba556
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/dag/index.js
@@ -0,0 +1,2 @@
+// /dag is an alias for show
+import '../show/index';
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index 4b4a274794d..bbad3238ec4 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -50,6 +50,7 @@ document.addEventListener(
hasGitlabCi: parseBoolean(this.dataset.hasGitlabCi),
ciLintPath: this.dataset.ciLintPath,
resetCachePath: this.dataset.resetCachePath,
+ projectId: this.dataset.projectId,
},
});
},
diff --git a/app/assets/javascripts/pages/projects/settings/access_tokens/index.js b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
new file mode 100644
index 00000000000..ae2209b0292
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/access_tokens/index.js
@@ -0,0 +1,3 @@
+import initExpiresAtField from '~/access_tokens';
+
+document.addEventListener('DOMContentLoaded', initExpiresAtField);
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 6efddec1172..ab32fe18972 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,9 +1,8 @@
<script>
-import { GlSprintf, GlLink } from '@gitlab/ui';
+import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
-import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
@@ -12,6 +11,7 @@ import {
visibilityLevelDescriptions,
featureAccessLevelMembers,
featureAccessLevelEveryone,
+ featureAccessLevel,
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
@@ -24,8 +24,9 @@ export default {
projectSettingRow,
GlSprintf,
GlLink,
+ GlFormCheckbox,
},
- mixins: [settingsMixin, glFeatureFlagsMixin()],
+ mixins: [settingsMixin],
props: {
currentSettings: {
@@ -127,7 +128,7 @@ export default {
wikiAccessLevel: 20,
snippetsAccessLevel: 20,
pagesAccessLevel: 20,
- metricsAccessLevel: visibilityOptions.PRIVATE,
+ metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS,
containerRegistryEnabled: true,
lfsEnabled: true,
requestAccessEnabled: true,
@@ -174,6 +175,10 @@ export default {
return options;
},
+ metricsOptionsDropdownEnabled() {
+ return this.featureAccessLevelOptions.length < 2;
+ },
+
repositoryEnabled() {
return this.repositoryAccessLevel > 0;
},
@@ -195,10 +200,6 @@ export default {
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
);
},
-
- metricsDashboardVisibilitySwitchingAvailable() {
- return this.glFeatures.metricsDashboardVisibilitySwitchingAvailable;
- },
},
watch: {
@@ -211,6 +212,7 @@ export default {
this.buildsAccessLevel = Math.min(10, this.buildsAccessLevel);
this.wikiAccessLevel = Math.min(10, this.wikiAccessLevel);
this.snippetsAccessLevel = Math.min(10, this.snippetsAccessLevel);
+ this.metricsDashboardAccessLevel = Math.min(10, this.metricsDashboardAccessLevel);
if (this.pagesAccessLevel === 20) {
// When from Internal->Private narrow access for only members
this.pagesAccessLevel = 10;
@@ -225,6 +227,7 @@ export default {
if (this.wikiAccessLevel > 0) this.wikiAccessLevel = 20;
if (this.snippetsAccessLevel > 0) this.snippetsAccessLevel = 20;
if (this.pagesAccessLevel === 10) this.pagesAccessLevel = 20;
+ if (this.metricsDashboardAccessLevel === 10) this.metricsDashboardAccessLevel = 20;
this.highlightChanges();
}
},
@@ -473,7 +476,6 @@ export default {
/>
</project-setting-row>
<project-setting-row
- v-if="metricsDashboardVisibilitySwitchingAvailable"
ref="metrics-visibility-settings"
:label="__('Metrics Dashboard')"
:help-text="
@@ -485,17 +487,18 @@ export default {
<div class="project-feature-controls">
<div class="select-wrapper">
<select
- v-model="metricsAccessLevel"
+ v-model="metricsDashboardAccessLevel"
+ :disabled="metricsOptionsDropdownEnabled"
name="project[project_feature_attributes][metrics_dashboard_access_level]"
- class="form-control select-control"
+ class="form-control project-repo-select select-control"
>
<option
- :value="visibilityOptions.PRIVATE"
- :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ :value="featureAccessLevelMembers[0]"
+ :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
>{{ featureAccessLevelMembers[1] }}</option
>
<option
- :value="visibilityOptions.PUBLIC"
+ :value="featureAccessLevelEveryone[0]"
:disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
>{{ featureAccessLevelEveryone[1] }}</option
>
@@ -517,5 +520,23 @@ export default {
)
}}</span>
</project-setting-row>
+ <project-setting-row class="mb-3">
+ <input
+ :value="showDefaultAwardEmojis"
+ type="hidden"
+ name="project[project_setting_attributes][show_default_award_emojis]"
+ />
+ <gl-form-checkbox
+ v-model="showDefaultAwardEmojis"
+ name="project[project_setting_attributes][show_default_award_emojis]"
+ >
+ {{ s__('ProjectSettings|Show default award emojis') }}
+ <template #help>{{
+ s__(
+ 'ProjectSettings|When enabled, issues, merge requests, and snippets will always show thumbs-up and thumbs-down award emoji buttons.',
+ )
+ }}</template>
+ </gl-form-checkbox>
+ </project-setting-row>
</div>
</template>
diff --git a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
index 6af346ace67..580cca49b5e 100644
--- a/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
+++ b/app/assets/javascripts/pages/projects/wikis/components/delete_wiki_modal.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { GlModal, GlModalDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
@@ -38,7 +38,7 @@ export default {
return sprintf(
s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'),
{
- pageTitle: esc(this.pageTitle),
+ pageTitle: escape(this.pageTitle),
},
false,
);
@@ -46,6 +46,7 @@ export default {
},
methods: {
onSubmit() {
+ window.onbeforeunload = null;
this.$refs.form.submit();
},
},
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index 93afdc54ce1..ed67219383b 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -44,6 +44,19 @@ export default class Wikis {
linkExample.innerHTML = MARKDOWN_LINK_TEXT[e.target.value];
});
}
+
+ const wikiTextarea = document.querySelector('form.wiki-form #wiki_content');
+ const wikiForm = document.querySelector('form.wiki-form');
+
+ if (wikiTextarea) {
+ wikiTextarea.addEventListener('input', () => {
+ window.onbeforeunload = () => '';
+ });
+
+ wikiForm.addEventListener('submit', () => {
+ window.onbeforeunload = null;
+ });
+ }
}
handleWikiTitleChange(e) {
diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js
index 7fdf4ee0bf3..e54e32199f0 100644
--- a/app/assets/javascripts/pages/search/init_filtered_search.js
+++ b/app/assets/javascripts/pages/search/init_filtered_search.js
@@ -1,4 +1,4 @@
-import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
export default ({
page,
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 24ae900b445..e1a0e2df0e0 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -116,7 +116,9 @@ export default {
</template>
</table>
- <div slot="footer"></div>
+ <template #footer>
+ <div></div>
+ </template>
</gl-modal>
{{ title }}
<request-warning :html-id="htmlId" :warnings="warnings" />
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
index 4598626718c..b3068c46bcb 100644
--- a/app/assets/javascripts/persistent_user_callout.js
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -18,6 +18,11 @@ export default class PersistentUserCallout {
init() {
const closeButton = this.container.querySelector('.js-close');
+
+ if (!closeButton) {
+ return;
+ }
+
closeButton.addEventListener('click', event => this.dismiss(event));
if (this.deferLinks) {
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index ebd7a17040a..15c220a554d 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -69,7 +69,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ <span
+ class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom"
+ >
{{ group.name }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 7125790ac3d..74a261f35d7 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,9 @@ export default {
<template>
<span class="ci-job-name-component mw-100">
<ci-icon :status="status" />
- <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ <span
+ class="ci-status-text text-truncate mw-70p gl-pl-1-deprecated-no-really-do-not-use-me d-inline-block align-bottom"
+ >
{{ name }}
</span>
</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 3d3dabbdf22..bed0ed51d5f 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,5 +1,5 @@
<script>
-import { isEmpty, escape as esc } from 'lodash';
+import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
@@ -44,7 +44,7 @@ export default {
},
methods: {
groupId(group) {
- return `ci-badge-${esc(group.name)}`;
+ return `ci-badge-${escape(group.name)}`;
},
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index d4f23697e09..fc93635bdb5 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -9,14 +9,18 @@ import NavigationTabs from '../../vue_shared/components/navigation_tabs.vue';
import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
+import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
+import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
TablePagination,
NavigationTabs,
NavigationControls,
+ PipelinesFilteredSearch,
},
- mixins: [pipelinesMixin, CIPaginationMixin],
+ mixins: [pipelinesMixin, CIPaginationMixin, glFeatureFlagsMixin()],
props: {
store: {
type: Object,
@@ -78,6 +82,10 @@ export default {
required: false,
default: null,
},
+ projectId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -209,6 +217,9 @@ export default {
},
];
},
+ canFilterPipelines() {
+ return this.glFeatures.filterPipelinesSearch;
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
@@ -238,6 +249,30 @@ export default {
createFlash(s__('Pipelines|Something went wrong while cleaning runners cache.'));
});
},
+ resetRequestData() {
+ this.requestData = { page: this.page, scope: this.scope };
+ },
+ filterPipelines(filters) {
+ this.resetRequestData();
+
+ filters.forEach(filter => {
+ // do not add Any for username query param, so we
+ // can fetch all trigger authors
+ if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) {
+ this.requestData[filter.type] = filter.value.data;
+ }
+
+ if (!filter.type) {
+ createFlash(RAW_TEXT_WARNING, 'warning');
+ }
+ });
+
+ if (filters.length === 0) {
+ this.resetRequestData();
+ }
+
+ this.updateContent(this.requestData);
+ },
},
};
</script>
@@ -267,6 +302,13 @@ export default {
/>
</div>
+ <pipelines-filtered-search
+ v-if="canFilterPipelines"
+ :pipelines="state.pipelines"
+ :project-id="projectId"
+ @filterPipelines="filterPipelines"
+ />
+
<div class="content-list pipelines">
<gl-loading-icon
v-if="stateToRender === $options.stateMap.loading"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
new file mode 100644
index 00000000000..8f9c3eb70a2
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlFilteredSearch } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
+import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
+import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { FETCH_AUTHOR_ERROR_MESSAGE, FETCH_BRANCH_ERROR_MESSAGE } from '../constants';
+
+export default {
+ components: {
+ GlFilteredSearch,
+ },
+ props: {
+ pipelines: {
+ type: Array,
+ required: true,
+ },
+ projectId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectUsers: null,
+ projectBranches: null,
+ };
+ },
+ computed: {
+ tokens() {
+ return [
+ {
+ type: 'username',
+ icon: 'user',
+ title: s__('Pipeline|Trigger author'),
+ unique: true,
+ token: PipelineTriggerAuthorToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ triggerAuthors: this.projectUsers,
+ projectId: this.projectId,
+ },
+ {
+ type: 'ref',
+ icon: 'branch',
+ title: s__('Pipeline|Branch name'),
+ unique: true,
+ token: PipelineBranchNameToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ branches: this.projectBranches,
+ projectId: this.projectId,
+ },
+ ];
+ },
+ },
+ created() {
+ Api.projectUsers(this.projectId)
+ .then(users => {
+ this.projectUsers = users;
+ })
+ .catch(err => {
+ createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
+ throw err;
+ });
+
+ Api.branches(this.projectId)
+ .then(({ data }) => {
+ this.projectBranches = data.map(branch => branch.name);
+ })
+ .catch(err => {
+ createFlash(FETCH_BRANCH_ERROR_MESSAGE);
+ throw err;
+ });
+ },
+ methods: {
+ onSubmit(filters) {
+ this.$emit('filterPipelines', filters);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row-content-block">
+ <gl-filtered-search
+ :placeholder="__('Filter pipelines')"
+ :available-tokens="tokens"
+ @submit="onSubmit"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index e25f8ab4790..981914dd046 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -99,9 +99,10 @@ export default {
// 3. If GitLab user does not have avatar, they might have a Gravatar
} else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, {
+ commitAuthorInformation = {
+ ...this.pipeline.commit.author,
avatar_url: this.pipeline.commit.author_gravatar_url,
- });
+ };
}
// 4. If committer is not a GitLab User, they can have a Gravatar
} else {
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 7426936515a..569920a4f31 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -137,7 +137,7 @@ export default {
},
isDropdownOpen() {
- return this.$el.classList.contains('open');
+ return this.$el.classList.contains('show');
},
pipelineActionRequestComplete() {
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
index 388b300b39d..06ab45adf80 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -21,7 +21,8 @@ export default {
return this.selectedSuite.total_count > 0;
},
showTests() {
- return this.testReports.total_count > 0;
+ const { test_suites: testSuites = [] } = this.testReports;
+ return testSuites.length > 0;
},
},
methods: {
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 9739ef76867..80a1c83f171 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -29,7 +29,14 @@ export default {
successPercentage() {
// Returns a full number when the decimals equal .00.
// Otherwise returns a float to two decimal points
- return Number(((this.report.success_count / this.report.total_count) * 100 || 0).toFixed(2));
+ // Do not include skipped tests as part of the total when doing success calculations.
+
+ const totalCompletedCount = this.report.total_count - this.report.skipped_count;
+
+ if (totalCompletedCount > 0) {
+ return Number(((this.report.success_count / totalCompletedCount) * 100 || 0).toFixed(2));
+ }
+ return 0;
},
formattedDuration() {
return formatTime(secondsToMilliseconds(this.report.total_time));
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 6effd6e949d..4dfb67dd8e8 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -1,14 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
+import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import store from '~/pipelines/stores/test_reports';
import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
name: 'TestsSummaryTable',
components: {
+ GlIcon,
SmartVirtualList,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
store,
props: {
heading: {
@@ -75,7 +80,10 @@ export default {
v-for="(testSuite, index) in getTestSuites"
:key="index"
role="row"
- class="gl-responsive-table-row gl-responsive-table-row-clickable test-reports-summary-row rounded cursor-pointer js-suite-row"
+ class="gl-responsive-table-row test-reports-summary-row rounded js-suite-row"
+ :class="{
+ 'gl-responsive-table-row-clickable cursor-pointer': !testSuite.suite_error,
+ }"
@click="tableRowClick(testSuite)"
>
<div class="table-section section-25">
@@ -84,6 +92,14 @@ export default {
</div>
<div class="table-mobile-content underline cgray pl-3">
{{ testSuite.name }}
+ <gl-icon
+ v-if="testSuite.suite_error"
+ ref="suiteErrorIcon"
+ v-gl-tooltip
+ name="error"
+ :title="testSuite.suite_error"
+ class="vertical-align-middle"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
new file mode 100644
index 00000000000..a7a3f986255
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import createFlash from '~/flash';
+import { debounce } from 'lodash';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ branches: this.config.branches,
+ loading: true,
+ };
+ },
+ methods: {
+ fetchBranchBySearchTerm(searchTerm) {
+ Api.branches(this.config.projectId, searchTerm)
+ .then(res => {
+ this.branches = res.data.map(branch => branch.name);
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_BRANCH_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchBranches: debounce(function debounceSearch({ data }) {
+ this.fetchBranchBySearchTerm(data);
+ }, FILTER_PIPELINES_SEARCH_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchBranches"
+ >
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="(branch, index) in branches"
+ :key="index"
+ :value="branch"
+ >
+ {{ branch }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
new file mode 100644
index 00000000000..83e3558e1a1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
@@ -0,0 +1,114 @@
+<script>
+import {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+} from '@gitlab/ui';
+import Api from '~/api';
+import createFlash from '~/flash';
+import { debounce } from 'lodash';
+import {
+ ANY_TRIGGER_AUTHOR,
+ FETCH_AUTHOR_ERROR_MESSAGE,
+ FILTER_PIPELINES_SEARCH_DELAY,
+} from '../../constants';
+
+export default {
+ anyTriggerAuthor: ANY_TRIGGER_AUTHOR,
+ components: {
+ GlFilteredSearchToken,
+ GlAvatar,
+ GlFilteredSearchSuggestion,
+ GlDropdownDivider,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ users: this.config.triggerAuthors,
+ loading: true,
+ };
+ },
+ computed: {
+ currentValue() {
+ return this.value.data.toLowerCase();
+ },
+ activeUser() {
+ return this.users.find(user => {
+ return user.username.toLowerCase() === this.currentValue;
+ });
+ },
+ },
+ methods: {
+ fetchAuthorBySearchTerm(searchTerm) {
+ Api.projectUsers(this.config.projectId, searchTerm)
+ .then(res => {
+ this.users = res;
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchAuthors: debounce(function debounceSearch({ data }) {
+ this.fetchAuthorBySearchTerm(data);
+ }, FILTER_PIPELINES_SEARCH_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token
+ :config="config"
+ v-bind="{ ...$props, ...$attrs }"
+ v-on="$listeners"
+ @input="searchAuthors"
+ >
+ <template #view="{inputValue}">
+ <gl-avatar
+ v-if="activeUser"
+ :size="16"
+ :src="activeUser.avatar_url"
+ shape="circle"
+ class="gl-mr-2"
+ />
+ <span>{{ activeUser ? activeUser.name : inputValue }}</span>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{
+ $options.anyTriggerAuthor
+ }}</gl-filtered-search-suggestion>
+ <gl-dropdown-divider />
+
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion
+ v-for="user in users"
+ :key="user.username"
+ :value="user.username"
+ >
+ <div class="d-flex">
+ <gl-avatar :size="32" :src="user.avatar_url" />
+ <div>
+ <div>{{ user.name }}</div>
+ <div>@{{ user.username }}</div>
+ </div>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index c9655d18a04..d694430830b 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,9 +1,19 @@
+import { s__, __ } from '~/locale';
+
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
+export const FILTER_PIPELINES_SEARCH_DELAY = 200;
+export const ANY_TRIGGER_AUTHOR = 'Any';
export const TestStatus = {
FAILED: 'failed',
SKIPPED: 'skipped',
SUCCESS: 'success',
};
+
+export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
+export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
+export const RAW_TEXT_WARNING = s__(
+ 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
+);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index d76425c96b7..01295874e56 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -14,13 +14,7 @@ import axios from '~/lib/utils/axios_utils';
Vue.use(Translate);
-export default () => {
- const { dataset } = document.querySelector('.js-pipeline-details-vue');
-
- const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
-
- mediator.fetchPipeline();
-
+const createPipelinesDetailApp = mediator => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-graph-vue',
@@ -50,7 +44,9 @@ export default () => {
});
},
});
+};
+const createPipelineHeaderApp = mediator => {
// eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-header-vue',
@@ -94,7 +90,9 @@ export default () => {
});
},
});
+};
+const createPipelinesTabs = dataset => {
const tabsElement = document.querySelector('.pipelines-tabs');
const testReportsEnabled =
window.gon && window.gon.features && window.gon.features.junitPipelineView;
@@ -119,27 +117,40 @@ export default () => {
tabsElement.addEventListener('click', tabClickHandler);
}
+ }
+};
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-pipeline-tests-detail',
- components: {
- TestReports,
- },
- render(createElement) {
- return createElement('test-reports');
- },
- });
+const createTestDetails = detailsEndpoint => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-pipeline-tests-detail',
+ components: {
+ TestReports,
+ },
+ render(createElement) {
+ return createElement('test-reports');
+ },
+ });
- axios
- .get(dataset.testReportsCountEndpoint)
- .then(({ data }) => {
- if (!data.total_count) {
- return;
- }
+ axios
+ .get(detailsEndpoint)
+ .then(({ data }) => {
+ if (!data.total_count) {
+ return;
+ }
- document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
- })
- .catch(() => {});
- }
+ document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count;
+ })
+ .catch(() => {});
+};
+
+export default () => {
+ const { dataset } = document.querySelector('.js-pipeline-details-vue');
+ const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
+ mediator.fetchPipeline();
+
+ createPipelinesDetailApp(mediator);
+ createPipelineHeaderApp(mediator);
+ createPipelinesTabs(dataset);
+ createTestDetails(dataset.testReportsCountEndpoint);
};
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index 3c755db23dc..ae94d7a7ca0 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -19,13 +19,23 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
- const { scope, page } = data;
+ const { scope, page, username, ref } = data;
const { CancelToken } = axios;
+ const queryParams = { scope, page };
+
+ if (username) {
+ queryParams.username = username;
+ }
+
+ if (ref) {
+ queryParams.ref = ref;
+ }
+
this.cancelationSource = CancelToken.source();
return axios.get(this.endpoint, {
- params: { scope, page },
+ params: queryParams,
cancelToken: this.cancelationSource.token,
});
}
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 1ef73760e02..c6f65277c8d 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -15,7 +15,7 @@ export default class PipelineStore {
* @param {Object} pipeline
*/
storePipeline(pipeline = {}) {
- const pipelineCopy = Object.assign({}, pipeline);
+ const pipelineCopy = { ...pipeline };
if (pipelineCopy.triggered_by) {
pipelineCopy.triggered_by = [pipelineCopy.triggered_by];
diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
index 85c5c073a74..aeb69fb1c05 100644
--- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue
+++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue
@@ -85,7 +85,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`),
kind="danger"
@submit="onSubmit"
>
- <template slot="body" slot-scope="props">
+ <template #body="props">
<p v-html="props.text"></p>
<form ref="form" :action="actionUrl" method="post">
diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue
index fa09e063552..feb83e07607 100644
--- a/app/assets/javascripts/profile/account/components/update_username.vue
+++ b/app/assets/javascripts/profile/account/components/update_username.vue
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
@@ -43,10 +43,10 @@ You are going to change the username %{currentUsernameBold} to %{newUsernameBold
Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group.
Please update your Git repository remotes as soon as possible.`),
{
- currentUsernameBold: `<strong>${esc(this.username)}</strong>`,
- newUsernameBold: `<strong>${esc(this.newUsername)}</strong>`,
- currentUsername: esc(this.username),
- newUsername: esc(this.newUsername),
+ currentUsernameBold: `<strong>${escape(this.username)}</strong>`,
+ newUsernameBold: `<strong>${escape(this.newUsername)}</strong>`,
+ currentUsername: escape(this.username),
+ newUsername: escape(this.newUsername),
},
false,
);
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 15c7c09366c..2b2c365dd54 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-else-return */
+/* eslint-disable func-names */
import $ from 'jquery';
import Api from './api';
@@ -74,18 +74,17 @@ const projectSelect = () => {
},
projectsCallback,
);
- } else {
- return Api.projects(
- query.term,
- {
- order_by: this.orderBy,
- with_issues_enabled: this.withIssuesEnabled,
- with_merge_requests_enabled: this.withMergeRequestsEnabled,
- membership: !this.allProjects,
- },
- projectsCallback,
- );
}
+ return Api.projects(
+ query.term,
+ {
+ order_by: this.orderBy,
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ membership: !this.allProjects,
+ },
+ projectsCallback,
+ );
},
id(project) {
if (simpleFilter) return project.id;
diff --git a/app/assets/javascripts/projects/commits/components/author_select.vue b/app/assets/javascripts/projects/commits/components/author_select.vue
index 78f9389b80c..eb514b5c070 100644
--- a/app/assets/javascripts/projects/commits/components/author_select.vue
+++ b/app/assets/javascripts/projects/commits/components/author_select.vue
@@ -110,6 +110,7 @@ export default {
<gl-new-dropdown
:text="dropdownText"
:disabled="hasSearchParam"
+ toggle-class="gl-py-3"
class="gl-dropdown w-100 mt-2 mt-sm-0"
>
<gl-new-dropdown-header>
diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js
index daeae071d6a..a3a53c2f975 100644
--- a/app/assets/javascripts/projects/commits/store/actions.js
+++ b/app/assets/javascripts/projects/commits/store/actions.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
@@ -26,6 +27,9 @@ export default {
},
})
.then(({ data }) => dispatch('receiveAuthorsSuccess', data))
- .catch(() => dispatch('receiveAuthorsError'));
+ .catch(error => {
+ Sentry.captureException(error);
+ dispatch('receiveAuthorsError');
+ });
},
};
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 4dc1c512689..cdf03a5013f 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -113,6 +113,9 @@ export default {
</script>
<template>
<div>
+ <div class="mb-3">
+ <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ </div>
<h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
<div class="row">
<div class="col-md-6">
diff --git a/app/assets/javascripts/registry/explorer/components/image_list.vue b/app/assets/javascripts/registry/explorer/components/image_list.vue
new file mode 100644
index 00000000000..bc209b12738
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/components/image_list.vue
@@ -0,0 +1,124 @@
+<script>
+import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
+import {
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+} from '../constants';
+
+export default {
+ name: 'ImageList',
+ components: {
+ GlPagination,
+ ClipboardButton,
+ GlDeprecatedButton,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ images: {
+ type: Array,
+ required: true,
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ },
+ computed: {
+ currentPage: {
+ get() {
+ return this.pagination.page;
+ },
+ set(page) {
+ this.$emit('pageChange', page);
+ },
+ },
+ },
+ methods: {
+ encodeListItem(item) {
+ const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
+ return window.btoa(params);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <div
+ v-for="(listItem, index) in images"
+ :key="index"
+ v-gl-tooltip="{
+ placement: 'left',
+ disabled: !listItem.deleting,
+ title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
+ }"
+ data-testid="rowItem"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom"
+ :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
+ >
+ <div class="gl-display-flex gl-align-items-center">
+ <router-link
+ data-testid="detailsLink"
+ :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
+ >
+ {{ listItem.path }}
+ </router-link>
+ <clipboard-button
+ v-if="listItem.location"
+ :disabled="listItem.deleting"
+ :text="listItem.location"
+ :title="listItem.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ <gl-icon
+ v-if="listItem.failedDelete"
+ v-gl-tooltip
+ :title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
+ name="warning"
+ class="text-warning align-middle"
+ />
+ </div>
+ <div
+ v-gl-tooltip="{ disabled: listItem.destroy_path }"
+ class="d-none d-sm-block"
+ :title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
+ >
+ <gl-deprecated-button
+ v-gl-tooltip
+ data-testid="deleteImageButton"
+ :disabled="!listItem.destroy_path || listItem.deleting"
+ :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ :aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ class="btn-inverted"
+ variant="danger"
+ @click="$emit('delete', listItem)"
+ >
+ <gl-icon name="remove" />
+ </gl-deprecated-button>
+ </div>
+ </div>
+ </div>
+ <gl-pagination
+ v-model="currentPage"
+ :per-page="pagination.perPage"
+ :total-items="pagination.total"
+ align="center"
+ class="w-100 gl-mt-2"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/explorer/constants.js b/app/assets/javascripts/registry/explorer/constants.js
index d4b9d25b212..7cbe657bfc0 100644
--- a/app/assets/javascripts/registry/explorer/constants.js
+++ b/app/assets/javascripts/registry/explorer/constants.js
@@ -37,16 +37,31 @@ export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
'ContainerRegistry|%{title} was successfully scheduled for deletion',
);
+export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories');
+
+export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name');
+
+export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
+export const EMPTY_RESULT_MESSAGE = s__(
+ 'ContainerRegistry|To widen your search, change or remove the filters above.',
+);
+
// Image details page
+export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
+
export const DELETE_TAG_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while deleting the tag.',
+ 'ContainerRegistry|Something went wrong while marking the tag for deletion.',
+);
+export const DELETE_TAG_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tag successfully marked for deletion.',
);
-export const DELETE_TAG_SUCCESS_MESSAGE = s__('ContainerRegistry|Tag deleted successfully');
export const DELETE_TAGS_ERROR_MESSAGE = s__(
- 'ContainerRegistry|Something went wrong while deleting the tags.',
+ 'ContainerRegistry|Something went wrong while marking the tags for deletion.',
+);
+export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tags successfully marked for deletion.',
);
-export const DELETE_TAGS_SUCCESS_MESSAGE = s__('ContainerRegistry|Tags deleted successfully');
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 10;
@@ -65,6 +80,27 @@ export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
+export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
+export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags');
+
+export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item}. Are you sure?`,
+);
+export const REMOVE_TAGS_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`,
+);
+
+export const EMPTY_IMAGE_REPOSITORY_TITLE = s__('ContainerRegistry|This image has no active tags');
+export const EMPTY_IMAGE_REPOSITORY_MESSAGE = s__(
+ `ContainerRegistry|The last tag related to this image was recently removed.
+This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+If you have any questions, contact your administrator.`,
+);
+
+export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
+ 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
+);
+
// Expiration policies
export const EXPIRATION_POLICY_ALERT_TITLE = s__(
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 9269aa074f8..2bba3ee4ff9 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -19,7 +19,7 @@ export default () => {
const { endpoint } = el.dataset;
const store = createStore();
- const router = createRouter(endpoint, store);
+ const router = createRouter(endpoint);
store.dispatch('setInitialState', el.dataset);
const attachMainComponent = () =>
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue
index 6afd4d1107a..cc2dc531dc8 100644
--- a/app/assets/javascripts/registry/explorer/pages/details.vue
+++ b/app/assets/javascripts/registry/explorer/pages/details.vue
@@ -9,12 +9,14 @@ import {
GlPagination,
GlModal,
GlSprintf,
+ GlAlert,
+ GlLink,
GlEmptyState,
GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
-import { n__, s__ } from '~/locale';
+import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
@@ -35,6 +37,14 @@ import {
DELETE_TAG_ERROR_MESSAGE,
DELETE_TAGS_SUCCESS_MESSAGE,
DELETE_TAGS_ERROR_MESSAGE,
+ REMOVE_TAG_CONFIRMATION_TEXT,
+ REMOVE_TAGS_CONFIRMATION_TEXT,
+ DETAILS_PAGE_TITLE,
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+ EMPTY_IMAGE_REPOSITORY_TITLE,
+ EMPTY_IMAGE_REPOSITORY_MESSAGE,
+ ADMIN_GARBAGE_COLLECTION_TIP,
} from '../constants';
export default {
@@ -49,6 +59,8 @@ export default {
GlSkeletonLoader,
GlSprintf,
GlEmptyState,
+ GlAlert,
+ GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,6 +72,19 @@ export default {
width: 1000,
height: 40,
},
+ i18n: {
+ DETAILS_PAGE_TITLE,
+ REMOVE_TAGS_BUTTON_TITLE,
+ REMOVE_TAG_BUTTON_TITLE,
+ EMPTY_IMAGE_REPOSITORY_TITLE,
+ EMPTY_IMAGE_REPOSITORY_MESSAGE,
+ },
+ alertMessages: {
+ success_tag: DELETE_TAG_SUCCESS_MESSAGE,
+ danger_tag: DELETE_TAG_ERROR_MESSAGE,
+ success_tags: DELETE_TAGS_SUCCESS_MESSAGE,
+ danger_tags: DELETE_TAGS_ERROR_MESSAGE,
+ },
data() {
return {
selectedItems: [],
@@ -67,6 +92,7 @@ export default {
selectAllChecked: false,
modalDescription: null,
isDesktop: true,
+ deleteAlertType: false,
};
},
computed: {
@@ -78,9 +104,15 @@ export default {
},
fields() {
const tagClass = this.isDesktop ? 'w-25' : '';
+ const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
return [
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
- { key: LIST_KEY_TAG, label: LIST_LABEL_TAG, class: `${tagClass} js-tag-column` },
+ {
+ key: LIST_KEY_TAG,
+ label: LIST_LABEL_TAG,
+ class: `${tagClass} js-tag-column`,
+ innerClass: tagInnerClass,
+ },
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
@@ -110,20 +142,43 @@ export default {
this.requestTagsList({ pagination: { page }, params: this.$route.params.id });
},
},
+ deleteAlertConfig() {
+ const config = {
+ title: '',
+ message: '',
+ type: 'success',
+ };
+ if (this.deleteAlertType) {
+ [config.type] = this.deleteAlertType.split('_');
+
+ const defaultMessage = this.$options.alertMessages[this.deleteAlertType];
+
+ if (this.config.isAdmin && config.type === 'success') {
+ config.title = defaultMessage;
+ config.message = ADMIN_GARBAGE_COLLECTION_TIP;
+ } else {
+ config.message = defaultMessage;
+ }
+ }
+ return config;
+ },
+ },
+ mounted() {
+ this.requestTagsList({ params: this.$route.params.id });
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = {
- message: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`),
+ message: REMOVE_TAGS_CONFIRMATION_TEXT,
item: this.itemsToBeDeleted.length,
};
} else {
const { path } = this.tags[itemIndex];
this.modalDescription = {
- message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`),
+ message: REMOVE_TAG_CONFIRMATION_TEXT,
item: path,
};
}
@@ -179,19 +234,17 @@ export default {
this.track('click_button');
this.$refs.deleteModal.show();
},
- handleSingleDelete(itemToDelete) {
+ handleSingleDelete(index) {
+ const itemToDelete = this.tags[index];
this.itemsToBeDeleted = [];
+ this.selectedItems = this.selectedItems.filter(i => i !== index);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
- .then(() =>
- this.$toast.show(DELETE_TAG_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_TAG_ERROR_MESSAGE, {
- type: 'error',
- }),
- );
+ .then(() => {
+ this.deleteAlertType = 'success_tag';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger_tag';
+ });
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
@@ -202,24 +255,19 @@ export default {
ids: itemsToBeDeleted.map(x => this.tags[x].name),
params: this.$route.params.id,
})
- .then(() =>
- this.$toast.show(DELETE_TAGS_SUCCESS_MESSAGE, {
- type: 'success',
- }),
- )
- .catch(() =>
- this.$toast.show(DELETE_TAGS_ERROR_MESSAGE, {
- type: 'error',
- }),
- );
+ .then(() => {
+ this.deleteAlertType = 'success_tags';
+ })
+ .catch(() => {
+ this.deleteAlertType = 'danger_tags';
+ });
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.isMultiDelete) {
this.handleMultipleDelete();
} else {
- const index = this.itemsToBeDeleted[0];
- this.handleSingleDelete(this.tags[index]);
+ this.handleSingleDelete(this.itemsToBeDeleted[0]);
}
},
handleResize() {
@@ -231,9 +279,24 @@ export default {
<template>
<div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element">
+ <gl-alert
+ v-if="deleteAlertType"
+ :variant="deleteAlertConfig.type"
+ :title="deleteAlertConfig.title"
+ class="my-2"
+ @dismiss="deleteAlertType = null"
+ >
+ <gl-sprintf :message="deleteAlertConfig.message">
+ <template #docLink="{content}">
+ <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
<div class="d-flex my-3 align-items-center">
<h4>
- <gl-sprintf :message="s__('ContainerRegistry|%{imageName} tags')">
+ <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE">
<template #imageName>
{{ imageName }}
</template>
@@ -256,8 +319,8 @@ export default {
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
- :title="s__('ContainerRegistry|Remove selected tags')"
- :aria-label="s__('ContainerRegistry|Remove selected tags')"
+ :title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
@@ -272,17 +335,24 @@ export default {
@change="updateSelectedItems(index)"
/>
</template>
- <template #cell(name)="{item}">
- <span ref="rowName">
- {{ item.name }}
- </span>
- <clipboard-button
- v-if="item.location"
- ref="rowClipboardButton"
- :title="item.location"
- :text="item.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
+ <template #cell(name)="{item, field}">
+ <div ref="rowName" :class="[field.innerClass, 'gl-display-flex']">
+ <span
+ v-gl-tooltip
+ data-testid="rowNameText"
+ :title="item.name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ >
+ {{ item.name }}
+ </span>
+ <clipboard-button
+ v-if="item.location"
+ ref="rowClipboardButton"
+ :title="item.location"
+ :text="item.location"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">
@@ -299,15 +369,15 @@ export default {
</span>
</template>
<template #cell(created_at)="{value}">
- <span ref="rowTime">
+ <span ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{index, item}">
<gl-deprecated-button
ref="singleDeleteButton"
- :title="s__('ContainerRegistry|Remove tag')"
- :aria-label="s__('ContainerRegistry|Remove tag')"
+ :title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
+ :aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:disabled="!item.destroy_path"
variant="danger"
class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
@@ -337,15 +407,9 @@ export default {
</template>
<gl-empty-state
v-else
- :title="s__('ContainerRegistry|This image has no active tags')"
+ :title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="config.noContainersImage"
- :description="
- s__(
- `ContainerRegistry|The last tag related to this image was recently removed.
- This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
- If you have any questions, contact your administrator.`,
- )
- "
+ :description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="mx-auto my-0"
/>
</template>
diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue
index 95d83c82987..709a163d56d 100644
--- a/app/assets/javascripts/registry/explorer/pages/index.vue
+++ b/app/assets/javascripts/registry/explorer/pages/index.vue
@@ -1,46 +1,9 @@
<script>
-import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
-import { mapState, mapActions, mapGetters } from 'vuex';
-import { s__ } from '~/locale';
-
-export default {
- components: {
- GlAlert,
- GlSprintf,
- GlLink,
- },
- i18n: {
- garbageCollectionTipText: s__(
- 'ContainerRegistry|This Registry contains deleted image tag data. Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
- ),
- },
- computed: {
- ...mapState(['config']),
- ...mapGetters(['showGarbageCollection']),
- },
- methods: {
- ...mapActions(['setShowGarbageCollectionTip']),
- },
-};
+export default {};
</script>
<template>
<div>
- <gl-alert
- v-if="showGarbageCollection"
- variant="tip"
- class="my-2"
- @dismiss="setShowGarbageCollectionTip(false)"
- >
- <gl-sprintf :message="$options.i18n.garbageCollectionTipText">
- <template #docLink="{content}">
- <gl-link :href="config.garbageCollectionHelpPagePath" target="_blank">
- {{ content }}
- </gl-link>
- </template>
- </gl-sprintf>
- </gl-alert>
-
<transition name="slide">
<router-view ref="router-view" />
</transition>
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 8923c305b2d..4efa6f08d84 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -2,53 +2,52 @@
import { mapState, mapActions } from 'vuex';
import {
GlEmptyState,
- GlPagination,
GlTooltipDirective,
- GlDeprecatedButton,
- GlIcon,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
+ GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue';
+import ImageList from '../components/image_list.vue';
+
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
- ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
- LIST_DELETE_BUTTON_DISABLED,
- REMOVE_REPOSITORY_LABEL,
REMOVE_REPOSITORY_MODAL_TEXT,
- ROW_SCHEDULED_FOR_DELETION,
+ REMOVE_REPOSITORY_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
} from '../constants';
export default {
name: 'RegistryListApp',
components: {
GlEmptyState,
- GlPagination,
ProjectEmptyState,
GroupEmptyState,
ProjectPolicyAlert,
- ClipboardButton,
QuickstartDropdown,
- GlDeprecatedButton,
- GlIcon,
+ ImageList,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
+ GlSearchBoxByClick,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,20 +59,23 @@ export default {
height: 40,
},
i18n: {
- containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
- connectionErrorTitle: CONNECTION_ERROR_TITLE,
- connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
- introText: LIST_INTRO_TEXT,
- deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
- removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
- removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
- rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
- asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ CONTAINER_REGISTRY_TITLE,
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ LIST_INTRO_TEXT,
+ REMOVE_REPOSITORY_MODAL_TEXT,
+ REMOVE_REPOSITORY_LABEL,
+ SEARCH_PLACEHOLDER_TEXT,
+ IMAGE_REPOSITORY_LIST_LABEL,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
},
data() {
return {
itemToDelete: {},
deleteAlertType: null,
+ search: null,
+ isEmpty: false,
};
},
computed: {
@@ -83,14 +85,6 @@ export default {
label: 'registry_repository_delete',
};
},
- currentPage: {
- get() {
- return this.pagination.page;
- },
- set(page) {
- this.requestImagesList({ page });
- },
- },
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
@@ -103,8 +97,19 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
+ mounted() {
+ this.loadImageList(this.$route.name);
+ },
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
+ loadImageList(fromName) {
+ if (!fromName || !this.images?.length) {
+ return this.requestImagesList().then(() => {
+ this.isEmpty = this.images.length === 0;
+ });
+ }
+ return Promise.resolve();
+ },
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
@@ -120,10 +125,6 @@ export default {
this.deleteAlertType = 'danger';
});
},
- encodeListItem(item) {
- const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
- return window.btoa(params);
- },
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
@@ -152,12 +153,12 @@ export default {
<gl-empty-state
v-if="config.characterError"
- :title="$options.i18n.connectionErrorTitle"
+ :title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage"
>
<template #description>
<p>
- <gl-sprintf :message="$options.i18n.connectionErrorMessage">
+ <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
<template #docLink="{content}">
<gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
{{ content }}
@@ -171,11 +172,11 @@ export default {
<template v-else>
<div>
<div class="d-flex justify-content-between align-items-center">
- <h4>{{ $options.i18n.containerRegistryTitle }}</h4>
+ <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4>
<quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" />
</div>
<p>
- <gl-sprintf :message="$options.i18n.introText">
+ <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
@@ -199,73 +200,40 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <div v-if="images.length" ref="imagesList" class="d-flex flex-column">
- <div
- v-for="(listItem, index) in images"
- :key="index"
- ref="rowItem"
- v-gl-tooltip="{
- placement: 'left',
- disabled: !listItem.deleting,
- title: $options.i18n.rowScheduledForDeletion,
- }"
- >
- <div
- class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
- :class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
- >
- <div class="d-felx align-items-center">
- <router-link
- ref="detailsLink"
- :to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
- >
- {{ listItem.path }}
- </router-link>
- <clipboard-button
- v-if="listItem.location"
- ref="clipboardButton"
- :disabled="listItem.deleting"
- :text="listItem.location"
- :title="listItem.location"
- css-class="btn-default btn-transparent btn-clipboard"
- />
- <gl-icon
- v-if="listItem.failedDelete"
- v-gl-tooltip
- :title="$options.i18n.asyncDeleteErrorMessage"
- name="warning"
- class="text-warning align-middle"
- />
- </div>
- <div
- v-gl-tooltip="{ disabled: listItem.destroy_path }"
- class="d-none d-sm-block"
- :title="$options.i18n.deleteButtonDisabled"
- >
- <gl-deprecated-button
- ref="deleteImageButton"
- v-gl-tooltip
- :disabled="!listItem.destroy_path || listItem.deleting"
- :title="$options.i18n.removeRepositoryLabel"
- :aria-label="$options.i18n.removeRepositoryLabel"
- class="btn-inverted"
- variant="danger"
- @click="deleteImage(listItem)"
- >
- <gl-icon name="remove" />
- </gl-deprecated-button>
- </div>
+ <template v-if="!isEmpty">
+ <div class="gl-display-flex gl-p-1" data-testid="listHeader">
+ <div class="gl-flex-fill-1">
+ <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
+ </div>
+ <div>
+ <gl-search-box-by-click
+ v-model="search"
+ :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
+ @submit="requestImagesList({ name: $event })"
+ />
</div>
</div>
- <gl-pagination
- v-model="currentPage"
- :per-page="pagination.perPage"
- :total-items="pagination.total"
- align="center"
- class="w-100 mt-2"
+
+ <image-list
+ v-if="images.length"
+ :images="images"
+ :pagination="pagination"
+ @pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
+ @delete="deleteImage"
/>
- </div>
+ <gl-empty-state
+ v-else
+ :svg-path="config.noContainersImage"
+ data-testid="emptySearch"
+ :title="$options.i18n.EMPTY_RESULT_TITLE"
+ class="container-message"
+ >
+ <template #description>
+ {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
+ </template>
+ </gl-empty-state>
+ </template>
<template v-else>
<project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else />
@@ -279,9 +247,9 @@ export default {
@ok="handleDeleteImage"
@cancel="track('cancel_delete')"
>
- <template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template>
+ <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p>
- <gl-sprintf :message="$options.i18n.removeRepositoryModalText">
+ <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
diff --git a/app/assets/javascripts/registry/explorer/router.js b/app/assets/javascripts/registry/explorer/router.js
index 28df3177df4..478eaca1a68 100644
--- a/app/assets/javascripts/registry/explorer/router.js
+++ b/app/assets/javascripts/registry/explorer/router.js
@@ -7,7 +7,7 @@ import { decodeAndParse } from './utils';
Vue.use(VueRouter);
-export default function createRouter(base, store) {
+export default function createRouter(base) {
const router = new VueRouter({
base,
mode: 'history',
@@ -20,12 +20,6 @@ export default function createRouter(base, store) {
nameGenerator: () => s__('ContainerRegistry|Container Registry'),
root: true,
},
- beforeEnter: (to, from, next) => {
- if (!from.name || !store.state.images?.length) {
- store.dispatch('requestImagesList');
- }
- next();
- },
},
{
name: 'details',
@@ -34,10 +28,6 @@ export default function createRouter(base, store) {
meta: {
nameGenerator: route => decodeAndParse(route.params.id).name,
},
- beforeEnter: (to, from, next) => {
- store.dispatch('requestTagsList', { params: to.params.id });
- next();
- },
},
],
});
diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js
index b4f66dbbcd6..7f80bc21d6e 100644
--- a/app/assets/javascripts/registry/explorer/stores/actions.js
+++ b/app/assets/javascripts/registry/explorer/stores/actions.js
@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_TAGS_PAGINATION, headers);
};
-export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => {
+export const requestImagesList = (
+ { commit, dispatch, state },
+ { pagination = {}, name = null } = {},
+) => {
commit(types.SET_MAIN_LOADING, true);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
- .get(state.config.endpoint, { params: { page, per_page: perPage } })
+ .get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ data, headers }) => {
dispatch('receiveImagesListSuccess', { data, headers });
})
@@ -66,7 +69,7 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) =
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
- .catch(() => {
+ .finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
@@ -83,7 +86,7 @@ export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params })
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
- .catch(() => {
+ .finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
diff --git a/app/assets/javascripts/registry/explorer/stores/index.js b/app/assets/javascripts/registry/explorer/stores/index.js
index b3ff2e6e002..153032e37d3 100644
--- a/app/assets/javascripts/registry/explorer/stores/index.js
+++ b/app/assets/javascripts/registry/explorer/stores/index.js
@@ -15,4 +15,5 @@ export const createStore = () =>
mutations,
});
+// Deprecated and to be removed
export default createStore();
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index 6ae1dbb72c4..a318aa2a694 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,7 +1,7 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
-import store from './store/';
+import store from './store';
import RegistrySettingsApp from './components/registry_settings_app.vue';
Vue.use(GlToast);
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
index ef4b4f0ba02..ac1a931d8e0 100644
--- a/app/assets/javascripts/registry/settings/store/getters.js
+++ b/app/assets/javascripts/registry/settings/store/getters.js
@@ -16,6 +16,7 @@ export const getSettings = (state, getters) => ({
older_than: getters.getOlderThan,
keep_n: getters.getKeepN,
name_regex: state.settings.name_regex,
+ name_regex_keep: state.settings.name_regex_keep,
});
export const getIsEdited = state => !isEqual(state.original, state.settings);
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
index bb7071b020b..3ba13419b98 100644
--- a/app/assets/javascripts/registry/settings/store/mutations.js
+++ b/app/assets/javascripts/registry/settings/store/mutations.js
@@ -21,7 +21,7 @@ export default {
state.original = Object.freeze(settings);
},
[types.RESET_SETTINGS](state) {
- state.settings = Object.assign({}, state.original);
+ state.settings = { ...state.original };
},
[types.TOGGLE_LOADING](state) {
state.isLoading = !state.isLoading;
diff --git a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
index 3e212f09e35..04a547db07e 100644
--- a/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
+++ b/app/assets/javascripts/registry/shared/components/expiration_policy_fields.vue
@@ -1,8 +1,23 @@
<script>
import { uniqueId } from 'lodash';
import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlSprintf } from '@gitlab/ui';
-import { s__, __ } from '~/locale';
-import { NAME_REGEX_LENGTH } from '../constants';
+import {
+ NAME_REGEX_LENGTH,
+ ENABLED_TEXT,
+ DISABLED_TEXT,
+ TEXT_AREA_INVALID_FEEDBACK,
+ EXPIRATION_INTERVAL_LABEL,
+ EXPIRATION_SCHEDULE_LABEL,
+ KEEP_N_LABEL,
+ NAME_REGEX_LABEL,
+ NAME_REGEX_PLACEHOLDER,
+ NAME_REGEX_DESCRIPTION,
+ NAME_REGEX_KEEP_LABEL,
+ NAME_REGEX_KEEP_PLACEHOLDER,
+ NAME_REGEX_KEEP_DESCRIPTION,
+ ENABLE_TOGGLE_LABEL,
+ ENABLE_TOGGLE_DESCRIPTION,
+} from '../constants';
import { mapComputedToEvent } from '../utils';
export default {
@@ -40,42 +55,73 @@ export default {
default: 'right',
},
},
- nameRegexPlaceholder: '.*',
+ i18n: {
+ textAreaInvalidFeedback: TEXT_AREA_INVALID_FEEDBACK,
+ enableToggleLabel: ENABLE_TOGGLE_LABEL,
+ enableToggleDescription: ENABLE_TOGGLE_DESCRIPTION,
+ },
selectList: [
{
name: 'expiration-policy-interval',
- label: s__('ContainerRegistry|Expiration interval:'),
+ label: EXPIRATION_INTERVAL_LABEL,
model: 'older_than',
optionKey: 'olderThan',
},
{
name: 'expiration-policy-schedule',
- label: s__('ContainerRegistry|Expiration schedule:'),
+ label: EXPIRATION_SCHEDULE_LABEL,
model: 'cadence',
optionKey: 'cadence',
},
{
name: 'expiration-policy-latest',
- label: s__('ContainerRegistry|Number of tags to retain:'),
+ label: KEEP_N_LABEL,
model: 'keep_n',
optionKey: 'keepN',
},
],
+ textAreaList: [
+ {
+ name: 'expiration-policy-name-matching',
+ label: NAME_REGEX_LABEL,
+ model: 'name_regex',
+ placeholder: NAME_REGEX_PLACEHOLDER,
+ stateVariable: 'nameRegexState',
+ description: NAME_REGEX_DESCRIPTION,
+ },
+ {
+ name: 'expiration-policy-keep-name',
+ label: NAME_REGEX_KEEP_LABEL,
+ model: 'name_regex_keep',
+ placeholder: NAME_REGEX_KEEP_PLACEHOLDER,
+ stateVariable: 'nameKeepRegexState',
+ description: NAME_REGEX_KEEP_DESCRIPTION,
+ },
+ ],
data() {
return {
uniqueId: uniqueId(),
};
},
computed: {
- ...mapComputedToEvent(['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex'], 'value'),
+ ...mapComputedToEvent(
+ ['enabled', 'cadence', 'older_than', 'keep_n', 'name_regex', 'name_regex_keep'],
+ 'value',
+ ),
policyEnabledText() {
- return this.enabled ? __('enabled') : __('disabled');
+ return this.enabled ? ENABLED_TEXT : DISABLED_TEXT;
},
- nameRegexState() {
- return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
+ textAreaState() {
+ return {
+ nameRegexState: this.validateNameRegex(this.name_regex),
+ nameKeepRegexState: this.validateNameRegex(this.name_regex_keep),
+ };
},
fieldsValidity() {
- return this.nameRegexState !== false;
+ return (
+ this.textAreaState.nameRegexState !== false &&
+ this.textAreaState.nameKeepRegexState !== false
+ );
},
isFormElementDisabled() {
return !this.enabled || this.isLoading;
@@ -94,6 +140,9 @@ export default {
},
},
methods: {
+ validateNameRegex(value) {
+ return value ? value.length <= NAME_REGEX_LENGTH : null;
+ },
idGenerator(id) {
return `${id}_${this.uniqueId}`;
},
@@ -111,7 +160,7 @@ export default {
:label-cols="labelCols"
:label-align="labelAlign"
:label-for="idGenerator('expiration-policy-toggle')"
- :label="s__('ContainerRegistry|Expiration policy:')"
+ :label="$options.i18n.enableToggleLabel"
>
<div class="d-flex align-items-start">
<gl-toggle
@@ -120,9 +169,7 @@ export default {
:disabled="isLoading"
/>
<span class="mb-2 ml-1 lh-2">
- <gl-sprintf
- :message="s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}')"
- >
+ <gl-sprintf :message="$options.i18n.enableToggleDescription">
<template #toggleStatus>
<strong>{{ policyEnabledText }}</strong>
</template>
@@ -157,35 +204,34 @@ export default {
</gl-form-group>
<gl-form-group
- :id="idGenerator('expiration-policy-name-matching-group')"
+ v-for="textarea in $options.textAreaList"
+ :id="idGenerator(`${textarea.name}-group`)"
+ :key="textarea.name"
:label-cols="labelCols"
:label-align="labelAlign"
- :label-for="idGenerator('expiration-policy-name-matching')"
- :label="
- s__('ContainerRegistry|Docker tags with names matching this regex pattern will expire:')
- "
- :state="nameRegexState"
- :invalid-feedback="
- s__('ContainerRegistry|The value of this input should be less than 255 characters')
- "
+ :label-for="idGenerator(textarea.name)"
+ :state="textAreaState[textarea.stateVariable]"
+ :invalid-feedback="$options.i18n.textAreaInvalidFeedback"
>
+ <template #label>
+ <gl-sprintf :message="textarea.label">
+ <template #italic="{content}">
+ <i>{{ content }}</i>
+ </template>
+ </gl-sprintf>
+ </template>
<gl-form-textarea
- :id="idGenerator('expiration-policy-name-matching')"
- v-model="name_regex"
- :placeholder="$options.nameRegexPlaceholder"
- :state="nameRegexState"
+ :id="idGenerator(textarea.name)"
+ :value="value[textarea.model]"
+ :placeholder="textarea.placeholder"
+ :state="textAreaState[textarea.stateVariable]"
:disabled="isFormElementDisabled"
trim
+ @input="updateModel($event, textarea.model)"
/>
<template #description>
<span ref="regex-description">
- <gl-sprintf
- :message="
- s__(
- 'ContainerRegistry|Wildcards such as %{codeStart}.*-stable%{codeEnd} or %{codeStart}production/.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
- )
- "
- >
+ <gl-sprintf :message="textarea.description">
<template #code="{content}">
<code>{{ content }}</code>
</template>
diff --git a/app/assets/javascripts/registry/shared/constants.js b/app/assets/javascripts/registry/shared/constants.js
index c0dac466b29..4689d01b1c8 100644
--- a/app/assets/javascripts/registry/shared/constants.js
+++ b/app/assets/javascripts/registry/shared/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
'ContainerRegistry|Something went wrong while fetching the expiration policy.',
@@ -13,3 +13,33 @@ export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
);
export const NAME_REGEX_LENGTH = 255;
+
+export const ENABLED_TEXT = __('enabled');
+export const DISABLED_TEXT = __('disabled');
+
+export const ENABLE_TOGGLE_LABEL = s__('ContainerRegistry|Expiration policy:');
+export const ENABLE_TOGGLE_DESCRIPTION = s__(
+ 'ContainerRegistry|Docker tag expiration policy is %{toggleStatus}',
+);
+
+export const TEXT_AREA_INVALID_FEEDBACK = s__(
+ 'ContainerRegistry|The value of this input should be less than 255 characters',
+);
+
+export const EXPIRATION_INTERVAL_LABEL = s__('ContainerRegistry|Expiration interval:');
+export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Expiration schedule:');
+export const KEEP_N_LABEL = s__('ContainerRegistry|Number of tags to retain:');
+export const NAME_REGEX_LABEL = s__(
+ 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}',
+);
+export const NAME_REGEX_PLACEHOLDER = '.*';
+export const NAME_REGEX_DESCRIPTION = s__(
+ 'ContainerRegistry|Regular expressions such as %{codeStart}.*-test%{codeEnd} or %{codeStart}dev-.*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
+);
+export const NAME_REGEX_KEEP_LABEL = s__(
+ 'ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}',
+);
+export const NAME_REGEX_KEEP_PLACEHOLDER = '';
+export const NAME_REGEX_KEEP_DESCRIPTION = s__(
+ 'ContainerRegistry|Regular expressions such as %{codeStart}.*-master%{codeEnd} or %{codeStart}release-.*%{codeEnd} are supported',
+);
diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue
index 8d68ff02116..01dd0638023 100644
--- a/app/assets/javascripts/releases/components/app_edit.vue
+++ b/app/assets/javascripts/releases/components/app_edit.vue
@@ -1,7 +1,7 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
@@ -9,6 +9,7 @@ import { BACK_URL_PARAM } from '~/releases/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import AssetLinksForm from './asset_links_form.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
export default {
name: 'ReleaseEditApp',
@@ -18,6 +19,7 @@ export default {
GlButton,
MarkdownField,
AssetLinksForm,
+ MilestoneCombobox,
},
directives: {
autofocusonshow,
@@ -32,6 +34,10 @@ export default {
'markdownPreviewPath',
'releasesPagePath',
'updateReleaseApiDocsPath',
+ 'release',
+ 'newMilestonePath',
+ 'manageMilestonesPath',
+ 'projectId',
]),
...mapGetters('detail', ['isValid']),
showForm() {
@@ -58,7 +64,7 @@ export default {
'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
),
{
- linkStart: `<a href="${esc(
+ linkStart: `<a href="${escape(
this.updateReleaseApiDocsPath,
)}" target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
@@ -82,6 +88,14 @@ export default {
this.updateReleaseNotes(notes);
},
},
+ releaseMilestones: {
+ get() {
+ return this.$store.state.detail.release.milestones;
+ },
+ set(milestones) {
+ this.updateReleaseMilestones(milestones);
+ },
+ },
cancelPath() {
return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath;
},
@@ -91,6 +105,18 @@ export default {
isSaveChangesDisabled() {
return this.isUpdatingRelease || !this.isValid;
},
+ milestoneComboboxExtraLinks() {
+ return [
+ {
+ text: __('Create new'),
+ url: this.newMilestonePath,
+ },
+ {
+ text: __('Manage milestones'),
+ url: this.manageMilestonesPath,
+ },
+ ];
+ },
},
created() {
this.fetchRelease();
@@ -101,6 +127,7 @@ export default {
'updateRelease',
'updateReleaseTitle',
'updateReleaseNotes',
+ 'updateReleaseMilestones',
]),
},
};
@@ -137,6 +164,16 @@ export default {
class="form-control"
/>
</gl-form-group>
+ <gl-form-group class="w-50">
+ <label>{{ __('Milestones') }}</label>
+ <div class="d-flex flex-column col-md-6 col-sm-10 pl-0">
+ <milestone-combobox
+ v-model="releaseMilestones"
+ :project-id="projectId"
+ :extra-links="milestoneComboboxExtraLinks"
+ />
+ </div>
+ </gl-form-group>
<gl-form-group>
<label for="release-notes">{{ __('Release notes') }}</label>
<div class="bordered-box pr-3 pl-3">
@@ -147,19 +184,19 @@ export default {
:add-spacing-classes="false"
class="prepend-top-10 append-bottom-10"
>
- <textarea
- id="release-notes"
- slot="textarea"
- v-model="releaseNotes"
- class="note-textarea js-gfm-input js-autosize markdown-area"
- dir="auto"
- data-supports-quick-actions="false"
- :aria-label="__('Release notes')"
- :placeholder="__('Write your release notes or drag your files here…')"
- @keydown.meta.enter="updateRelease()"
- @keydown.ctrl.enter="updateRelease()"
- >
- </textarea>
+ <template #textarea>
+ <textarea
+ id="release-notes"
+ v-model="releaseNotes"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
+ dir="auto"
+ data-supports-quick-actions="false"
+ :aria-label="__('Release notes')"
+ :placeholder="__('Write your release notes or drag your files here…')"
+ @keydown.meta.enter="updateRelease()"
+ @keydown.ctrl.enter="updateRelease()"
+ ></textarea>
+ </template>
</markdown-field>
</div>
</gl-form-group>
@@ -174,12 +211,9 @@ export default {
type="submit"
:aria-label="__('Save changes')"
:disabled="isSaveChangesDisabled"
+ >{{ __('Save changes') }}</gl-button
>
- {{ __('Save changes') }}
- </gl-button>
- <gl-button :href="cancelPath" class="js-cancel-button">
- {{ __('Cancel') }}
- </gl-button>
+ <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue
index 215a376fc76..67085ecca2b 100644
--- a/app/assets/javascripts/releases/components/app_index.vue
+++ b/app/assets/javascripts/releases/components/app_index.vue
@@ -1,6 +1,6 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui';
+import { GlSkeletonLoading, GlEmptyState, GlLink, GlButton } from '@gitlab/ui';
import {
getParameterByName,
historyPushState,
@@ -18,6 +18,7 @@ export default {
ReleaseBlock,
TablePagination,
GlLink,
+ GlButton,
},
props: {
projectId: {
@@ -69,14 +70,16 @@ export default {
</script>
<template>
<div class="flex flex-column mt-2">
- <gl-link
+ <gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
- class="btn btn-success align-self-end mb-2 js-new-release-btn"
+ category="primary"
+ variant="success"
+ class="align-self-end mb-2 js-new-release-btn"
>
{{ __('New release') }}
- </gl-link>
+ </gl-button>
<gl-skeleton-loading v-if="isLoading" class="js-loading" />
diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue
index 4bdc88f01dd..0698ca5e31f 100644
--- a/app/assets/javascripts/releases/components/asset_links_form.vue
+++ b/app/assets/javascripts/releases/components/asset_links_form.vue
@@ -162,7 +162,7 @@ export default {
:state="isNameValid(link)"
@change="onLinkTitleInput(link.id, $event)"
/>
- <template v-slot:invalid-feedback>
+ <template #invalid-feedback>
<span v-if="hasEmptyName(link)" class="invalid-feedback d-inline">
{{ __('Link title is required') }}
</span>
diff --git a/app/assets/javascripts/releases/components/evidence_block.vue b/app/assets/javascripts/releases/components/evidence_block.vue
index 59c1b3eb48e..acae6fda533 100644
--- a/app/assets/javascripts/releases/components/evidence_block.vue
+++ b/app/assets/javascripts/releases/components/evidence_block.vue
@@ -76,11 +76,13 @@ export default {
</gl-link>
<expand-button>
- <template slot="short">
+ <template #short>
<span class="js-short monospace">{{ shortSha(index) }}</span>
</template>
- <template slot="expanded">
- <span class="js-expanded monospace gl-pl-1">{{ sha(index) }}</span>
+ <template #expanded>
+ <span class="js-expanded monospace gl-pl-1-deprecated-no-really-do-not-use-me">{{
+ sha(index)
+ }}</span>
</template>
</expand-button>
<clipboard-button
diff --git a/app/assets/javascripts/releases/components/release_block_footer.vue b/app/assets/javascripts/releases/components/release_block_footer.vue
index a95fbc0b373..26154272d39 100644
--- a/app/assets/javascripts/releases/components/release_block_footer.vue
+++ b/app/assets/javascripts/releases/components/release_block_footer.vue
@@ -57,6 +57,11 @@ export default {
? sprintf(__("%{username}'s avatar"), { username: this.author.username })
: null;
},
+ createdTime() {
+ const now = new Date();
+ const isFuture = now < new Date(this.releasedAt);
+ return isFuture ? __('Will be created') : __('Created');
+ },
},
};
</script>
@@ -86,7 +91,7 @@ export default {
v-if="releasedAt || author"
class="float-left d-flex align-items-center js-author-date-info"
>
- <span class="text-secondary">{{ __('Created') }}&nbsp;</span>
+ <span class="text-secondary">{{ createdTime }}&nbsp;</span>
<template v-if="releasedAt">
<span
v-gl-tooltip.bottom
diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue
index 6f7e1dcfe2f..ed49841757a 100644
--- a/app/assets/javascripts/releases/components/release_block_header.vue
+++ b/app/assets/javascripts/releases/components/release_block_header.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { BACK_URL_PARAM } from '~/releases/constants';
import { setUrlParams } from '~/lib/utils/url_utility';
@@ -10,6 +10,7 @@ export default {
GlLink,
GlBadge,
Icon,
+ GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -41,7 +42,7 @@ export default {
<template>
<div class="card-header d-flex align-items-center bg-white pr-0">
- <h2 class="card-title my-2 mr-auto gl-font-size-20">
+ <h2 class="card-title my-2 mr-auto gl-font-size-20-deprecated-no-really-do-not-use-me">
<gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit">
{{ release.name }}
</gl-link>
@@ -50,14 +51,16 @@ export default {
__('Upcoming Release')
}}</gl-badge>
</h2>
- <gl-link
+ <gl-button
v-if="editLink"
v-gl-tooltip
- class="btn btn-default append-right-10 js-edit-button ml-2"
+ category="primary"
+ variant="default"
+ class="append-right-10 js-edit-button ml-2 pb-2"
:title="__('Edit this release')"
:href="editLink"
>
<icon name="pencil" />
- </gl-link>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/releases/components/release_block_metadata.vue b/app/assets/javascripts/releases/components/release_block_metadata.vue
index 052e4088a5f..40133941011 100644
--- a/app/assets/javascripts/releases/components/release_block_metadata.vue
+++ b/app/assets/javascripts/releases/components/release_block_metadata.vue
@@ -38,9 +38,12 @@ export default {
return Boolean(this.author);
},
releasedTimeAgo() {
- return sprintf(__('released %{time}'), {
- time: this.timeFormatted(this.release.releasedAt),
- });
+ const now = new Date();
+ const isFuture = now < new Date(this.release.releasedAt);
+ const time = this.timeFormatted(this.release.releasedAt);
+ return isFuture
+ ? sprintf(__('will be released %{time}'), { time })
+ : sprintf(__('released %{time}'), { time });
},
shouldRenderMilestones() {
return Boolean(this.release.milestones?.length);
@@ -74,7 +77,11 @@ export default {
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.releasedAt)">
+ <span
+ v-gl-tooltip.bottom
+ class="js-release-date-info"
+ :title="tooltipTitle(release.releasedAt)"
+ >
{{ releasedTimeAgo }}
</span>
</div>
diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
index 01ad0cbf732..d9fbd2884b7 100644
--- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue
+++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue
@@ -3,7 +3,7 @@ import {
GlProgressBar,
GlLink,
GlBadge,
- GlDeprecatedButton,
+ GlButton,
GlTooltipDirective,
GlSprintf,
} from '@gitlab/ui';
@@ -17,7 +17,7 @@ export default {
GlProgressBar,
GlLink,
GlBadge,
- GlDeprecatedButton,
+ GlButton,
GlSprintf,
},
directives: {
@@ -134,13 +134,9 @@ export default {
<span :key="'bullet-' + milestone.id" class="append-right-4">&bull;</span>
</template>
<template v-if="shouldRenderShowMoreLink(index)">
- <gl-deprecated-button
- :key="'more-button-' + milestone.id"
- variant="link"
- @click="toggleShowAll"
- >
+ <gl-button :key="'more-button-' + milestone.id" variant="link" @click="toggleShowAll">
{{ moreText }}
- </gl-deprecated-button>
+ </gl-button>
</template>
</template>
</div>
diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js
index 7b84c18242c..3bc427dfa16 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/actions.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js
@@ -18,7 +18,12 @@ export const fetchRelease = ({ dispatch, state }) => {
return api
.release(state.projectId, state.tagName)
- .then(({ data: release }) => {
+ .then(({ data }) => {
+ const release = {
+ ...data,
+ milestones: data.milestones || [],
+ };
+
dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
})
.catch(error => {
@@ -28,6 +33,8 @@ export const fetchRelease = ({ dispatch, state }) => {
export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title);
export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes);
+export const updateReleaseMilestones = ({ commit }, milestones) =>
+ commit(types.UPDATE_RELEASE_MILESTONES, milestones);
export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE);
export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => {
@@ -45,12 +52,14 @@ export const updateRelease = ({ dispatch, state, getters }) => {
dispatch('requestUpdateRelease');
const { release } = state;
+ const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : [];
return (
api
.updateRelease(state.projectId, state.tagName, {
name: release.name,
description: release.description,
+ milestones,
})
/**
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
index 04944b76e42..1d6356990ce 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js
@@ -4,6 +4,7 @@ export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR';
export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE';
export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES';
+export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES';
export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE';
export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS';
diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
index 3d97e3a75c2..5c29b402cba 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js
@@ -28,6 +28,10 @@ export default {
state.release.description = notes;
},
+ [types.UPDATE_RELEASE_MILESTONES](state, milestones) {
+ state.release.milestones = milestones;
+ },
+
[types.REQUEST_UPDATE_RELEASE](state) {
state.isUpdatingRelease = true;
},
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index b513e1bed79..6d0d102c719 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -6,6 +6,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
+ manageMilestonesPath,
+ newMilestonePath,
}) => ({
projectId,
tagName,
@@ -14,6 +16,8 @@ export default ({
markdownPreviewPath,
updateReleaseApiDocsPath,
releaseAssetsDocsPath,
+ manageMilestonesPath,
+ newMilestonePath,
/** The Release object */
release: null,
diff --git a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
index 6aae9195be1..653dcced98b 100644
--- a/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
+++ b/app/assets/javascripts/reports/accessibility_report/components/accessibility_issue_body.vue
@@ -26,18 +26,11 @@ export default {
* The TECHS code is the "G18", "G168", "H91", etc. from the code which is used for the documentation.
* Here we simply split the string on `.` and get the code in the 5th position
*/
- if (this.issue.code === undefined) {
- return null;
- }
-
- return this.issue.code.split('.')[4] || null;
+ return this.issue.code?.split('.')[4];
},
learnMoreUrl() {
- if (this.parsedTECHSCode === null) {
- return 'https://www.w3.org/TR/WCAG20-TECHS/Overview.html';
- }
-
- return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode}.html`;
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `https://www.w3.org/TR/WCAG20-TECHS/${this.parsedTECHSCode || 'Overview'}.html`;
},
},
};
@@ -52,10 +45,19 @@ export default {
>
{{ s__('AccessibilityReport|New') }}
</div>
- {{ issue.name }}
- <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
- s__('AccessibilityReport|Learn More')
- }}</gl-link>
+ <div>
+ {{
+ sprintf(
+ s__(
+ 'AccessibilityReport|The accessibility scanning found an error of the following type: %{code}',
+ ),
+ { code: issue.code },
+ )
+ }}
+ <gl-link ref="accessibility-issue-learn-more" :href="learnMoreUrl" target="_blank">{{
+ s__('AccessibilityReport|Learn More')
+ }}</gl-link>
+ </div>
{{ sprintf(s__('AccessibilityReport|Message: %{message}'), { message: issue.message }) }}
</div>
</div>
diff --git a/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
new file mode 100644
index 00000000000..6f8ddd01df8
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/grouped_accessibility_reports_app.vue
@@ -0,0 +1,64 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import { componentNames } from '~/reports/components/issue_body';
+import ReportSection from '~/reports/components/report_section.vue';
+import IssuesList from '~/reports/components/issues_list.vue';
+import createStore from './store';
+
+export default {
+ name: 'GroupedAccessibilityReportsApp',
+ store: createStore(),
+ components: {
+ ReportSection,
+ IssuesList,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ componentNames,
+ computed: {
+ ...mapGetters([
+ 'summaryStatus',
+ 'groupedSummaryText',
+ 'shouldRenderIssuesList',
+ 'unresolvedIssues',
+ 'resolvedIssues',
+ 'newIssues',
+ ]),
+ },
+ created() {
+ this.setEndpoint(this.endpoint);
+
+ this.fetchReport();
+ },
+ methods: {
+ ...mapActions(['fetchReport', 'setEndpoint']),
+ },
+};
+</script>
+<template>
+ <report-section
+ :status="summaryStatus"
+ :success-text="groupedSummaryText"
+ :loading-text="groupedSummaryText"
+ :error-text="groupedSummaryText"
+ :has-issues="shouldRenderIssuesList"
+ class="mr-widget-section grouped-security-reports mr-report"
+ >
+ <template #body>
+ <div class="mr-widget-grouped-section report-block">
+ <issues-list
+ v-if="shouldRenderIssuesList"
+ :unresolved-issues="unresolvedIssues"
+ :new-issues="newIssues"
+ :resolved-issues="resolvedIssues"
+ :component="$options.componentNames.AccessibilityIssueBody"
+ class="report-block-group-list"
+ />
+ </div>
+ </template>
+ </report-section>
+</template>
diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js
new file mode 100644
index 00000000000..446cfd79984
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js
@@ -0,0 +1,79 @@
+import Visibility from 'visibilityjs';
+import Poll from '~/lib/utils/poll';
+import httpStatusCodes from '~/lib/utils/http_status';
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+
+let eTagPoll;
+
+export const clearEtagPoll = () => {
+ eTagPoll = null;
+};
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export const restartPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
+
+/**
+ * We need to poll the report endpoint while they are being parsed in the Backend.
+ * This can take up to one minute.
+ *
+ * Poll.js will handle etag response.
+ * While http status code is 204, it means it's parsing, and we'll keep polling
+ * When http status code is 200, it means parsing is done, we can show the results & stop polling
+ * When http status code is 500, it means parsing went wrong and we stop polling
+ */
+export const fetchReport = ({ state, dispatch, commit }) => {
+ commit(types.REQUEST_REPORT);
+
+ eTagPoll = new Poll({
+ resource: {
+ getReport(endpoint) {
+ return axios.get(endpoint);
+ },
+ },
+ data: state.endpoint,
+ method: 'getReport',
+ successCallback: ({ status, data }) => dispatch('receiveReportSuccess', { status, data }),
+ errorCallback: () => dispatch('receiveReportError'),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ } else {
+ axios
+ .get(state.endpoint)
+ .then(({ status, data }) => dispatch('receiveReportSuccess', { status, data }))
+ .catch(() => dispatch('receiveReportError'));
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden() && state.isLoading) {
+ dispatch('restartPolling');
+ } else {
+ dispatch('stopPolling');
+ }
+ });
+};
+
+export const receiveReportSuccess = ({ commit, dispatch }, { status, data }) => {
+ if (status === httpStatusCodes.OK) {
+ commit(types.RECEIVE_REPORT_SUCCESS, data);
+ // Stop polling since we have the information already parsed and it won't be changing
+ dispatch('stopPolling');
+ }
+};
+
+export const receiveReportError = ({ commit, dispatch }) => {
+ commit(types.RECEIVE_REPORT_ERROR);
+ dispatch('stopPolling');
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js
new file mode 100644
index 00000000000..9aff427e644
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/getters.js
@@ -0,0 +1,48 @@
+import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../../constants';
+import { s__, n__ } from '~/locale';
+
+export const groupedSummaryText = state => {
+ if (state.isLoading) {
+ return s__('Reports|Accessibility scanning results are being parsed');
+ }
+
+ if (state.hasError) {
+ return s__('Reports|Accessibility scanning failed loading results');
+ }
+
+ const numberOfResults = state.report?.summary?.errored || 0;
+ if (numberOfResults === 0) {
+ return s__('Reports|Accessibility scanning detected no issues for the source branch only');
+ }
+
+ return n__(
+ 'Reports|Accessibility scanning detected %d issue for the source branch only',
+ 'Reports|Accessibility scanning detected %d issues for the source branch only',
+ numberOfResults,
+ );
+};
+
+export const summaryStatus = state => {
+ if (state.isLoading) {
+ return LOADING;
+ }
+
+ if (state.hasError || state.status === STATUS_FAILED) {
+ return ERROR;
+ }
+
+ return SUCCESS;
+};
+
+export const shouldRenderIssuesList = state =>
+ Object.values(state.report).some(x => Array.isArray(x) && x.length > 0);
+
+// We could just map state, but we're going to iterate in the future
+// to add notes and warnings to these issue lists, so I'm going to
+// keep these as getters
+export const unresolvedIssues = state => state.report.existing_errors;
+export const resolvedIssues = state => state.report.resolved_errors;
+export const newIssues = state => state.report.new_errors;
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/static_site_editor/store/index.js b/app/assets/javascripts/reports/accessibility_report/store/index.js
index 43256979ddd..047964260ad 100644
--- a/app/assets/javascripts/static_site_editor/store/index.js
+++ b/app/assets/javascripts/reports/accessibility_report/store/index.js
@@ -1,19 +1,16 @@
-import Vuex from 'vuex';
import Vue from 'vue';
-import createState from './state';
-import * as getters from './getters';
+import Vuex from 'vuex';
import * as actions from './actions';
+import * as getters from './getters';
import mutations from './mutations';
+import state from './state';
Vue.use(Vuex);
-const createStore = ({ initialState } = {}) => {
- return new Vuex.Store({
- state: createState(initialState),
- getters,
+export default initialState =>
+ new Vuex.Store({
actions,
+ getters,
mutations,
+ state: state(initialState),
});
-};
-
-export default createStore;
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
new file mode 100644
index 00000000000..22e2330e1ea
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+
+export const REQUEST_REPORT = 'REQUEST_REPORT';
+export const RECEIVE_REPORT_SUCCESS = 'RECEIVE_REPORT_SUCCESS';
+export const RECEIVE_REPORT_ERROR = 'RECEIVE_REPORT_ERROR';
diff --git a/app/assets/javascripts/reports/accessibility_report/store/mutations.js b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
new file mode 100644
index 00000000000..20d3e5be9a3
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/mutations.js
@@ -0,0 +1,20 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ state.endpoint = endpoint;
+ },
+ [types.REQUEST_REPORT](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_REPORT_SUCCESS](state, report) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.report = report;
+ },
+ [types.RECEIVE_REPORT_ERROR](state) {
+ state.isLoading = false;
+ state.hasError = true;
+ state.report = {};
+ },
+};
diff --git a/app/assets/javascripts/reports/accessibility_report/store/state.js b/app/assets/javascripts/reports/accessibility_report/store/state.js
new file mode 100644
index 00000000000..2a4cefea5e6
--- /dev/null
+++ b/app/assets/javascripts/reports/accessibility_report/store/state.js
@@ -0,0 +1,28 @@
+export default (initialState = {}) => ({
+ endpoint: initialState.endpoint || '',
+
+ isLoading: initialState.isLoading || false,
+ hasError: initialState.hasError || false,
+
+ /**
+ * Report will have the following format:
+ * {
+ * status: {String},
+ * summary: {
+ * total: {Number},
+ * resolved: {Number},
+ * errored: {Number},
+ * },
+ * existing_errors: {Array.<Object>},
+ * existing_notes: {Array.<Object>},
+ * existing_warnings: {Array.<Object>},
+ * new_errors: {Array.<Object>},
+ * new_notes: {Array.<Object>},
+ * new_warnings: {Array.<Object>},
+ * resolved_errors: {Array.<Object>},
+ * resolved_notes: {Array.<Object>},
+ * resolved_warnings: {Array.<Object>},
+ * }
+ */
+ report: initialState.report || {},
+});
diff --git a/app/assets/javascripts/reports/components/grouped_issues_list.vue b/app/assets/javascripts/reports/components/grouped_issues_list.vue
new file mode 100644
index 00000000000..97587636644
--- /dev/null
+++ b/app/assets/javascripts/reports/components/grouped_issues_list.vue
@@ -0,0 +1,93 @@
+<script>
+import { s__ } from '~/locale';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
+import ReportItem from '~/reports/components/report_item.vue';
+
+export default {
+ components: {
+ ReportItem,
+ SmartVirtualList,
+ },
+ props: {
+ component: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ resolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ unresolvedIssues: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ resolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|Fixed'),
+ },
+ unresolvedHeading: {
+ type: String,
+ required: false,
+ default: s__('ciReport|New'),
+ },
+ },
+ groups: ['unresolved', 'resolved'],
+ typicalReportItemHeight: 32,
+ maxShownReportItems: 20,
+ computed: {
+ groups() {
+ return this.$options.groups
+ .map(group => ({
+ name: group,
+ issues: this[`${group}Issues`],
+ heading: this[`${group}Heading`],
+ }))
+ .filter(({ issues }) => issues.length > 0);
+ },
+ listLength() {
+ // every group has a header which is rendered as a list item
+ const groupsCount = this.groups.length;
+ const issuesCount = this.groups.reduce(
+ (totalIssues, { issues }) => totalIssues + issues.length,
+ 0,
+ );
+
+ return groupsCount + issuesCount;
+ },
+ },
+};
+</script>
+
+<template>
+ <smart-virtual-list
+ :length="listLength"
+ :remain="$options.maxShownReportItems"
+ :size="$options.typicalReportItemHeight"
+ class="report-block-container"
+ wtag="ul"
+ wclass="report-block-list"
+ >
+ <template v-for="(group, groupIndex) in groups">
+ <h2
+ :key="group.name"
+ :data-testid="`${group.name}Heading`"
+ :class="[groupIndex > 0 ? 'mt-2' : 'mt-0']"
+ class="h5 mb-1"
+ >
+ {{ group.heading }}
+ </h2>
+ <report-item
+ v-for="(issue, issueIndex) in group.issues"
+ :key="`${group.name}-${issue.name}-${group.name}-${issueIndex}`"
+ :issue="issue"
+ :show-report-section-status-icon="false"
+ :component="component"
+ status="none"
+ />
+ </template>
+ </smart-virtual-list>
+</template>
diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
index 88d174f96ed..0f7a0e60dc0 100644
--- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
+++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
-import { s__ } from '~/locale';
+import { sprintf, s__ } from '~/locale';
import { componentNames } from './issue_body';
import ReportSection from './report_section.vue';
import SummaryRow from './summary_row.vue';
@@ -52,8 +52,17 @@ export default {
methods: {
...mapActions(['setEndpoint', 'fetchReports']),
reportText(report) {
- const summary = report.summary || {};
- return reportTextBuilder(report.name, summary);
+ const { name, summary } = report || {};
+
+ if (report.status === 'error') {
+ return sprintf(s__('Reports|An error occurred while loading %{name} results'), { name });
+ }
+
+ if (!report.name) {
+ return s__('Reports|An error occured while loading report');
+ }
+
+ return reportTextBuilder(name, summary);
},
getReportIcon(report) {
return statusIcon(report.status);
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 62a9338b864..d79e3ddd798 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -8,7 +8,6 @@ export default {
Icon,
},
props: {
- // failed || success
status: {
type: String,
required: true,
@@ -27,7 +26,7 @@ export default {
return 'status_success_borderless';
}
- return 'status_created_borderless';
+ return 'dash';
},
isStatusFailed() {
return this.status === STATUS_FAILED;
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 20b0c52dbda..68956fc6d2b 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -167,7 +167,7 @@ export default {
<div class="media">
<status-icon :status="statusIconName" :size="24" class="align-self-center" />
<div class="media-body d-flex flex-align-self-center align-items-center">
- <div class="js-code-text code-text">
+ <div data-testid="report-section-code-text" class="js-code-text code-text">
<div>
{{ headerText }}
<slot :name="slotName"></slot>
diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js
index 1845b51e6b2..b3905cbfcfb 100644
--- a/app/assets/javascripts/reports/constants.js
+++ b/app/assets/javascripts/reports/constants.js
@@ -22,3 +22,6 @@ export const status = {
ERROR: 'ERROR',
SUCCESS: 'SUCCESS',
};
+
+export const ACCESSIBILITY_ISSUE_ERROR = 'error';
+export const ACCESSIBILITY_ISSUE_WARNING = 'warning';
diff --git a/app/assets/javascripts/reports/store/mutations.js b/app/assets/javascripts/reports/store/mutations.js
index 68f6de3a7ee..35ab72bf694 100644
--- a/app/assets/javascripts/reports/store/mutations.js
+++ b/app/assets/javascripts/reports/store/mutations.js
@@ -8,8 +8,7 @@ export default {
state.isLoading = true;
},
[types.RECEIVE_REPORTS_SUCCESS](state, response) {
- // Make sure to clean previous state in case it was an error
- state.hasError = false;
+ state.hasError = response.suites.some(suite => suite.status === 'error');
state.isLoading = false;
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 886e9d76cca..45c343c3f7f 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -242,7 +242,7 @@ export default {
</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">
+ <template #button-content>
<span class="sr-only">{{ __('Add to tree') }}</span>
<icon name="plus" :size="16" class="float-left" />
<icon name="chevron-down" :size="16" class="float-left" />
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index a13f8ac65cf..010fc9a5d1a 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -121,9 +121,8 @@ export default {
:href="commit.webUrl"
:class="{ 'font-italic': !commit.message }"
class="commit-row-message item-title"
- >
- {{ commit.title }}
- </gl-link>
+ v-html="commit.titleHtml"
+ />
<gl-deprecated-button
v-if="commit.description"
:class="{ open: showDescription }"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index f741a6df5d9..34424121390 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -167,9 +167,8 @@ export default {
:href="commit.commitPath"
:title="commit.message"
class="str-truncated-100 tree-commit-link"
- >
- {{ commit.message }}
- </gl-link>
+ v-html="commit.titleHtml"
+ />
<gl-skeleton-loading v-else :lines="1" class="h-auto" />
</td>
<td class="tree-time-ago text-right cursor-default">
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
index 9bb13c475c7..be6897b9a16 100644
--- a/app/assets/javascripts/repository/queries/commit.fragment.graphql
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -1,6 +1,7 @@
fragment TreeEntryCommit on LogTreeCommit {
sha
message
+ titleHtml
committedDate
commitPath
fileName
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index a22cadf0e8d..f54f09fd647 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -5,6 +5,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
lastCommit {
sha
title
+ titleHtml
description
message
webUrl
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 49e024ca4ff..c5646c32850 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -7,17 +7,28 @@ import TreePage from './pages/tree.vue';
Vue.use(VueRouter);
export default function createRouter(base, baseRef) {
+ const treePathRoute = {
+ component: TreePage,
+ props: route => ({
+ path: route.params.path?.replace(/^\//, '') || '/',
+ }),
+ };
+
return new VueRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [
{
- path: `(/-)?/tree/${baseRef}/:path*`,
+ name: 'treePathDecoded',
+ // Sometimes the ref needs decoding depending on how the backend sends it to us
+ path: `(/-)?/tree/${decodeURI(baseRef)}/:path*`,
+ ...treePathRoute,
+ },
+ {
name: 'treePath',
- component: TreePage,
- props: route => ({
- path: route.params.path?.replace(/^\//, '') || '/',
- }),
+ // Support without decoding as well just in case the ref doesn't need to be decoded
+ path: `(/-)?/tree/${baseRef}/:path*`,
+ ...treePathRoute,
},
{
path: '/',
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
index 3973798605d..90ac01c5874 100644
--- a/app/assets/javascripts/repository/utils/commit.js
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -3,6 +3,7 @@ export function normalizeData(data, path, extra = () => {}) {
return data.map(d => ({
sha: d.commit.id,
message: d.commit.message,
+ titleHtml: d.commit_title_html,
committedDate: d.commit.committed_date,
commitPath: d.commit_path,
fileName: d.file_name,
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 550ec3cb0d1..0bb33de0234 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,7 +1,6 @@
/* eslint-disable func-names, consistent-return, no-param-reassign */
import $ from 'jquery';
-import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
@@ -142,7 +141,7 @@ Sidebar.prototype.sidebarCollapseClicked = function(e) {
};
Sidebar.prototype.openDropdown = function(blockOrName) {
- const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
+ const $block = typeof blockOrName === 'string' ? this.getBlock(blockOrName) : blockOrName;
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
this.toggleSidebar('open');
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 3eaa34c8a93..d8eb981c106 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,7 +1,7 @@
/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
-import { escape, throttle } from 'underscore';
+import { escape, throttle } from 'lodash';
import { s__, __ } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
@@ -407,7 +407,7 @@ export class SearchAutocomplete {
disableAutocomplete() {
if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
this.searchInput.addClass('js-autocomplete-disabled');
- this.dropdown.dropdown('toggle');
+ this.dropdownToggle.dropdown('toggle');
this.restoreMenu();
}
}
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index 272c0bd5614..29a61cfbbfe 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -138,8 +138,8 @@ export default {
:width="width"
:include-legend-avg-max="false"
>
- <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template>
- <template slot="tooltipContent">{{ tooltipPopoverContent }}</template>
+ <template #tooltipTitle>{{ tooltipPopoverTitle }}</template>
+ <template #tooltipContent>{{ tooltipPopoverContent }}</template>
</gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/serverless/event_hub.js b/app/assets/javascripts/serverless/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/serverless/event_hub.js
+++ b/app/assets/javascripts/serverless/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/set_status_modal/event_hub.js b/app/assets/javascripts/set_status_modal/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/set_status_modal/event_hub.js
+++ b/app/assets/javascripts/set_status_modal/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index f16b16a6837..3baf4bf0742 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -1,6 +1,6 @@
<script>
-import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue';
-import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue';
+import CollapsedAssigneeList from './collapsed_assignee_list.vue';
+import UncollapsedAssigneeList from './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
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
new file mode 100644
index 00000000000..bf0c52b2341
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees_realtime.vue
@@ -0,0 +1,75 @@
+<script>
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import query from '~/issuable_sidebar/queries/issue_sidebar.query.graphql';
+import actionCable from '~/actioncable_consumer';
+
+export default {
+ subscription: null,
+ name: 'AssigneesRealtime',
+ props: {
+ mediator: {
+ type: Object,
+ required: true,
+ },
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ apollo: {
+ project: {
+ query,
+ variables() {
+ return {
+ iid: this.issuableIid,
+ fullPath: this.projectPath,
+ };
+ },
+ result(data) {
+ this.handleFetchResult(data);
+ },
+ },
+ },
+ mounted() {
+ this.initActionCablePolling();
+ },
+ beforeDestroy() {
+ this.$options.subscription.unsubscribe();
+ },
+ methods: {
+ received(data) {
+ if (data.event === 'updated') {
+ this.$apollo.queries.project.refetch();
+ }
+ },
+ initActionCablePolling() {
+ this.$options.subscription = actionCable.subscriptions.create(
+ {
+ channel: 'IssuesChannel',
+ project_path: this.projectPath,
+ iid: this.issuableIid,
+ },
+ { received: this.received },
+ );
+ },
+ handleFetchResult({ data }) {
+ const { nodes } = data.project.issue.assignees;
+
+ const assignees = nodes.map(n => ({
+ ...n,
+ avatar_url: n.avatarUrl,
+ id: getIdFromGraphQLId(n.id),
+ }));
+
+ this.mediator.store.setAssigneesFromRealtime(assignees);
+ },
+ },
+ render() {
+ return this.$slots.default;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
index ce592720531..0906d5abec3 100644
--- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue
@@ -3,8 +3,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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import AssigneeTitle from './assignee_title.vue';
import Assignees from './assignees.vue';
+import AssigneesRealtime from './assignees_realtime.vue';
import { __ } from '~/locale';
export default {
@@ -12,7 +14,9 @@ export default {
components: {
AssigneeTitle,
Assignees,
+ AssigneesRealtime,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
mediator: {
type: Object,
@@ -32,6 +36,14 @@ export default {
required: false,
default: 'issue',
},
+ issuableIid: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -39,6 +51,12 @@ export default {
loading: false,
};
},
+ computed: {
+ shouldEnableRealtime() {
+ // Note: Realtime is only available on issues right now, future support for MR wil be built later.
+ return this.glFeatures.realTimeIssueSidebar && this.issuableType === 'issue';
+ },
+ },
created() {
this.removeAssignee = this.store.removeAssignee.bind(this.store);
this.addAssignee = this.store.addAssignee.bind(this.store);
@@ -84,6 +102,12 @@ export default {
<template>
<div>
+ <assignees-realtime
+ v-if="shouldEnableRealtime"
+ :issuable-iid="issuableIid"
+ :project-path="projectPath"
+ :mediator="mediator"
+ />
<assignee-title
:number-of-assignees="store.assignees.length"
:loading="loading || store.isFetching.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
index 3d112bba668..fed9e5886c0 100644
--- a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue
@@ -66,7 +66,7 @@ export default {
<template>
<assignee-avatar-link
v-if="hasOneUser"
- v-slot="{ user }"
+ #default="{ user }"
tooltip-placement="left"
:tooltip-has-name="false"
:user="firstUser"
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 5b3c3642290..550a1be1e64 100644
--- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue
@@ -1,15 +1,16 @@
<script>
+import { mapState } from 'vuex';
import { __ } from '~/locale';
import Flash from '~/flash';
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 EditForm from './edit_form.vue';
import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor';
export default {
components: {
- editForm,
+ EditForm,
Icon,
},
directives: {
@@ -17,10 +18,6 @@ export default {
},
mixins: [recaptchaModalImplementor],
props: {
- isConfidential: {
- required: true,
- type: Boolean,
- },
isEditable: {
required: true,
type: Boolean,
@@ -36,11 +33,12 @@ export default {
};
},
computed: {
+ ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }),
confidentialityIcon() {
- return this.isConfidential ? 'eye-slash' : 'eye';
+ return this.confidential ? 'eye-slash' : 'eye';
},
tooltipLabel() {
- return this.isConfidential ? __('Confidential') : __('Not confidential');
+ return this.confidential ? __('Confidential') : __('Not confidential');
},
},
created() {
@@ -95,17 +93,16 @@ export default {
data-track-label="right_sidebar"
data-track-property="confidentiality"
@click.prevent="toggleForm"
+ >{{ __('Edit') }}</a
>
- {{ __('Edit') }}
- </a>
</div>
<div class="value sidebar-item-value hide-collapsed">
- <editForm
+ <edit-form
v-if="edit"
- :is-confidential="isConfidential"
+ :is-confidential="confidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
- <div v-if="!isConfidential" class="no-value sidebar-item-value">
+ <div v-if="!confidential" class="no-value sidebar-item-value">
<icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
{{ __('Not confidential') }}
</div>
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 5d0e39e8195..e106afea9f5 100644
--- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
+++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue
@@ -40,7 +40,12 @@ export default {
<button type="button" class="btn btn-default append-right-10" @click="closeForm">
{{ __('Cancel') }}
</button>
- <button type="button" class="btn btn-close" @click.prevent="submitForm">
+ <button
+ type="button"
+ class="btn btn-close"
+ data-testid="confidential-toggle"
+ @click.prevent="submitForm"
+ >
{{ toggleButtonText }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index db2e51c3aca..d2904f4157c 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -95,22 +95,18 @@ export default {
@click="onClickCollapsedIcon"
>
<i class="fa fa-users" aria-hidden="true"> </i>
- <gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" />
- <span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span>
+ <gl-loading-icon v-if="loading" />
+ <span v-else data-testid="collapsed-count"> {{ participantCount }} </span>
</div>
<div v-if="showParticipantLabel" class="title hide-collapsed">
- <gl-loading-icon
- v-if="loading"
- :inline="true"
- class="js-participants-expanded-loading-icon"
- />
+ <gl-loading-icon v-if="loading" :inline="true" />
{{ participantLabel }}
</div>
<div class="participants-list hide-collapsed">
<div
v-for="participant in visibleParticipants"
:key="participant.id"
- class="participants-author js-participants-author"
+ class="participants-author"
>
<a :href="participant.web_url" class="author-link">
<user-avatar-image
@@ -125,11 +121,7 @@ export default {
</div>
</div>
<div v-if="hasMoreParticipants" class="participants-more hide-collapsed">
- <button
- type="button"
- class="btn-transparent btn-link js-toggle-participants-button"
- @click="toggleMoreParticipants"
- >
+ <button type="button" class="btn-transparent btn-link" @click="toggleMoreParticipants">
{{ toggleLabel }}
</button>
</div>
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 2a61f7b5c05..0fb9cf22653 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import '~/gl_dropdown';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __ } from '~/locale';
function isValidProjectId(id) {
@@ -49,7 +49,7 @@ class SidebarMoveIssue {
renderRow: project => `
<li>
<a href="#" class="js-move-issue-dropdown-item">
- ${esc(project.name_with_namespace)}
+ ${escape(project.name_with_namespace)}
</a>
</li>
`,
diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js
index 6f8214b18ee..e371091fc53 100644
--- a/app/assets/javascripts/sidebar/mount_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_sidebar.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
@@ -8,17 +9,29 @@ import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate';
+import createDefaultClient from '~/lib/graphql';
+import { store } from '~/notes/stores';
Vue.use(Translate);
+Vue.use(VueApollo);
+
+function getSidebarOptions() {
+ return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
+}
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
if (!el) return;
+ const { iid, fullPath } = getSidebarOptions();
// eslint-disable-next-line no-new
new Vue({
el,
+ apolloProvider,
components: {
SidebarAssignees,
},
@@ -26,6 +39,8 @@ function mountAssigneesComponent(mediator) {
createElement('sidebar-assignees', {
props: {
mediator,
+ issuableIid: String(iid),
+ projectPath: fullPath,
field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'),
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
@@ -45,8 +60,8 @@ function mountConfidentialComponent(mediator) {
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
+ store,
propsData: {
- isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
@@ -144,6 +159,4 @@ export function mountSidebar(mediator) {
mountTimeTrackingComponent();
}
-export function getSidebarOptions() {
- return JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
-}
+export { getSidebarOptions };
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 66f7f9e3c66..095f93b72a9 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -89,6 +89,10 @@ export default class SidebarStore {
this.assignees = [];
}
+ setAssigneesFromRealtime(data) {
+ this.assignees = data;
+ }
+
setAutocompleteProjects(projects) {
this.autocompleteProjects = projects;
}
diff --git a/app/assets/javascripts/snippet/snippet_bundle.js b/app/assets/javascripts/snippet/snippet_bundle.js
index a3ed8d9c632..76a1f6d1458 100644
--- a/app/assets/javascripts/snippet/snippet_bundle.js
+++ b/app/assets/javascripts/snippet/snippet_bundle.js
@@ -3,18 +3,6 @@ import setupCollapsibleInputs from './collapsible_input';
let editor;
-const initAce = () => {
- const editorEl = document.getElementById('editor');
- const form = document.querySelector('.snippet-form-holder form');
- const content = document.querySelector('.snippet-file-content');
-
- editor = initEditorLite({ el: editorEl });
-
- form.addEventListener('submit', () => {
- content.value = editor.getValue();
- });
-};
-
const initMonaco = () => {
const editorEl = document.getElementById('editor');
const contentEl = document.querySelector('.snippet-file-content');
@@ -36,15 +24,7 @@ const initMonaco = () => {
});
};
-export const initEditor = () => {
- if (window?.gon?.features?.monacoSnippets) {
- initMonaco();
- } else {
- initAce();
- }
- setupCollapsibleInputs();
-};
-
export default () => {
- initEditor();
+ initMonaco();
+ setupCollapsibleInputs();
};
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 2185b1d67e4..e8d6c005435 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -81,7 +81,10 @@ export default {
return this.isUpdating ? __('Saving') : __('Save changes');
},
cancelButtonHref() {
- return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ if (this.newSnippet) {
+ return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`;
+ }
+ return this.snippet.webUrl;
},
titleFieldId() {
return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_title`;
@@ -173,7 +176,13 @@ export default {
class="loading-animation prepend-top-20 append-bottom-20"
/>
<template v-else>
- <title-field :id="titleFieldId" v-model="snippet.title" required :autofocus="true" />
+ <title-field
+ :id="titleFieldId"
+ v-model="snippet.title"
+ data-qa-selector="snippet_title"
+ required
+ :autofocus="true"
+ />
<snippet-description-edit
:id="descriptionFieldId"
v-model="snippet.description"
@@ -198,12 +207,15 @@ export default {
category="primary"
variant="success"
:disabled="updatePrevented"
+ data-qa-selector="submit_button"
@click="handleFormSubmit"
>{{ saveButtonLabel }}</gl-button
>
</template>
<template #append>
- <gl-button :href="cancelButtonHref">{{ __('Cancel') }}</gl-button>
+ <gl-button data-testid="snippet-cancel-btn" :href="cancelButtonHref">{{
+ __('Cancel')
+ }}</gl-button>
</template>
</form-footer-actions>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index 44b4607e5a9..dd03902417d 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -33,7 +33,11 @@ export default {
<div class="form-group file-editor">
<label>{{ s__('Snippets|File') }}</label>
<div class="file-holder snippet">
- <blob-header-edit :value="fileName" @input="$emit('name-change', $event)" />
+ <blob-header-edit
+ :value="fileName"
+ data-qa-selector="snippet_file_name"
+ @input="$emit('name-change', $event)"
+ />
<gl-loading-icon
v-if="isLoading"
:label="__('Loading snippet')"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
index 02a0fc7686d..6b218b21e56 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue
@@ -7,7 +7,12 @@ import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
-import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
+import {
+ SIMPLE_BLOB_VIEWER,
+ RICH_BLOB_VIEWER,
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
+} from '~/blob/components/constants';
export default {
components: {
@@ -27,6 +32,16 @@ export default {
},
update: data =>
data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData,
+ result() {
+ if (this.activeViewerType === RICH_BLOB_VIEWER) {
+ this.blob.richViewer.renderError = null;
+ } else {
+ this.blob.simpleViewer.renderError = null;
+ }
+ },
+ skip() {
+ return this.viewer.renderError;
+ },
},
},
props: {
@@ -62,9 +77,15 @@ export default {
},
methods: {
switchViewer(newViewer) {
- this.activeViewerType = newViewer;
+ this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
+ },
+ forceQuery() {
+ this.$apollo.queries.blobContent.skip = false;
+ this.$apollo.queries.blobContent.refetch();
},
},
+ BLOB_RENDER_EVENT_LOAD,
+ BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
@@ -75,12 +96,21 @@ export default {
<template #actions>
<clone-dropdown-button
v-if="canBeCloned"
+ class="mr-2"
:ssh-link="snippet.sshUrlToRepo"
:http-link="snippet.httpUrlToRepo"
+ data-qa-selector="clone_button"
/>
</template>
</blob-header>
- <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" />
+ <blob-content
+ :loading="isContentLoading"
+ :content="blobContent"
+ :active-viewer="viewer"
+ :blob="blob"
+ @[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
+ @[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
+ />
</article>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
index 6f3a86be8d7..0fe539a5de7 100644
--- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue
@@ -51,9 +51,9 @@ export default {
>
<textarea
slot="textarea"
- class="note-textarea js-gfm-input js-autosize markdown-area
- qa-description-textarea"
+ class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
+ data-qa-selector="snippet_description_field"
data-supports-quick-actions="false"
:value="value"
:aria-label="__('Description')"
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
new file mode 100644
index 00000000000..72afcc30be6
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -0,0 +1,21 @@
+<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+export default {
+ components: {
+ MarkdownFieldView,
+ },
+ props: {
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field">
+ <div class="md js-snippet-description" v-html="description"></div>
+ </markdown-field-view>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue
index 30a23b51bc4..c0967e9093c 100644
--- a/app/assets/javascripts/snippets/components/snippet_header.vue
+++ b/app/assets/javascripts/snippets/components/snippet_header.vue
@@ -10,6 +10,7 @@ import {
GlDropdown,
GlDropdownItem,
GlButton,
+ GlTooltipDirective,
} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
@@ -30,6 +31,9 @@ export default {
TimeAgoTooltip,
GlButton,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
apollo: {
canCreateSnippet: {
query() {
@@ -43,7 +47,7 @@ export default {
update(data) {
return this.snippet.project
? data.project.userPermissions.createSnippet
- : data.currentUser.userPermissions.createSnippet;
+ : data.currentUser?.userPermissions.createSnippet;
},
},
},
@@ -67,6 +71,10 @@ export default {
condition: this.snippet.userPermissions.updateSnippet,
text: __('Edit'),
href: this.editLink,
+ disabled: this.snippet.blob.binary,
+ title: this.snippet.blob.binary
+ ? __('Snippets with non-text files can only be edited via Git.')
+ : undefined,
},
{
condition: this.snippet.userPermissions.adminSnippet,
@@ -119,7 +127,7 @@ export default {
},
methods: {
redirectToSnippets() {
- window.location.pathname = 'dashboard/snippets';
+ window.location.pathname = `${this.snippet.project?.fullPath || 'dashboard'}/snippets`;
},
closeDeleteModal() {
this.$refs.deleteModal.hide();
@@ -186,18 +194,26 @@ export default {
<div class="detail-page-header-actions">
<div class="d-none d-sm-flex">
<template v-for="(action, index) in personalSnippetActions">
- <gl-button
+ <div
v-if="action.condition"
:key="index"
- :disabled="action.disabled"
- :variant="action.variant"
- :category="action.category"
- :class="action.cssClass"
- :href="action.href"
- @click="action.click ? action.click() : undefined"
+ v-gl-tooltip
+ :title="action.title"
+ class="d-inline-block"
>
- {{ action.text }}
- </gl-button>
+ <gl-button
+ :disabled="action.disabled"
+ :variant="action.variant"
+ :category="action.category"
+ :class="action.cssClass"
+ :href="action.href"
+ data-qa-selector="snippet_action_button"
+ :data-qa-action="action.text"
+ @click="action.click ? action.click() : undefined"
+ >
+ {{ action.text }}
+ </gl-button>
+ </div>
</template>
</div>
<div class="d-block d-sm-none dropdown">
@@ -205,6 +221,8 @@ export default {
<gl-dropdown-item
v-for="(action, index) in personalSnippetActions"
:key="index"
+ :disabled="action.disabled"
+ :title="action.title"
:href="action.href"
@click="action.click ? action.click() : undefined"
>{{ action.text }}</gl-dropdown-item
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
index 1fc0423a06c..5267c3748ca 100644
--- a/app/assets/javascripts/snippets/components/snippet_title.vue
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -1,11 +1,14 @@
<script>
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import SnippetDescription from './snippet_description_view.vue';
+
export default {
components: {
TimeAgoTooltip,
GlSprintf,
+ SnippetDescription,
},
props: {
snippet: {
@@ -20,9 +23,8 @@ export default {
<h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
{{ snippet.title }}
</h2>
- <div v-if="snippet.description" class="description" data-qa-selector="snippet_description">
- <div class="md js-snippet-description" v-html="snippet.descriptionHtml"></div>
- </div>
+
+ <snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" />
<small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
<gl-sprintf :message="__('Edited %{timeago}')">
diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
index d793d0b6bb4..e7765dfd8ba 100644
--- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
+++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql
@@ -17,6 +17,8 @@ fragment SnippetBase on Snippet {
path
rawPath
size
+ externalStorage
+ renderedAsText
simpleViewer {
...BlobViewer
}
diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue
index 921d93669c5..dff21d919a9 100644
--- a/app/assets/javascripts/static_site_editor/components/edit_area.vue
+++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue
@@ -1,18 +1,61 @@
<script>
-import { GlFormTextarea } from '@gitlab/ui';
+import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
+import PublishToolbar from './publish_toolbar.vue';
+import EditHeader from './edit_header.vue';
export default {
components: {
- GlFormTextarea,
+ RichContentEditor,
+ PublishToolbar,
+ EditHeader,
},
props: {
- value: {
+ title: {
type: String,
required: true,
},
+ content: {
+ type: String,
+ required: true,
+ },
+ savingChanges: {
+ type: Boolean,
+ required: true,
+ },
+ returnUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ editableContent: this.content,
+ saveable: false,
+ };
+ },
+ computed: {
+ modified() {
+ return this.content !== this.editableContent;
+ },
+ },
+ methods: {
+ onSubmit() {
+ this.$emit('submit', { content: this.editableContent });
+ },
},
};
</script>
<template>
- <gl-form-textarea :value="value" v-on="$listeners" />
+ <div class="d-flex flex-grow-1 flex-column">
+ <edit-header class="py-2" :title="title" />
+ <rich-content-editor v-model="editableContent" class="mb-9" />
+ <publish-toolbar
+ class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
+ :return-url="returnUrl"
+ :saveable="modified"
+ :saving-changes="savingChanges"
+ @submit="onSubmit"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
index 274d2f71749..6cd2a4dd700 100644
--- a/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
+++ b/app/assets/javascripts/static_site_editor/components/publish_toolbar.vue
@@ -1,10 +1,9 @@
<script>
-import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
export default {
components: {
GlButton,
- GlLoadingIcon,
},
props: {
returnUrl: {
@@ -26,14 +25,18 @@ export default {
};
</script>
<template>
- <div class="d-flex bg-light border-top justify-content-between align-items-center py-3 px-4">
- <gl-loading-icon :class="{ invisible: !savingChanges }" size="md" />
+ <div class="d-flex bg-light border-top justify-content-end align-items-center py-3 px-4">
<div>
<gl-button v-if="returnUrl" ref="returnUrlLink" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-button>
- <gl-button variant="success" :disabled="!saveable || savingChanges" @click="$emit('submit')">
- {{ __('Submit Changes') }}
+ <gl-button
+ variant="success"
+ :disabled="!saveable"
+ :loading="savingChanges"
+ @click="$emit('submit')"
+ >
+ <span>{{ __('Submit Changes') }}</span>
</gl-button>
</div>
</div>
diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
index 41cb901720c..dd907570114 100644
--- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
+++ b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue
@@ -28,7 +28,8 @@ export default {
},
returnUrl: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
};
@@ -46,7 +47,7 @@ export default {
}}
</p>
<div class="d-flex justify-content-end">
- <gl-button ref="returnToSiteButton" :href="returnUrl">{{
+ <gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{
s__('StaticSiteEditor|Return to site')
}}</gl-button>
<gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success">
@@ -60,7 +61,7 @@ export default {
<ul>
<li>
{{ s__('StaticSiteEditor|You created a new branch:') }}
- <span ref="branchLink">{{ branch.label }}</span>
+ <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link>
</li>
<li>
{{ s__('StaticSiteEditor|You created a merge request:') }}
diff --git a/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
new file mode 100644
index 00000000000..1b6179883aa
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/components/skeleton_loader.vue
@@ -0,0 +1,19 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+<template>
+ <gl-skeleton-loader :width="500" :height="102">
+ <rect width="500" height="16" rx="4" />
+ <rect y="20" width="375" height="16" rx="4" />
+ <rect x="380" y="20" width="120" height="16" rx="4" />
+ <rect y="40" width="250" height="16" rx="4" />
+ <rect x="255" y="40" width="150" height="16" rx="4" />
+ <rect x="410" y="40" width="90" height="16" rx="4" />
+ </gl-skeleton-loader>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue b/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
deleted file mode 100644
index 82917319fc3..00000000000
--- a/app/assets/javascripts/static_site_editor/components/static_site_editor.vue
+++ /dev/null
@@ -1,95 +0,0 @@
-<script>
-import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlSkeletonLoader } from '@gitlab/ui';
-
-import EditArea from './edit_area.vue';
-import EditHeader from './edit_header.vue';
-import SavedChangesMessage from './saved_changes_message.vue';
-import Toolbar from './publish_toolbar.vue';
-import InvalidContentMessage from './invalid_content_message.vue';
-import SubmitChangesError from './submit_changes_error.vue';
-
-export default {
- components: {
- EditArea,
- EditHeader,
- InvalidContentMessage,
- GlSkeletonLoader,
- SavedChangesMessage,
- Toolbar,
- SubmitChangesError,
- },
- computed: {
- ...mapState([
- 'content',
- 'isLoadingContent',
- 'isSavingChanges',
- 'isContentLoaded',
- 'isSupportedContent',
- 'returnUrl',
- 'title',
- 'submitChangesError',
- 'savedContentMeta',
- ]),
- ...mapGetters(['contentChanged']),
- },
- mounted() {
- if (this.isSupportedContent) {
- this.loadContent();
- }
- },
- methods: {
- ...mapActions(['loadContent', 'setContent', 'submitChanges', 'dismissSubmitChangesError']),
- },
-};
-</script>
-<template>
- <div class="d-flex justify-content-center h-100 pt-2">
- <!-- Success view -->
- <saved-changes-message
- v-if="savedContentMeta"
- :branch="savedContentMeta.branch"
- :commit="savedContentMeta.commit"
- :merge-request="savedContentMeta.mergeRequest"
- :return-url="returnUrl"
- />
-
- <!-- Main view -->
- <template v-else-if="isSupportedContent">
- <div v-if="isLoadingContent" class="w-50 h-50">
- <gl-skeleton-loader :width="500" :height="102">
- <rect width="500" height="16" rx="4" />
- <rect y="20" width="375" height="16" rx="4" />
- <rect x="380" y="20" width="120" height="16" rx="4" />
- <rect y="40" width="250" height="16" rx="4" />
- <rect x="255" y="40" width="150" height="16" rx="4" />
- <rect x="410" y="40" width="90" height="16" rx="4" />
- </gl-skeleton-loader>
- </div>
- <div v-if="isContentLoaded" class="d-flex flex-grow-1 flex-column">
- <submit-changes-error
- v-if="submitChangesError"
- class="w-75 align-self-center"
- :error="submitChangesError"
- @retry="submitChanges"
- @dismiss="dismissSubmitChangesError"
- />
- <edit-header class="w-75 align-self-center py-2" :title="title" />
- <edit-area
- class="w-75 h-100 shadow-none align-self-center"
- :value="content"
- @input="setContent"
- />
- <toolbar
- :return-url="returnUrl"
- :saveable="contentChanged"
- :saving-changes="isSavingChanges"
- @submit="submitChanges"
- />
- </div>
- </template>
-
- <!-- Error view -->
- <invalid-content-message v-else class="w-75" />
- </div>
-</template>
diff --git a/app/assets/javascripts/static_site_editor/constants.js b/app/assets/javascripts/static_site_editor/constants.js
index d7ce2a93a56..4794cf5eead 100644
--- a/app/assets/javascripts/static_site_editor/constants.js
+++ b/app/assets/javascripts/static_site_editor/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const BRANCH_SUFFIX_COUNT = 8;
export const DEFAULT_TARGET_BRANCH = 'master';
@@ -10,5 +10,10 @@ export const SUBMIT_CHANGES_COMMIT_ERROR = s__(
export const SUBMIT_CHANGES_MERGE_REQUEST_ERROR = s__(
'StaticSiteEditor|Could not create merge request.',
);
+export const LOAD_CONTENT_ERROR = __(
+ 'An error ocurred while loading your content. Please try again.',
+);
export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
+
+export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';
diff --git a/app/assets/javascripts/static_site_editor/graphql/index.js b/app/assets/javascripts/static_site_editor/graphql/index.js
new file mode 100644
index 00000000000..0a5d8c07ad9
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import typeDefs from './typedefs.graphql';
+import fileResolver from './resolvers/file';
+import submitContentChangesResolver from './resolvers/submit_content_changes';
+
+Vue.use(VueApollo);
+
+const createApolloProvider = appData => {
+ const defaultClient = createDefaultClient(
+ {
+ Project: {
+ file: fileResolver,
+ },
+ Mutation: {
+ submitContentChanges: submitContentChangesResolver,
+ },
+ },
+ {
+ typeDefs,
+ },
+ );
+
+ defaultClient.cache.writeData({
+ data: {
+ appData: {
+ __typename: 'AppData',
+ ...appData,
+ },
+ },
+ });
+
+ return new VueApollo({
+ defaultClient,
+ });
+};
+
+export default createApolloProvider;
diff --git a/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
new file mode 100644
index 00000000000..2840d419966
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql
@@ -0,0 +1,7 @@
+mutation submitContentChanges($input: SubmitContentChangesInput) {
+ submitContentChanges(input: $input) @client {
+ branch
+ commit
+ mergeRequest
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
new file mode 100644
index 00000000000..fdbf4459aee
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/app_data.query.graphql
@@ -0,0 +1,9 @@
+query appData {
+ appData @client {
+ isSupportedContent
+ project
+ sourcePath
+ username,
+ returnUrl
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
new file mode 100644
index 00000000000..c29b6f93b81
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/saved_content_meta.query.graphql
@@ -0,0 +1,3 @@
+query savedContentMeta {
+ savedContentMeta @client
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
new file mode 100644
index 00000000000..e36d244ae57
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/queries/source_content.query.graphql
@@ -0,0 +1,9 @@
+query sourceContent($project: ID!, $sourcePath: String!) {
+ project(fullPath: $project) {
+ fullPath,
+ file(path: $sourcePath) @client {
+ title
+ content
+ }
+ }
+}
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
new file mode 100644
index 00000000000..16f176581cb
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/file.js
@@ -0,0 +1,11 @@
+import loadSourceContent from '../../services/load_source_content';
+
+const fileResolver = ({ fullPath: projectId }, { path: sourcePath }) => {
+ return loadSourceContent({ projectId, sourcePath }).then(sourceContent => ({
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ __typename: 'File',
+ ...sourceContent,
+ }));
+};
+
+export default fileResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
new file mode 100644
index 00000000000..6c4e3a4d973
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/resolvers/submit_content_changes.js
@@ -0,0 +1,24 @@
+import submitContentChanges from '../../services/submit_content_changes';
+import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
+
+const submitContentChangesResolver = (
+ _,
+ { input: { project: projectId, username, sourcePath, content } },
+ { cache },
+) => {
+ return submitContentChanges({ projectId, username, sourcePath, content }).then(
+ savedContentMeta => {
+ cache.writeQuery({
+ query: savedContentMetaQuery,
+ data: {
+ savedContentMeta: {
+ __typename: 'SavedContentMeta',
+ ...savedContentMeta,
+ },
+ },
+ });
+ },
+ );
+};
+
+export default submitContentChangesResolver;
diff --git a/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
new file mode 100644
index 00000000000..59da2e27144
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/graphql/typedefs.graphql
@@ -0,0 +1,43 @@
+type File {
+ title: String
+ content: String!
+}
+
+type SavedContentField {
+ label: String!
+ url: String!
+}
+
+type SavedContentMeta {
+ mergeRequest: SavedContentField!
+ commit: SavedContentField!
+ branch: SavedContentField!
+}
+
+type AppData {
+ isSupportedContent: Boolean!
+ project: String!
+ returnUrl: String
+ sourcePath: String!
+ username: String!
+}
+
+type SubmitContentChangesInput {
+ project: String!
+ sourcePath: String!
+ content: String!
+ username: String!
+}
+
+extend type Project {
+ file(path: ID!): File
+}
+
+extend type Query {
+ appData: AppData!
+ savedContentMeta: SavedContentMeta
+}
+
+extend type Mutation {
+ submitContentChanges(input: SubmitContentChangesInput!): SavedContentMeta
+}
diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js
index 15d668fd431..12aa301e02f 100644
--- a/app/assets/javascripts/static_site_editor/index.js
+++ b/app/assets/javascripts/static_site_editor/index.js
@@ -1,29 +1,32 @@
import Vue from 'vue';
-import StaticSiteEditor from './components/static_site_editor.vue';
-import createStore from './store';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import App from './components/app.vue';
+import createRouter from './router';
+import createApolloProvider from './graphql';
const initStaticSiteEditor = el => {
- const { projectId, path: sourcePath, returnUrl } = el.dataset;
- const isSupportedContent = 'isSupportedContent' in el.dataset;
+ const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset;
+ const { current_username: username } = window.gon;
+ const returnUrl = el.dataset.returnUrl || null;
- const store = createStore({
- initialState: {
- isSupportedContent,
- projectId,
- returnUrl,
- sourcePath,
- username: window.gon.current_username,
- },
+ const router = createRouter(baseUrl);
+ const apolloProvider = createApolloProvider({
+ isSupportedContent: parseBoolean(isSupportedContent),
+ project: `${namespace}/${project}`,
+ returnUrl,
+ sourcePath,
+ username,
});
return new Vue({
el,
- store,
+ router,
+ apolloProvider,
components: {
- StaticSiteEditor,
+ App,
},
render(createElement) {
- return createElement('static-site-editor', StaticSiteEditor);
+ return createElement('app');
},
});
};
diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue
new file mode 100644
index 00000000000..f65b648acd6
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/pages/home.vue
@@ -0,0 +1,120 @@
+<script>
+import SkeletonLoader from '../components/skeleton_loader.vue';
+import EditArea from '../components/edit_area.vue';
+import InvalidContentMessage from '../components/invalid_content_message.vue';
+import SubmitChangesError from '../components/submit_changes_error.vue';
+import appDataQuery from '../graphql/queries/app_data.query.graphql';
+import sourceContentQuery from '../graphql/queries/source_content.query.graphql';
+import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql';
+import createFlash from '~/flash';
+import { LOAD_CONTENT_ERROR } from '../constants';
+import { SUCCESS_ROUTE } from '../router/constants';
+
+export default {
+ components: {
+ SkeletonLoader,
+ EditArea,
+ InvalidContentMessage,
+ SubmitChangesError,
+ },
+ apollo: {
+ appData: {
+ query: appDataQuery,
+ },
+ sourceContent: {
+ query: sourceContentQuery,
+ update: ({
+ project: {
+ file: { title, content },
+ },
+ }) => {
+ return { title, content };
+ },
+ variables() {
+ return {
+ project: this.appData.project,
+ sourcePath: this.appData.sourcePath,
+ };
+ },
+ skip() {
+ return !this.appData.isSupportedContent;
+ },
+ error() {
+ createFlash(LOAD_CONTENT_ERROR);
+ },
+ },
+ },
+ data() {
+ return {
+ content: null,
+ submitChangesError: null,
+ isSavingChanges: false,
+ };
+ },
+ computed: {
+ isLoadingContent() {
+ return this.$apollo.queries.sourceContent.loading;
+ },
+ isContentLoaded() {
+ return Boolean(this.sourceContent);
+ },
+ },
+ methods: {
+ onDismissError() {
+ this.submitChangesError = null;
+ },
+ onSubmit({ content }) {
+ this.content = content;
+ this.submitChanges();
+ },
+ submitChanges() {
+ this.isSavingChanges = true;
+
+ this.$apollo
+ .mutate({
+ mutation: submitContentChangesMutation,
+ variables: {
+ input: {
+ project: this.appData.project,
+ username: this.appData.username,
+ sourcePath: this.appData.sourcePath,
+ content: this.content,
+ },
+ },
+ })
+ .then(() => {
+ this.$router.push(SUCCESS_ROUTE);
+ })
+ .catch(e => {
+ this.submitChangesError = e.message;
+ })
+ .finally(() => {
+ this.isSavingChanges = false;
+ });
+ },
+ },
+};
+</script>
+<template>
+ <div class="container d-flex gl-flex-direction-column pt-2 h-100">
+ <template v-if="appData.isSupportedContent">
+ <skeleton-loader v-if="isLoadingContent" class="w-75 gl-align-self-center gl-mt-5" />
+ <submit-changes-error
+ v-if="submitChangesError"
+ :error="submitChangesError"
+ @retry="submitChanges"
+ @dismiss="onDismissError"
+ />
+ <edit-area
+ v-if="isContentLoaded"
+ :title="sourceContent.title"
+ :content="sourceContent.content"
+ :saving-changes="isSavingChanges"
+ :return-url="appData.returnUrl"
+ @submit="onSubmit"
+ />
+ </template>
+
+ <invalid-content-message v-else class="w-75" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue
new file mode 100644
index 00000000000..123683b2833
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/pages/success.vue
@@ -0,0 +1,35 @@
+<script>
+import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql';
+import appDataQuery from '../graphql/queries/app_data.query.graphql';
+import SavedChangesMessage from '../components/saved_changes_message.vue';
+import { HOME_ROUTE } from '../router/constants';
+
+export default {
+ components: {
+ SavedChangesMessage,
+ },
+ apollo: {
+ savedContentMeta: {
+ query: savedContentMetaQuery,
+ },
+ appData: {
+ query: appDataQuery,
+ },
+ },
+ created() {
+ if (!this.savedContentMeta) {
+ this.$router.push(HOME_ROUTE);
+ }
+ },
+};
+</script>
+<template>
+ <div v-if="savedContentMeta" class="container">
+ <saved-changes-message
+ :branch="savedContentMeta.branch"
+ :commit="savedContentMeta.commit"
+ :merge-request="savedContentMeta.mergeRequest"
+ :return-url="appData.returnUrl"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/static_site_editor/router/constants.js b/app/assets/javascripts/static_site_editor/router/constants.js
new file mode 100644
index 00000000000..fd715f918ce
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/router/constants.js
@@ -0,0 +1,2 @@
+export const HOME_ROUTE = { name: 'home' };
+export const SUCCESS_ROUTE = { name: 'success' };
diff --git a/app/assets/javascripts/static_site_editor/router/index.js b/app/assets/javascripts/static_site_editor/router/index.js
new file mode 100644
index 00000000000..12692612bbc
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/router/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import routes from './routes';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes,
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/static_site_editor/router/routes.js b/app/assets/javascripts/static_site_editor/router/routes.js
new file mode 100644
index 00000000000..6fb9dbe0182
--- /dev/null
+++ b/app/assets/javascripts/static_site_editor/router/routes.js
@@ -0,0 +1,21 @@
+import Home from '../pages/home.vue';
+import Success from '../pages/success.vue';
+
+import { HOME_ROUTE, SUCCESS_ROUTE } from './constants';
+
+export default [
+ {
+ ...HOME_ROUTE,
+ path: '/',
+ component: Home,
+ },
+ {
+ ...SUCCESS_ROUTE,
+ path: '/success',
+ component: Success,
+ },
+ {
+ path: '*',
+ redirect: HOME_ROUTE,
+ },
+];
diff --git a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
index ff591e4b245..49135d2141b 100644
--- a/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
+++ b/app/assets/javascripts/static_site_editor/services/submit_content_changes.js
@@ -1,4 +1,5 @@
import Api from '~/api';
+import Tracking from '~/tracking';
import { s__, sprintf } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
@@ -8,6 +9,7 @@ import {
SUBMIT_CHANGES_BRANCH_ERROR,
SUBMIT_CHANGES_COMMIT_ERROR,
SUBMIT_CHANGES_MERGE_REQUEST_ERROR,
+ TRACKING_ACTION_CREATE_COMMIT,
} from '../constants';
const createBranch = (projectId, branch) =>
@@ -18,8 +20,10 @@ const createBranch = (projectId, branch) =>
throw new Error(SUBMIT_CHANGES_BRANCH_ERROR);
});
-const commitContent = (projectId, message, branch, sourcePath, content) =>
- Api.commitMultiple(
+const commitContent = (projectId, message, branch, sourcePath, content) => {
+ Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
+
+ return Api.commitMultiple(
projectId,
convertObjectPropsToSnakeCase({
branch,
@@ -35,6 +39,7 @@ const commitContent = (projectId, message, branch, sourcePath, content) =>
).catch(() => {
throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
});
+};
const createMergeRequest = (projectId, title, sourceBranch, targetBranch = DEFAULT_TARGET_BRANCH) =>
Api.createProjectMergeRequest(
@@ -56,8 +61,8 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
const meta = {};
return createBranch(projectId, branch)
- .then(() => {
- Object.assign(meta, { branch: { label: branch } });
+ .then(({ data: { web_url: url } }) => {
+ Object.assign(meta, { branch: { label: branch, url } });
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content);
})
@@ -67,7 +72,7 @@ const submitContentChanges = ({ username, projectId, sourcePath, content }) => {
return createMergeRequest(projectId, mergeRequestTitle, branch);
})
.then(({ data: { iid: label, web_url: url } }) => {
- Object.assign(meta, { mergeRequest: { label, url } });
+ Object.assign(meta, { mergeRequest: { label: label.toString(), url } });
return meta;
});
diff --git a/app/assets/javascripts/static_site_editor/store/actions.js b/app/assets/javascripts/static_site_editor/store/actions.js
deleted file mode 100644
index 9f5e9e8c589..00000000000
--- a/app/assets/javascripts/static_site_editor/store/actions.js
+++ /dev/null
@@ -1,37 +0,0 @@
-import createFlash from '~/flash';
-import { __ } from '~/locale';
-
-import * as mutationTypes from './mutation_types';
-import loadSourceContent from '~/static_site_editor/services/load_source_content';
-import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
-
-export const loadContent = ({ commit, state: { sourcePath, projectId } }) => {
- commit(mutationTypes.LOAD_CONTENT);
-
- return loadSourceContent({ sourcePath, projectId })
- .then(data => commit(mutationTypes.RECEIVE_CONTENT_SUCCESS, data))
- .catch(() => {
- commit(mutationTypes.RECEIVE_CONTENT_ERROR);
- createFlash(__('An error ocurred while loading your content. Please try again.'));
- });
-};
-
-export const setContent = ({ commit }, content) => {
- commit(mutationTypes.SET_CONTENT, content);
-};
-
-export const submitChanges = ({ state: { projectId, content, sourcePath, username }, commit }) => {
- commit(mutationTypes.SUBMIT_CHANGES);
-
- return submitContentChanges({ content, projectId, sourcePath, username })
- .then(data => commit(mutationTypes.SUBMIT_CHANGES_SUCCESS, data))
- .catch(error => {
- commit(mutationTypes.SUBMIT_CHANGES_ERROR, error.message);
- });
-};
-
-export const dismissSubmitChangesError = ({ commit }) => {
- commit(mutationTypes.DISMISS_SUBMIT_CHANGES_ERROR);
-};
-
-export default () => {};
diff --git a/app/assets/javascripts/static_site_editor/store/getters.js b/app/assets/javascripts/static_site_editor/store/getters.js
deleted file mode 100644
index ebc68f8e9e6..00000000000
--- a/app/assets/javascripts/static_site_editor/store/getters.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// eslint-disable-next-line import/prefer-default-export
-export const contentChanged = ({ originalContent, content }) => originalContent !== content;
diff --git a/app/assets/javascripts/static_site_editor/store/mutation_types.js b/app/assets/javascripts/static_site_editor/store/mutation_types.js
deleted file mode 100644
index 9cf356aecc5..00000000000
--- a/app/assets/javascripts/static_site_editor/store/mutation_types.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const LOAD_CONTENT = 'loadContent';
-export const RECEIVE_CONTENT_SUCCESS = 'receiveContentSuccess';
-export const RECEIVE_CONTENT_ERROR = 'receiveContentError';
-export const SET_CONTENT = 'setContent';
-export const SUBMIT_CHANGES = 'submitChanges';
-export const SUBMIT_CHANGES_SUCCESS = 'submitChangesSuccess';
-export const SUBMIT_CHANGES_ERROR = 'submitChangesError';
-export const DISMISS_SUBMIT_CHANGES_ERROR = 'dismissSubmitChangesError';
diff --git a/app/assets/javascripts/static_site_editor/store/mutations.js b/app/assets/javascripts/static_site_editor/store/mutations.js
deleted file mode 100644
index 72fe71f1c9b..00000000000
--- a/app/assets/javascripts/static_site_editor/store/mutations.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.LOAD_CONTENT](state) {
- state.isLoadingContent = true;
- },
- [types.RECEIVE_CONTENT_SUCCESS](state, { title, content }) {
- state.isLoadingContent = false;
- state.isContentLoaded = true;
- state.title = title;
- state.content = content;
- state.originalContent = content;
- },
- [types.RECEIVE_CONTENT_ERROR](state) {
- state.isLoadingContent = false;
- },
- [types.SET_CONTENT](state, content) {
- state.content = content;
- },
- [types.SUBMIT_CHANGES](state) {
- state.isSavingChanges = true;
- state.submitChangesError = '';
- },
- [types.SUBMIT_CHANGES_SUCCESS](state, meta) {
- state.savedContentMeta = meta;
- state.isSavingChanges = false;
- state.originalContent = state.content;
- },
- [types.SUBMIT_CHANGES_ERROR](state, error) {
- state.submitChangesError = error;
- state.isSavingChanges = false;
- },
- [types.DISMISS_SUBMIT_CHANGES_ERROR](state) {
- state.submitChangesError = '';
- },
-};
diff --git a/app/assets/javascripts/static_site_editor/store/state.js b/app/assets/javascripts/static_site_editor/store/state.js
deleted file mode 100644
index 8c524b4ffe9..00000000000
--- a/app/assets/javascripts/static_site_editor/store/state.js
+++ /dev/null
@@ -1,23 +0,0 @@
-const createState = (initialState = {}) => ({
- username: null,
- projectId: null,
- returnUrl: null,
- sourcePath: null,
-
- isLoadingContent: false,
- isSavingChanges: false,
- isSupportedContent: false,
-
- isContentLoaded: false,
-
- originalContent: '',
- content: '',
- title: '',
-
- submitChangesError: '',
- savedContentMeta: null,
-
- ...initialState,
-});
-
-export default createState;
diff --git a/app/assets/javascripts/syntax_highlight.js b/app/assets/javascripts/syntax_highlight.js
index 37f3dd4b496..474b5132bc6 100644
--- a/app/assets/javascripts/syntax_highlight.js
+++ b/app/assets/javascripts/syntax_highlight.js
@@ -1,4 +1,4 @@
-/* eslint-disable consistent-return, no-else-return */
+/* eslint-disable consistent-return */
import $ from 'jquery';
@@ -16,11 +16,10 @@ export default function syntaxHighlight(el) {
if ($(el).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting
return $(el).addClass(gon.user_color_scheme);
- } else {
- // Given a parent element, recurse to any of its applicable children
- const $children = $(el).find('.js-syntax-highlight');
- if ($children.length) {
- return syntaxHighlight($children);
- }
+ }
+ // Given a parent element, recurse to any of its applicable children
+ const $children = $(el).find('.js-syntax-highlight');
+ if ($children.length) {
+ return syntaxHighlight($children);
}
}
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index f4e546e4d4e..cf9064aba57 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -13,14 +13,11 @@ Terminal.applyAddon(webLinks);
export default class GLTerminal {
constructor(element, options = {}) {
- this.options = Object.assign(
- {},
- {
- cursorBlink: true,
- screenKeys: true,
- },
- options,
- );
+ this.options = {
+ cursorBlink: true,
+ screenKeys: true,
+ ...options,
+ };
this.container = element;
this.onDispose = [];
diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js
index 09fe952e5f0..10510595570 100644
--- a/app/assets/javascripts/tracking.js
+++ b/app/assets/javascripts/tracking.js
@@ -1,4 +1,4 @@
-import _ from 'underscore';
+import { omitBy, isUndefined } from 'lodash';
const DEFAULT_SNOWPLOW_OPTIONS = {
namespace: 'gl',
@@ -14,11 +14,8 @@ const DEFAULT_SNOWPLOW_OPTIONS = {
linkClickTracking: false,
};
-const eventHandler = (e, func, opts = {}) => {
- const el = e.target.closest('[data-track-event]');
- const action = el && el.dataset.trackEvent;
- if (!action) return;
-
+const createEventPayload = (el, { suffix = '' } = {}) => {
+ const action = el.dataset.trackEvent + (suffix || '');
let value = el.dataset.trackValue || el.value || undefined;
if (el.type === 'checkbox' && !el.checked) value = false;
@@ -29,7 +26,19 @@ const eventHandler = (e, func, opts = {}) => {
context: el.dataset.trackContext,
};
- func(opts.category, action + (opts.suffix || ''), _.omit(data, _.isUndefined));
+ return {
+ action,
+ data: omitBy(data, isUndefined),
+ };
+};
+
+const eventHandler = (e, func, opts = {}) => {
+ const el = e.target.closest('[data-track-event]');
+
+ if (!el) return;
+
+ const { action, data } = createEventPayload(el, opts);
+ func(opts.category, action, data);
};
const eventHandlers = (category, func) => {
@@ -62,17 +71,30 @@ export default class Tracking {
return window.snowplow('trackStructEvent', category, action, label, property, value, contexts);
}
- static bindDocument(category = document.body.dataset.page, documentOverride = null) {
- const el = documentOverride || document;
- if (!this.enabled() || el.trackingBound) return [];
+ static bindDocument(category = document.body.dataset.page, parent = document) {
+ if (!this.enabled() || parent.trackingBound) return [];
- el.trackingBound = true;
+ // eslint-disable-next-line no-param-reassign
+ parent.trackingBound = true;
const handlers = eventHandlers(category, (...args) => this.event(...args));
- handlers.forEach(event => el.addEventListener(event.name, event.func));
+ handlers.forEach(event => parent.addEventListener(event.name, event.func));
return handlers;
}
+ static trackLoadEvents(category = document.body.dataset.page, parent = document) {
+ if (!this.enabled()) return [];
+
+ const loadEvents = parent.querySelectorAll('[data-track-event="render"]');
+
+ loadEvents.forEach(element => {
+ const { action, data } = createEventPayload(element);
+ this.event(category, action, data);
+ });
+
+ return loadEvents;
+ }
+
static mixin(opts = {}) {
return {
computed: {
@@ -111,6 +133,7 @@ export function initUserTracking() {
if (opts.linkClickTracking) window.snowplow('enableLinkClickTracking');
Tracking.bindDocument();
+ Tracking.trackLoadEvents();
document.dispatchEvent(new Event('SnowplowInitialized'));
}
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 59276ee79d8..947246b2fbb 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, one-var, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -15,9 +15,8 @@ export default class TreeView {
if (e.metaKey || e.which === 2) {
e.preventDefault();
return window.open(path, '_blank');
- } else {
- return visitUrl(path);
}
+ return visitUrl(path);
}
});
// Show the "Loading commit data" for only the first element
diff --git a/app/assets/javascripts/users_select/constants.js b/app/assets/javascripts/users_select/constants.js
new file mode 100644
index 00000000000..64df1e1748c
--- /dev/null
+++ b/app/assets/javascripts/users_select/constants.js
@@ -0,0 +1,18 @@
+export const AJAX_USERS_SELECT_OPTIONS_MAP = {
+ projectId: 'projectId',
+ groupId: 'groupId',
+ showCurrentUser: 'currentUser',
+ authorId: 'authorId',
+ skipUsers: 'skipUsers',
+};
+
+export const AJAX_USERS_SELECT_PARAMS_MAP = {
+ project_id: 'projectId',
+ group_id: 'groupId',
+ skip_ldap: 'skipLdap',
+ todo_filter: 'todoFilter',
+ todo_state_filter: 'todoStateFilter',
+ current_user: 'showCurrentUser',
+ author_id: 'authorId',
+ skip_users: 'skipUsers',
+};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select/index.js
index 6821df57b5a..2dbe5a8171e 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select/index.js
@@ -1,13 +1,18 @@
-/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-else-return, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
+/* eslint-disable func-names, prefer-rest-params, consistent-return, no-shadow, no-self-compare, no-unused-expressions, yoda, prefer-spread, babel/camelcase, no-param-reassign */
/* global Issuable */
/* global emitSidebarEvent */
import $ from 'jquery';
-import _ from 'underscore';
-import axios from './lib/utils/axios_utils';
-import { s__, __, sprintf } from './locale';
-import ModalStore from './boards/stores/modal_store';
-import { parseBoolean } from './lib/utils/common_utils';
+import { escape, template, uniqBy } from 'lodash';
+import axios from '../lib/utils/axios_utils';
+import { s__, __, sprintf } from '../locale';
+import ModalStore from '../boards/stores/modal_store';
+import { parseBoolean } from '../lib/utils/common_utils';
+import {
+ AJAX_USERS_SELECT_OPTIONS_MAP,
+ AJAX_USERS_SELECT_PARAMS_MAP,
+} from 'ee_else_ce/users_select/constants';
+import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils';
// TODO: remove eventHub hack after code splitting refactor
window.emitSidebarEvent = window.emitSidebarEvent || $.noop;
@@ -81,7 +86,7 @@ function UsersSelect(currentUser, els, options = {}) {
const userName = currentUserInfo.name;
const userId = currentUserInfo.id || currentUser.id;
- const inputHtmlString = _.template(`
+ const inputHtmlString = template(`
<input type="hidden" name="<%- fieldName %>"
data-meta="<%- userName %>"
value="<%- userId %>" />
@@ -148,12 +153,11 @@ function UsersSelect(currentUser, els, options = {}) {
name: selectedUser.name,
length: otherSelected.length,
});
- } else {
- return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
- name: firstUser.name,
- length: selectedUsers.length - 1,
- });
}
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: firstUser.name,
+ length: selectedUsers.length - 1,
+ });
};
$('.assign-to-me-link').on('click', e => {
@@ -205,7 +209,7 @@ function UsersSelect(currentUser, els, options = {}) {
username: data.assignee.username,
avatar: data.assignee.avatar_url,
};
- tooltipTitle = _.escape(user.name);
+ tooltipTitle = escape(user.name);
} else {
user = {
name: s__('UsersSelect|Unassigned'),
@@ -219,10 +223,10 @@ function UsersSelect(currentUser, els, options = {}) {
return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
});
};
- collapsedAssigneeTemplate = _.template(
+ collapsedAssigneeTemplate = template(
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
- assigneeTemplate = _.template(
+ 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">
${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
openingTag: '<a href="#" class="js-assign-yourself">',
@@ -248,7 +252,7 @@ function UsersSelect(currentUser, els, options = {}) {
// Potential duplicate entries when dealing with issue board
// because issue board is also managed by vue
- const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ const selectedUsers = uniqBy(selectedInputs, a => a.value)
.filter(input => {
const userId = parseInt(input.value, 10);
const inUsersArray = users.find(u => u.id === userId);
@@ -375,13 +379,11 @@ function UsersSelect(currentUser, els, options = {}) {
$dropdown.find('.dropdown-toggle-text').removeClass('is-default');
if (selected.text) {
return selected.text;
- } else {
- return selected.name;
}
- } else {
- $dropdown.find('.dropdown-toggle-text').addClass('is-default');
- return defaultLabel;
+ return selected.name;
}
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
+ return defaultLabel;
},
defaultLabel,
hidden() {
@@ -543,7 +545,7 @@ function UsersSelect(currentUser, els, options = {}) {
let img = '';
if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${escape(
user.name,
)}</a></li>`;
} else {
@@ -558,13 +560,8 @@ function UsersSelect(currentUser, els, options = {}) {
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
$('.ajax-users-select').each((i, select) => {
- const options = {};
+ const options = getAjaxUsersSelectOptions($(select), AJAX_USERS_SELECT_OPTIONS_MAP);
options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('projectId');
- options.groupId = $(select).data('groupId');
- options.showCurrentUser = $(select).data('currentUser');
- options.authorId = $(select).data('authorId');
- options.skipUsers = $(select).data('skipUsers');
const showNullUser = $(select).data('nullUser');
const showAnyUser = $(select).data('anyUser');
const showEmailUser = $(select).data('emailUser');
@@ -672,10 +669,10 @@ UsersSelect.prototype.formatResult = function(user) {
</div>
<div class='user-info'>
<div class='user-name dropdown-menu-user-full-name'>
- ${_.escape(user.name)}
+ ${escape(user.name)}
</div>
<div class='user-username dropdown-menu-user-username text-secondary'>
- ${!user.invite ? `@${_.escape(user.username)}` : ''}
+ ${!user.invite ? `@${escape(user.username)}` : ''}
</div>
</div>
</div>
@@ -683,7 +680,7 @@ UsersSelect.prototype.formatResult = function(user) {
};
UsersSelect.prototype.formatSelection = function(user) {
- return _.escape(user.name);
+ return escape(user.name);
};
UsersSelect.prototype.user = function(user_id, callback) {
@@ -705,14 +702,7 @@ UsersSelect.prototype.users = function(query, options, callback) {
const params = {
search: query,
active: true,
- project_id: options.projectId || null,
- group_id: options.groupId || null,
- skip_ldap: options.skipLdap || null,
- todo_filter: options.todoFilter || null,
- todo_state_filter: options.todoStateFilter || null,
- current_user: options.showCurrentUser || null,
- author_id: options.authorId || null,
- skip_users: options.skipUsers || null,
+ ...getAjaxUsersSelectParams(options, AJAX_USERS_SELECT_PARAMS_MAP),
};
if (options.issuableType === 'merge_request') {
@@ -746,7 +736,7 @@ UsersSelect.prototype.renderRow = function(issuableType, user, selected, usernam
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name">
- ${_.escape(user.name)}
+ ${escape(user.name)}
</strong>
${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''}
</span>
diff --git a/app/assets/javascripts/users_select/utils.js b/app/assets/javascripts/users_select/utils.js
new file mode 100644
index 00000000000..b46fd15fb77
--- /dev/null
+++ b/app/assets/javascripts/users_select/utils.js
@@ -0,0 +1,27 @@
+/**
+ * Get options from data attributes on passed `$select`.
+ * @param {jQuery} $select
+ * @param {Object} optionsMap e.g. { optionKeyName: 'dataAttributeName' }
+ */
+export const getAjaxUsersSelectOptions = ($select, optionsMap) => {
+ return Object.keys(optionsMap).reduce((accumulator, optionKey) => {
+ const dataKey = optionsMap[optionKey];
+ accumulator[optionKey] = $select.data(dataKey);
+
+ return accumulator;
+ }, {});
+};
+
+/**
+ * Get query parameters used for users request from passed `options` parameter
+ * @param {Object} options e.g. { currentUserId: 1, fooBar: 'baz' }
+ * @param {Object} paramsMap e.g. { user_id: 'currentUserId', foo_bar: 'fooBar' }
+ */
+export const getAjaxUsersSelectParams = (options, paramsMap) => {
+ return Object.keys(paramsMap).reduce((accumulator, paramKey) => {
+ const optionKey = paramsMap[paramKey];
+ accumulator[paramKey] = options[optionKey] || null;
+
+ return accumulator;
+ }, {});
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
index 33db9b87b17..2f922b990d9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue
@@ -75,7 +75,7 @@ export default {
:href="deployment.url"
target="_blank"
rel="noopener noreferrer nofollow"
- class="js-deploy-meta gl-font-size-12"
+ class="js-deploy-meta gl-font-sm"
>
{{ deployment.name }}
</gl-link>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index ba8da46d207..294871ca5c2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -51,7 +51,7 @@ export default {
<div class="mr-widget-extension d-flex align-items-center pl-3">
<div v-if="hasError" class="ci-widget media">
<div class="media-body">
- <span class="gl-font-size-small mr-widget-margin-left gl-line-height-24 js-error-state">{{
+ <span class="gl-font-sm mr-widget-margin-left gl-line-height-24 js-error-state">{{
title
}}</span>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index c38272ab239..2433ba879aa 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
@@ -1,5 +1,5 @@
<script>
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { n__, s__, sprintf } from '~/locale';
import { mergeUrlParams, webIDEUrl } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
@@ -35,7 +35,7 @@ export default {
'mrWidget|The source branch is %{commitsBehindLinkStart}%{commitsBehind}%{commitsBehindLinkEnd} the target branch',
),
{
- commitsBehindLinkStart: `<a href="${esc(this.mr.targetBranchPath)}">`,
+ commitsBehindLinkStart: `<a href="${escape(this.mr.targetBranchPath)}">`,
commitsBehind: n__('%d commit behind', '%d commits behind', this.mr.divergedCommitsCount),
commitsBehindLinkEnd: '</a>',
},
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 42db1935123..6df53311ef0 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
@@ -42,6 +42,10 @@ export default {
type: String,
required: false,
},
+ pipelineMustSucceed: {
+ type: Boolean,
+ required: false,
+ },
sourceBranchLink: {
type: String,
required: false,
@@ -60,7 +64,10 @@ export default {
return this.pipeline && Object.keys(this.pipeline).length > 0;
},
hasCIError() {
- return this.hasCi && !this.ciStatus;
+ return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict;
+ },
+ hasPipelineMustSucceedConflict() {
+ return !this.hasCi && this.pipelineMustSucceed;
},
status() {
return this.pipeline.details && this.pipeline.details.status
@@ -76,9 +83,13 @@ export default {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
},
errorText() {
+ if (this.hasPipelineMustSucceedConflict) {
+ return s__('Pipeline|No pipeline has been run for this commit.');
+ }
+
return sprintf(
s__(
- 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}',
+ 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
),
{
linkStart: `<a href="${this.troubleshootingDocsPath}">`,
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 d81e99d3c09..8fba0e2981f 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
@@ -79,11 +79,12 @@ export default {
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
+ :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
:source-branch-link="branchLink"
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
- <template v-slot:footer>
+ <template #footer>
<div v-if="mr.exposedArtifactsPath" class="js-exposed-artifacts">
<artifacts-app :endpoint="mr.exposedArtifactsPath" />
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
index edf90085a5b..8313b8afb1b 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_terraform_plan.vue
@@ -5,7 +5,6 @@ import axios from '~/lib/utils/axios_utils';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import flash from '~/flash';
import Poll from '~/lib/utils/poll';
-import Visibility from 'visibilityjs';
export default {
name: 'MRWidgetTerraformPlan',
@@ -68,7 +67,11 @@ export default {
method: 'fetchPlans',
successCallback: ({ data }) => {
this.plans = data;
- this.loading = false;
+
+ if (Object.keys(this.plan).length) {
+ this.loading = false;
+ poll.stop();
+ }
},
errorCallback: () => {
this.plans = {};
@@ -77,17 +80,7 @@ export default {
},
});
- if (!Visibility.hidden()) {
- poll.makeRequest();
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden()) {
- poll.restart();
- } else {
- poll.stop();
- }
- });
+ poll.makeRequest();
},
},
};
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 dcf02a29f52..e4f4032776b 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
@@ -1,6 +1,6 @@
<script>
import { GlDeprecatedButton } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, n__, sprintf, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -60,7 +60,7 @@ export default {
{
commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
- targetBranch: `<span class="label-branch">${esc(this.targetBranch)}</span>`,
+ targetBranch: `<span class="label-branch">${escape(this.targetBranch)}</span>`,
},
false,
);
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 a368e29d086..92848e86e76 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
@@ -2,7 +2,7 @@
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
-import MrWidgetAuthor from '../../components/mr_widget_author.vue';
+import MrWidgetAuthor from '../mr_widget_author.vue';
import eventHub from '../../event_hub';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
import { __ } from '~/locale';
@@ -52,7 +52,6 @@ export default {
.then(res => res.data)
.then(data => {
eventHub.$emit('UpdateWidgetData', data);
- eventHub.$emit('MRWidgetUpdateRequested');
})
.catch(() => {
this.isCancellingAutoMerge = false;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
index a5c75369fa1..302a30dab54 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_closed.vue
@@ -1,5 +1,5 @@
<script>
-import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
export default {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 139cbe17e35..d421b744fa1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,6 +1,6 @@
<script>
import $ from 'jquery';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { s__, sprintf } from '~/locale';
import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
import StatusIcon from '../mr_widget_status_icon.vue';
@@ -50,7 +50,7 @@ export default {
content: sprintf(
s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'),
{
- link_start: `<a href="${esc(
+ link_start: `<a href="${escape(
this.mr.conflictsDocsPath,
)}" target="_blank" rel="noopener noreferrer">`,
link_end: '</a>',
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 7279aaf0809..1a6e186a371 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
@@ -5,7 +5,7 @@ import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
import { s__, __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import MrWidgetAuthorTime from '../../components/mr_widget_author_time.vue';
+import MrWidgetAuthorTime from '../mr_widget_author_time.vue';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
index 01a195049ba..f6bfb178437 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_pipeline_tour.vue
@@ -1,5 +1,4 @@
<script>
-import { s__, sprintf } from '~/locale';
import { GlPopover, GlDeprecatedButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import Cookies from 'js-cookie';
@@ -15,18 +14,6 @@ export default {
dismissTrackValue: 20,
showTrackValue: 10,
trackEvent: 'click_button',
- popoverContent: sprintf(
- '%{messageText1}%{lineBreak}%{messageText2}%{lineBreak}%{messageText3}%{lineBreak}%{messageText4}%{lineBreak}%{messageText5}',
- {
- messageText1: s__('mrWidget|Detect issues before deployment with a CI pipeline'),
- messageText2: s__('mrWidget|that continuously tests your code. We created'),
- messageText3: s__("mrWidget|a quick guide that'll show you how to create"),
- messageText4: s__('mrWidget|one. Make your code more secure and more'),
- messageText5: s__('mrWidget|robust in just a minute.'),
- lineBreak: '<br/>',
- },
- false,
- ),
components: {
GlPopover,
GlDeprecatedButton,
@@ -110,7 +97,13 @@ export default {
<div class="svg-content svg-150 pt-1">
<img :src="pipelineSvgPath" />
</div>
- <p v-html="$options.popoverContent"></p>
+ <p>
+ {{
+ s__(
+ 'mrWidget|Detect issues before deployment with a CI pipeline that continuously tests your code. We created a quick guide that will show you how to create one. Make your code more secure and more robust in just a minute.',
+ )
+ }}
+ </p>
<gl-deprecated-button
ref="ok"
category="primary"
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 360a75c3946..82be5eeb5ff 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
@@ -1,6 +1,6 @@
<script>
import { isEmpty } from 'lodash';
-import { GlIcon, GlDeprecatedButton } from '@gitlab/ui';
+import { GlIcon, GlDeprecatedButton, GlSprintf, GlLink } from '@gitlab/ui';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
@@ -26,6 +26,8 @@ export default {
CommitEdit,
CommitMessageDropdown,
GlIcon,
+ GlSprintf,
+ GlLink,
GlDeprecatedButton,
MergeImmediatelyConfirmationDialog: () =>
import(
@@ -56,7 +58,7 @@ export default {
status() {
const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr;
- if (hasCI && !ciStatus) {
+ if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) {
return 'failed';
} else if (this.isAutoMergeAvailable) {
return 'pending';
@@ -97,6 +99,9 @@ export default {
return __('Merge');
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -343,9 +348,19 @@ export default {
/>
</template>
<template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- {{ mergeDisabledText }}
- </span>
+ <div class="bold js-resolve-mr-widget-items-message">
+ <gl-sprintf
+ v-if="hasPipelineMustSucceedConflict"
+ :message="pipelineMustSucceedConflictText"
+ >
+ <template #link="{ content }">
+ <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="mergeDisabledText" />
+ </div>
</template>
</div>
</div>
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 98f682c2e8a..5305894873f 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
@@ -51,7 +51,7 @@ export default {
rel="noopener noreferrer nofollow"
data-container="body"
>
- <icon name="question-o" />
+ <icon name="question" />
</a>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/event_hub.js b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
index 0948c2e5352..e31806ad199 100644
--- a/app/assets/javascripts/vue_merge_request_widget/event_hub.js
+++ b/app/assets/javascripts/vue_merge_request_widget/event_hub.js
@@ -1,3 +1,3 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
-export default new Vue();
+export default createEventHub();
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 32a2b7b83f4..39fa5e465b8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,6 +1,9 @@
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
+export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
+ 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}',
+);
export default {
computed: {
@@ -16,6 +19,9 @@ export default {
mergeDisabledText() {
return MERGE_DISABLED_TEXT;
},
+ pipelineMustSucceedConflictText() {
+ return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
+ },
autoMergeText() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
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 05f73c4cdaf..265ff81f39f 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
@@ -39,6 +39,7 @@ import SourceBranchRemovalStatus from './components/source_branch_removal_status
import TerraformPlan from './components/mr_widget_terraform_plan.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
+import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
export default {
el: '#js-vue-mr-widget',
@@ -76,6 +77,7 @@ export default {
SourceBranchRemovalStatus,
GroupedTestReportsApp,
TerraformPlan,
+ GroupedAccessibilityReportsApp,
},
props: {
mrData: {
@@ -100,8 +102,11 @@ export default {
shouldRenderMergeHelp() {
return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
shouldRenderPipelines() {
- return this.mr.hasCI;
+ return this.mr.hasCI || this.hasPipelineMustSucceedConflict;
},
shouldSuggestPipelines() {
return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
@@ -138,6 +143,9 @@ export default {
mergeError,
});
},
+ shouldShowAccessibilityReport() {
+ return this.mr.accessibilityReportPath;
+ },
},
watch: {
state(newVal, oldVal) {
@@ -380,6 +388,11 @@ export default {
<terraform-plan v-if="mr.terraformReportsPath" :endpoint="mr.terraformReportsPath" />
+ <grouped-accessibility-reports-app
+ v-if="shouldShowAccessibilityReport"
+ :endpoint="mr.accessibilityReportPath"
+ />
+
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
@@ -415,7 +428,9 @@ export default {
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
</div>
</div>
- <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
+ <div v-if="shouldRenderMergeHelp" class="mr-widget-footer">
+ <mr-widget-merge-help />
+ </div>
</div>
<mr-widget-pipeline-container
v-if="shouldRenderMergedPipeline"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index a298331c1fc..a2ee0bc3ca1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -21,7 +21,7 @@ export default function deviseState(data) {
return stateKey.unresolvedDiscussions;
} else if (this.isPipelineBlocked) {
return stateKey.pipelineBlocked;
- } else if (this.isSHAMismatch) {
+ } else if (this.canMerge && this.isSHAMismatch) {
return stateKey.shaMismatch;
} else if (this.autoMergeEnabled) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.autoMergeEnabled;
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 9f001dda540..d61e122d612 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
@@ -103,6 +103,7 @@ export default class MergeRequestStore {
this.ciStatusFaviconPath = pipelineStatus ? pipelineStatus.favicon : null;
this.terraformReportsPath = data.terraform_reports_path;
this.testResultsPath = data.test_reports_path;
+ this.accessibilityReportPath = data.accessibility_report_path;
this.exposedArtifactsPath = data.exposed_artifacts_path;
this.cancelAutoMergePath = data.cancel_auto_merge_path;
this.canCancelAutomaticMerge = Boolean(data.cancel_auto_merge_path);
@@ -123,15 +124,13 @@ export default class MergeRequestStore {
const currentUser = data.current_user;
- if (currentUser) {
- this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
- this.revertInForkPath = currentUser.revert_in_fork_path;
+ 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.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);
}
@@ -162,6 +161,7 @@ export default class MergeRequestStore {
// 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.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path;
this.mergeRequestBasicPath = data.merge_request_basic_path;
this.mergeRequestWidgetPath = data.merge_request_widget_path;
this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path;
diff --git a/app/assets/javascripts/vue_shared/components/awards_list.vue b/app/assets/javascripts/vue_shared/components/awards_list.vue
index 848295cc984..c0a42e08dee 100644
--- a/app/assets/javascripts/vue_shared/components/awards_list.vue
+++ b/app/assets/javascripts/vue_shared/components/awards_list.vue
@@ -34,10 +34,21 @@ export default {
required: false,
default: '',
},
+ defaultAwards: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
},
computed: {
+ groupedDefaultAwards() {
+ return this.defaultAwards.reduce((obj, key) => Object.assign(obj, { [key]: [] }), {});
+ },
groupedAwards() {
- const { thumbsup, thumbsdown, ...rest } = groupBy(this.awards, x => x.name);
+ const { thumbsup, thumbsdown, ...rest } = {
+ ...this.groupedDefaultAwards,
+ ...groupBy(this.awards, x => x.name),
+ };
return [
...(thumbsup ? [this.createAwardList('thumbsup', thumbsup)] : []),
@@ -73,6 +84,10 @@ export default {
};
},
getAwardListTitle(awardsList) {
+ if (!awardsList.length) {
+ return '';
+ }
+
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index afbfb1e0ee2..52ce05f0d99 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -1,8 +1,12 @@
<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import ViewerMixin from './mixins';
import { handleBlobRichViewer } from '~/blob/viewer';
export default {
+ components: {
+ MarkdownFieldView,
+ },
mixins: [ViewerMixin],
mounted() {
handleBlobRichViewer(this.$refs.content, this.type);
@@ -10,5 +14,5 @@ export default {
};
</script>
<template>
- <div ref="content" v-html="content"></div>
+ <markdown-field-view ref="content" v-html="content" />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
index e64c7132117..1eb05780206 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue
@@ -44,7 +44,8 @@ export default {
</script>
<template>
<div
- class="file-content code js-syntax-highlight qa-file-content"
+ class="file-content code js-syntax-highlight"
+ data-qa-selector="file_content"
:class="$options.userColorScheme"
>
<div class="line-numbers">
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index 162cfc02959..890dbe86c0d 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -1,5 +1,5 @@
<script>
-import Icon from '../../vue_shared/components/icon.vue';
+import Icon from './icon.vue';
/**
* Renders CI icon based on API response shared between all places where it is used.
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index d38dd258ce6..0234b6bf848 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -67,6 +67,7 @@ export default {
<template>
<gl-deprecated-button
v-gl-tooltip="{ placement: tooltipPlacement, container: tooltipContainer }"
+ v-gl-tooltip.hover.blur
:class="cssClass"
:title="title"
:data-clipboard-text="clipboardText"
diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
index 7826c179889..ac95c88225e 100644
--- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue
@@ -4,7 +4,6 @@ import {
GlNewDropdownHeader,
GlFormInputGroup,
GlButton,
- GlIcon,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, sprintf } from '~/locale';
@@ -16,7 +15,6 @@ export default {
GlNewDropdownHeader,
GlFormInputGroup,
GlButton,
- GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -59,9 +57,10 @@ export default {
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="sshLink"
- >
- <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-button>
+ data-qa-selector="copy_ssh_url_button"
+ icon="copy-to-clipboard"
+ class="d-inline-flex"
+ />
</template>
</gl-form-input-group>
</div>
@@ -77,9 +76,10 @@ export default {
v-gl-tooltip.hover
:title="$options.copyURLTooltip"
:data-clipboard-text="httpLink"
- >
- <gl-icon name="copy-to-clipboard" :title="$options.copyURLTooltip" />
- </gl-button>
+ data-qa-selector="copy_http_url_button"
+ icon="copy-to-clipboard"
+ class="d-inline-flex"
+ />
</template>
</gl-form-input-group>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/code_block.vue b/app/assets/javascripts/vue_shared/components/code_block.vue
index 3cca7a86bef..1928bf6dac5 100644
--- a/app/assets/javascripts/vue_shared/components/code_block.vue
+++ b/app/assets/javascripts/vue_shared/components/code_block.vue
@@ -6,11 +6,26 @@ export default {
type: String,
required: true,
},
+ maxHeight: {
+ type: String,
+ required: false,
+ default: 'initial',
+ },
+ },
+ computed: {
+ styleObject() {
+ const { maxHeight } = this;
+ const isScrollable = maxHeight !== 'initial';
+ const scrollableStyles = {
+ maxHeight,
+ overflowY: 'auto',
+ };
+
+ return isScrollable ? scrollableStyles : null;
+ },
},
};
</script>
<template>
- <pre class="code-block rounded">
- <code class="d-block">{{ code }}</code>
- </pre>
+ <pre class="code-block rounded" :style="styleObject"><code class="d-block">{{ code }}</code></pre>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index 356f733fb8c..23bea6c28b4 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -4,7 +4,7 @@ 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';
+import Icon from './icon.vue';
export default {
directives: {
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index 2f5e5f35064..fe488ab6cfa 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -24,6 +24,11 @@ export default {
required: false,
default: '',
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: false,
@@ -34,6 +39,11 @@ export default {
required: false,
default: '',
},
+ images: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
computed: {
viewer() {
@@ -62,6 +72,8 @@ export default {
:file-size="fileSize"
:project-path="projectPath"
:content="content"
+ :images="images"
+ :commit-sha="commitSha"
/>
</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 da0b45110e2..b7fa73bc197 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
@@ -26,8 +26,8 @@ const fileExtensionViewers = {
export function viewerInformationForPath(path) {
if (!path) return null;
const name = path.split('/').pop();
- const viewerName =
- fileNameViewers[name] || fileExtensionViewers[name ? name.split('.').pop() : ''] || '';
+ const extension = name.includes('.') && name.split('.').pop();
+ const viewerName = fileNameViewers[name] || fileExtensionViewers[extension];
return viewers[viewerName];
}
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 eb3e489fb8c..1344c766e0e 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
@@ -1,8 +1,11 @@
<script>
import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
import { GlSkeletonLoading } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
+import { forEach, escape } from 'lodash';
const { CancelToken } = axios;
let axiosSource;
@@ -16,6 +19,11 @@ export default {
type: String,
required: true,
},
+ commitSha: {
+ type: String,
+ required: false,
+ default: '',
+ },
filePath: {
type: String,
required: false,
@@ -25,6 +33,11 @@ export default {
type: String,
required: true,
},
+ images: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
},
data() {
return {
@@ -55,6 +68,9 @@ export default {
text: this.content,
path: this.filePath,
};
+ if (this.commitSha) {
+ postBody.ref = this.commitSha;
+ }
const postOptions = {
cancelToken: axiosSource.token,
};
@@ -66,11 +82,19 @@ export default {
postOptions,
)
.then(({ data }) => {
- this.previewContent = data.body;
+ let previewContent = data.body;
+ forEach(this.images, ({ src, title = '', alt }, key) => {
+ previewContent = previewContent.replace(
+ key,
+ `<img src="${escape(src)}" title="${escape(title)}" alt="${escape(alt)}">`,
+ );
+ });
+
+ this.previewContent = previewContent;
this.isLoading = false;
this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
+ $(this.$refs.markdownPreview).renderGFM();
});
})
.catch(() => {
@@ -84,7 +108,7 @@ export default {
</script>
<template>
- <div ref="markdown-preview" class="md-previewer">
+ <div ref="markdownPreview" class="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
<div v-else class="md" v-html="previewContent"></div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
index ffc616d7309..07748482204 100644
--- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue
@@ -169,15 +169,15 @@ export default {
menu-class="date-time-picker-menu"
toggle-class="date-time-picker-toggle text-truncate"
>
- <div class="d-flex justify-content-between gl-p-2">
+ <div class="d-flex justify-content-between gl-p-2-deprecated-no-really-do-not-use-me">
<gl-form-group
v-if="customEnabled"
:label="__('Custom range')"
label-for="custom-from-time"
- label-class="gl-pb-1"
- class="custom-time-range-form-group col-md-7 gl-pl-1 gl-pr-0 m-0"
+ label-class="gl-pb-1-deprecated-no-really-do-not-use-me"
+ class="custom-time-range-form-group col-md-7 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-0 m-0"
>
- <div class="gl-pt-2">
+ <div class="gl-pt-2-deprecated-no-really-do-not-use-me">
<date-time-picker-input
id="custom-time-from"
v-model="startInput"
@@ -198,14 +198,18 @@ export default {
</gl-deprecated-button>
</gl-form-group>
</gl-form-group>
- <gl-form-group label-for="group-id-dropdown" class="col-md-5 gl-pl-1 gl-pr-1 m-0">
+ <gl-form-group
+ label-for="group-id-dropdown"
+ class="col-md-5 gl-pl-1-deprecated-no-really-do-not-use-me gl-pr-1-deprecated-no-really-do-not-use-me m-0"
+ >
<template #label>
- <span class="gl-pl-5">{{ __('Quick range') }}</span>
+ <span class="gl-pl-5-deprecated-no-really-do-not-use-me">{{ __('Quick range') }}</span>
</template>
<gl-dropdown-item
v-for="(option, index) in options"
:key="index"
+ data-qa-selector="quick_range_item"
:active="isOptionActive(option)"
active-class="active"
@click="setQuickRange(option)"
diff --git a/app/assets/javascripts/vue_shared/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 73511879ff2..018e3a84c39 100644
--- a/app/assets/javascripts/vue_shared/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,8 +1,8 @@
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import Icon from '~/vue_shared/components/icon.vue';
-import FileIcon from '../../../vue_shared/components/file_icon.vue';
-import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue';
+import FileIcon from '../file_icon.vue';
+import ChangedFileIcon from '../changed_file_icon.vue';
const MAX_PATH_LENGTH = 60;
diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
index 2f6640232dd..9ecae87c1a9 100644
--- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
+++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js
@@ -493,6 +493,7 @@ const fileNameIcons = {
'.npmignore': 'npm',
'.npmrc': 'npm',
'.yarnrc': 'yarn',
+ '.yarnrc.yml': 'yarn',
'yarn.lock': 'yarn',
'.yarnclean': 'yarn',
'.yarn-integrity': 'yarn',
@@ -575,6 +576,7 @@ const fileNameIcons = {
'.prettierrc.json': 'prettier',
'.prettierrc.yaml': 'prettier',
'.prettierrc.yml': 'prettier',
+ '.prettierignore': 'prettier',
'nodemon.json': 'nodemon',
'.sonarrc': 'sonar',
browserslist: 'browserlist',
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 0a5cc7b693c..0cc96309a92 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -148,19 +148,6 @@ export default {
cursor: pointer;
}
-.file-row:hover,
-.file-row:focus {
- background: #f2f2f2;
-}
-
-.file-row:active {
- background: #dfdfdf;
-}
-
-.file-row.is-active {
- background: #f2f2f2;
-}
-
.file-row-name-container {
display: flex;
width: 100%;
diff --git a/app/assets/javascripts/vue_shared/components/form/title.vue b/app/assets/javascripts/vue_shared/components/form/title.vue
index fad69dc1e24..5d6633fa6d7 100644
--- a/app/assets/javascripts/vue_shared/components/form/title.vue
+++ b/app/assets/javascripts/vue_shared/components/form/title.vue
@@ -6,6 +6,7 @@ export default {
GlFormInput,
GlFormGroup,
},
+ inheritAttrs: false,
};
</script>
<template>
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index bbf293664a6..508f43afe61 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -34,7 +34,7 @@ function createMenuItemTemplate({ original }) {
return `${avatarTag}
${original.username}
- <small class="small font-weight-normal gl-color-inherit">${name}${count}</small>
+ <small class="small font-weight-normal gl-reset-color">${name}${count}</small>
${icon}`;
}
diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue
index 9dd61c8eada..87a995464fa 100644
--- a/app/assets/javascripts/vue_shared/components/identicon.vue
+++ b/app/assets/javascripts/vue_shared/components/identicon.vue
@@ -4,7 +4,7 @@ import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar
export default {
props: {
entityId: {
- type: Number,
+ type: [Number, String],
required: true,
},
entityName: {
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 89a8595fc79..cb3cd18e5a7 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue
@@ -1,11 +1,11 @@
<script>
import { GlLink } from '@gitlab/ui';
-import { escape as esc } from 'lodash';
+import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
-import icon from '../../../vue_shared/components/icon.vue';
+import icon from '../icon.vue';
function buildDocsLinkStart(path) {
- return `<a href="${esc(path)}" target="_blank" rel="noopener noreferrer">`;
+ return `<a href="${escape(path)}" target="_blank" rel="noopener noreferrer">`;
}
export default {
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 5d7e9557aff..4f1b1c758b2 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
@@ -1,9 +1,9 @@
<script>
import '~/commons/bootstrap';
-import { GlTooltip, GlTooltipDirective } from '@gitlab/ui';
+import { GlIcon, GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
-import IssueMilestone from '../../components/issue/issue_milestone.vue';
-import IssueAssignees from '../../components/issue/issue_assignees.vue';
+import IssueMilestone from './issue_milestone.vue';
+import IssueAssignees from './issue_assignees.vue';
import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
import CiIcon from '../ci_icon.vue';
@@ -13,6 +13,7 @@ export default {
IssueMilestone,
IssueAssignees,
CiIcon,
+ GlIcon,
GlTooltip,
},
directives: {
@@ -44,6 +45,9 @@ export default {
visibility: 'hidden',
};
},
+ iconClasses() {
+ return `${this.iconClass} ic-${this.iconName}`;
+ },
},
};
</script>
@@ -54,30 +58,29 @@ export default {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
- class="item-body d-flex align-items-center p-2 p-lg-3 py-xl-2 px-xl-3"
+ class="item-body d-flex align-items-center py-2 px-3"
>
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
<!-- Title area: Status icon (XL) and title -->
- <div class="item-title d-flex align-items-center mb-xl-0">
- <span ref="iconElementXL">
- <icon
+ <div class="item-title d-flex align-items-xl-center mb-xl-0">
+ <div ref="iconElementXL">
+ <gl-icon
v-if="hasState"
ref="iconElementXL"
- :class="iconClass"
+ class="mr-2 d-block"
+ :class="iconClasses"
:name="iconName"
- :size="16"
:title="stateTitle"
:aria-label="state"
/>
- </span>
+ </div>
<gl-tooltip :target="() => $refs.iconElementXL">
<span v-html="stateTitle"></span>
</gl-tooltip>
- <icon
+ <gl-icon
v-if="confidential"
v-gl-tooltip
name="eye-slash"
- :size="16"
:title="__('Confidential')"
class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
@@ -97,17 +100,6 @@ export default {
<div
class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
>
- <span ref="iconElement">
- <icon
- v-if="hasState"
- :class="iconClass"
- :name="iconName"
- :title="stateTitle"
- :aria-label="state"
- data-html="true"
- class="d-xl-none"
- />
- </span>
<gl-tooltip :target="() => this.$refs.iconElement">
<span v-html="stateTitle"></span>
</gl-tooltip>
@@ -159,7 +151,7 @@ export default {
v-gl-tooltip
:disabled="removeDisabled"
type="button"
- class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button mr-xl-0 align-self-xl-center"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button"
data-qa-selector="remove_related_issue_button"
:title="__('Remove')"
:aria-label="__('Remove')"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 26e878d56a0..8007ccb91d5 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 '~/behaviors/markdown/render_gfm';
-import { unescape as unesc } from 'lodash';
+import { unescape } from 'lodash';
import { __, sprintf } from '~/locale';
import { stripHtml } from '~/lib/utils/text_utility';
import Flash from '../../../flash';
@@ -115,7 +115,7 @@ export default {
return text;
}
- return unesc(stripHtml(richText).replace(/\n/g, ''));
+ return unescape(stripHtml(richText).replace(/\n/g, ''));
}
return '';
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
new file mode 100644
index 00000000000..d77123371f2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
@@ -0,0 +1,19 @@
+<script>
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$el).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div><slot></slot></div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
index a4e004c3341..e193883b6e9 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_eventhub.js
@@ -1,9 +1,9 @@
-import Vue from 'vue';
+import createEventHub from '~/helpers/event_hub_factory';
// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
export const callbackName = 'recaptchaDialogCallback';
-export const eventHub = new Vue();
+export const eventHub = createEventHub();
const throwDuplicateCallbackError = () => {
throw new Error(`${callbackName} is already defined!`);
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
new file mode 100644
index 00000000000..457f1806452
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js
@@ -0,0 +1,37 @@
+import { __ } from '~/locale';
+import { generateToolbarItem } from './toolbar_service';
+
+/* eslint-disable @gitlab/require-i18n-strings */
+const TOOLBAR_ITEM_CONFIGS = [
+ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') },
+ { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') },
+ { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') },
+ { icon: 'strikethrough', command: 'Strike', tooltip: __('Add strikethrough text') },
+ { isDivider: true },
+ { icon: 'quote', command: 'Blockquote', tooltip: __('Insert a quote') },
+ { icon: 'link', event: 'openPopupAddLink', tooltip: __('Add a link') },
+ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
+ { isDivider: true },
+ { icon: 'list-bulleted', command: 'UL', tooltip: __('Add a bullet list') },
+ { icon: 'list-numbered', command: 'OL', tooltip: __('Add a numbered list') },
+ { icon: 'list-task', command: 'Task', tooltip: __('Add a task list') },
+ { icon: 'list-indent', command: 'Indent', tooltip: __('Indent') },
+ { icon: 'list-outdent', command: 'Outdent', tooltip: __('Outdent') },
+ { isDivider: true },
+ { icon: 'dash', command: 'HR', tooltip: __('Add a line') },
+ { icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
+ { isDivider: true },
+ { icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
+];
+
+export const EDITOR_OPTIONS = {
+ toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)),
+};
+
+export const EDITOR_TYPES = {
+ wysiwyg: 'wysiwyg',
+};
+
+export const EDITOR_HEIGHT = '100%';
+
+export const EDITOR_PREVIEW_STYLE = 'horizontal';
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
new file mode 100644
index 00000000000..ba3696c8ad1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue
@@ -0,0 +1,65 @@
+<script>
+import 'codemirror/lib/codemirror.css';
+import '@toast-ui/editor/dist/toastui-editor.css';
+
+import { EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE } from './constants';
+
+export default {
+ components: {
+ ToastEditor: () =>
+ import(/* webpackChunkName: 'toast_editor' */ '@toast-ui/vue-editor').then(
+ toast => toast.Editor,
+ ),
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ options: {
+ type: Object,
+ required: false,
+ default: () => EDITOR_OPTIONS,
+ },
+ initialEditType: {
+ type: String,
+ required: false,
+ default: EDITOR_TYPES.wysiwyg,
+ },
+ height: {
+ type: String,
+ required: false,
+ default: EDITOR_HEIGHT,
+ },
+ previewStyle: {
+ type: String,
+ required: false,
+ default: EDITOR_PREVIEW_STYLE,
+ },
+ },
+ computed: {
+ editorOptions() {
+ return { ...EDITOR_OPTIONS, ...this.options };
+ },
+ },
+ methods: {
+ onContentChanged() {
+ this.$emit('input', this.getMarkdown());
+ },
+ getMarkdown() {
+ return this.$refs.editor.invoke('getMarkdown');
+ },
+ },
+};
+</script>
+<template>
+ <toast-editor
+ ref="editor"
+ :initial-value="value"
+ :options="editorOptions"
+ :preview-style="previewStyle"
+ :initial-edit-type="initialEditType"
+ :height="height"
+ @change="onContentChanged"
+ />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
new file mode 100644
index 00000000000..58aaeef45f2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_item.vue
@@ -0,0 +1,20 @@
+<script>
+import { GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ },
+ props: {
+ icon: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <button class="p-0 gl-display-flex toolbar-button">
+ <gl-icon class="gl-mx-auto" :name="icon" />
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
new file mode 100644
index 00000000000..fff90f3e3fb
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/toolbar_service.js
@@ -0,0 +1,32 @@
+import Vue from 'vue';
+import ToolbarItem from './toolbar_item.vue';
+
+const buildWrapper = propsData => {
+ const instance = new Vue({
+ render(createElement) {
+ return createElement(ToolbarItem, propsData);
+ },
+ });
+
+ instance.$mount();
+ return instance.$el;
+};
+
+// eslint-disable-next-line import/prefer-default-export
+export const generateToolbarItem = config => {
+ const { icon, classes, event, command, tooltip, isDivider } = config;
+
+ if (isDivider) {
+ return 'divider';
+ }
+
+ return {
+ type: 'button',
+ options: {
+ el: buildWrapper({ props: { icon }, class: classes }),
+ event,
+ command,
+ tooltip,
+ },
+ };
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index 44cc11a6aaa..5eef439aa90 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -80,11 +80,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
hiddenInputName() {
@@ -136,7 +131,6 @@ export default {
<dropdown-value
:labels="context.labels"
:label-filter-base-path="labelFilterBasePath"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
>
<slot></slot>
@@ -157,7 +151,6 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
/>
<div
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index c3bc61d0053..30f7e6a5980 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -36,11 +36,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
dropdownToggleText() {
@@ -72,7 +67,6 @@ export default {
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
:data-scoped-labels="enableScopedLabels"
- :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
index fe43f77b1ee..71d7069dd57 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -20,11 +20,6 @@ export default {
required: false,
default: false,
},
- scopedLabelsDocumentationLink: {
- type: String,
- required: false,
- default: '#',
- },
},
computed: {
isEmpty() {
@@ -64,7 +59,6 @@ export default {
:title="label.title"
:description="label.description"
:scoped="showScopedLabels(label)"
- :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
</template>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
new file mode 100644
index 00000000000..ab652c9356a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js
@@ -0,0 +1,5 @@
+// eslint-disable-next-line import/prefer-default-export
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
index 55fa1e4ef9c..f45c14f8344 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue
@@ -1,21 +1,35 @@
<script>
-import { mapGetters } from 'vuex';
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+import { GlButton, GlIcon } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
+ GlButton,
GlIcon,
},
computed: {
- ...mapGetters(['dropdownButtonText']),
+ ...mapGetters(['dropdownButtonText', 'isDropdownVariantStandalone']),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ handleButtonClick(e) {
+ if (this.isDropdownVariantStandalone) {
+ this.toggleDropdownContents();
+ e.stopPropagation();
+ }
+ },
},
};
</script>
<template>
- <gl-deprecated-button class="labels-select-dropdown-button w-100 text-left">
- <span class="dropdown-toggle-text">{{ dropdownButtonText }}</span>
+ <gl-button
+ class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
+ @click="handleButtonClick"
+ >
+ <span class="dropdown-toggle-text flex-fill">
+ {{ dropdownButtonText }}
+ </span>
<gl-icon name="chevron-down" class="pull-right" />
- </gl-deprecated-button>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
index 6bb77f6b6f3..ba8d8391952 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue
@@ -1,18 +1,10 @@
<script>
import { mapState, mapActions } from 'vuex';
-import {
- GlTooltipDirective,
- GlDeprecatedButton,
- GlIcon,
- GlFormInput,
- GlLink,
- GlLoadingIcon,
-} from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
GlFormInput,
GlLink,
GlLoadingIcon,
@@ -60,25 +52,23 @@ export default {
<template>
<div class="labels-select-contents-create js-labels-create">
<div class="dropdown-title d-flex align-items-center pt-0 pb-2">
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Go back')"
variant="link"
- size="sm"
+ size="small"
class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
@click="toggleDropdownContentsCreateView"
- >
- <gl-icon name="arrow-left" />
- </gl-deprecated-button>
+ />
<span class="flex-grow-1">{{ labelsCreateTitle }}</span>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Close')"
variant="link"
- size="sm"
+ size="small"
class="dropdown-header-button p-0"
+ icon="close"
@click="toggleDropdownContents"
- >
- <gl-icon name="close" />
- </gl-deprecated-button>
+ />
</div>
<div class="dropdown-input">
<gl-form-input
@@ -107,21 +97,19 @@ export default {
</div>
</div>
<div class="dropdown-actions clearfix pt-2 px-2">
- <gl-deprecated-button
+ <gl-button
:disabled="disableCreate"
- variant="primary"
+ category="primary"
+ variant="success"
class="pull-left d-flex align-items-center"
@click="handleCreateClick"
>
<gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
{{ __('Create') }}
- </gl-deprecated-button>
- <gl-deprecated-button
- class="pull-right js-btn-cancel-create"
- @click="toggleDropdownContentsCreateView"
- >
+ </gl-button>
+ <gl-button class="pull-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
{{ __('Cancel') }}
- </gl-deprecated-button>
+ </gl-button>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
index a8e48bfe1a1..1ef2e8b3bed 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue
@@ -1,16 +1,18 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import { GlLoadingIcon, GlDeprecatedButton, GlIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import LabelItem from './label_item.vue';
+
export default {
components: {
GlLoadingIcon,
- GlDeprecatedButton,
- GlIcon,
+ GlButton,
GlSearchBoxByType,
GlLink,
+ LabelItem,
},
data() {
return {
@@ -20,6 +22,8 @@ export default {
},
computed: {
...mapState([
+ 'allowLabelCreate',
+ 'allowMultiselect',
'labelsManagePath',
'labels',
'labelsFetchInProgress',
@@ -27,7 +31,7 @@ export default {
'footerCreateLabelTitle',
'footerManageLabelTitle',
]),
- ...mapGetters(['selectedLabelsList']),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar']),
visibleLabels() {
if (this.searchKey) {
return this.labels.filter(label =>
@@ -56,12 +60,8 @@ export default {
'toggleDropdownContentsCreateView',
'fetchLabels',
'updateSelectedLabels',
+ 'toggleDropdownContents',
]),
- getDropdownLabelBoxStyle(label) {
- return {
- backgroundColor: label.color,
- };
- },
isLabelSelected(label) {
return this.selectedLabelsList.includes(label.id);
},
@@ -111,6 +111,7 @@ export default {
},
handleLabelClick(label) {
this.updateSelectedLabels([label]);
+ if (!this.allowMultiselect) this.toggleDropdownContents();
},
},
};
@@ -123,54 +124,47 @@ export default {
class="labels-fetch-loading position-absolute d-flex align-items-center w-100 h-100"
size="md"
/>
- <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <div v-if="isDropdownVariantSidebar" class="dropdown-title d-flex align-items-center pt-0 pb-2">
<span class="flex-grow-1">{{ labelsListTitle }}</span>
- <gl-deprecated-button
+ <gl-button
:aria-label="__('Close')"
variant="link"
- size="sm"
+ size="small"
class="dropdown-header-button p-0"
+ icon="close"
@click="toggleDropdownContents"
- >
- <gl-icon name="close" />
- </gl-deprecated-button>
+ />
</div>
- <div class="dropdown-input">
+ <div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type v-model="searchKey" :autofocus="true" />
</div>
- <div v-if="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
+ <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content">
<ul class="list-unstyled mb-0">
<li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left">
- <gl-link
- class="d-flex align-items-baseline text-break-word label-item"
- :class="{ 'is-focused': index === currentHighlightItem }"
- @click="handleLabelClick(label)"
- >
- <gl-icon v-show="label.set" name="mobile-issue-close" class="mr-2 align-self-center" />
- <span v-show="!label.set" class="mr-3 pr-2"></span>
- <span class="dropdown-label-box" :style="getDropdownLabelBoxStyle(label)"></span>
- <span>{{ label.title }}</span>
- </gl-link>
+ <label-item
+ :label="label"
+ :highlight="index === currentHighlightItem"
+ @clickLabel="handleLabelClick(label)"
+ />
</li>
- <li v-if="!visibleLabels.length" class="p-2 text-center">
+ <li v-show="!visibleLabels.length" class="p-2 text-center">
{{ __('No matching results') }}
</li>
</ul>
</div>
- <div class="dropdown-footer">
+ <div v-if="isDropdownVariantSidebar" class="dropdown-footer">
<ul class="list-unstyled">
- <li>
- <gl-deprecated-button
- variant="link"
+ <li v-if="allowLabelCreate">
+ <gl-link
class="d-flex w-100 flex-row text-break-word label-item"
@click="toggleDropdownContentsCreateView"
- >{{ footerCreateLabelTitle }}</gl-deprecated-button
+ >{{ footerCreateLabelTitle }}</gl-link
>
</li>
<li>
- <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">
- {{ footerManageLabelTitle }}
- </gl-link>
+ <gl-link :href="labelsManagePath" class="d-flex flex-row text-break-word label-item">{{
+ footerManageLabelTitle
+ }}</gl-link>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
index 695af775750..12ad2acf308 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue
@@ -9,12 +9,7 @@ export default {
GlLabel,
},
computed: {
- ...mapState([
- 'selectedLabels',
- 'allowScopedLabels',
- 'labelsFilterBasePath',
- 'scopedLabelsDocumentationPath',
- ]),
+ ...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
},
methods: {
labelFilterUrl(label) {
@@ -45,7 +40,6 @@ export default {
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
- :scoped-labels-documentation-link="scopedLabelsDocumentationPath"
tooltip-placement="top"
/>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
new file mode 100644
index 00000000000..c95221d71b5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlIcon, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlIcon,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ highlight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isSet: this.label.set,
+ };
+ },
+ computed: {
+ labelBoxStyle() {
+ return {
+ backgroundColor: this.label.color,
+ };
+ },
+ },
+ methods: {
+ handleClick() {
+ this.isSet = !this.isSet;
+ this.$emit('clickLabel', this.label);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link
+ class="d-flex align-items-baseline text-break-word label-item"
+ :class="{ 'is-focused': highlight }"
+ @click="handleClick"
+ >
+ <gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" />
+ <span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span>
+ <span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span>
+ <span>{{ label.title }}</span>
+ </gl-link>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index 78102caacf5..f38b66fdfdf 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -1,7 +1,7 @@
<script>
import $ from 'jquery';
import Vue from 'vue';
-import Vuex, { mapState, mapActions } from 'vuex';
+import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
@@ -13,6 +13,8 @@ import DropdownValue from './dropdown_value.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
+import { DropdownVariant } from './constants';
+
Vue.use(Vuex);
export default {
@@ -33,14 +35,19 @@ export default {
type: Boolean,
required: true,
},
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
allowScopedLabels: {
type: Boolean,
required: true,
},
- dropdownOnly: {
- type: Boolean,
+ variant: {
+ type: String,
required: false,
- default: false,
+ default: DropdownVariant.Sidebar,
},
selectedLabels: {
type: Array,
@@ -67,11 +74,6 @@ export default {
required: false,
default: '',
},
- scopedLabelsDocumentationPath: {
- type: String,
- required: false,
- default: '',
- },
labelsListTitle: {
type: String,
required: false,
@@ -95,6 +97,10 @@ export default {
},
computed: {
...mapState(['showDropdownButton', 'showDropdownContents']),
+ ...mapGetters(['isDropdownVariantSidebar', 'isDropdownVariantStandalone']),
+ dropdownButtonVisible() {
+ return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
+ },
},
watch: {
selectedLabels(selectedLabels) {
@@ -105,15 +111,15 @@ export default {
},
mounted() {
this.setInitialState({
- dropdownOnly: this.dropdownOnly,
+ variant: this.variant,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
+ allowMultiselect: this.allowMultiselect,
allowScopedLabels: this.allowScopedLabels,
selectedLabels: this.selectedLabels,
labelsFetchPath: this.labelsFetchPath,
labelsManagePath: this.labelsManagePath,
labelsFilterBasePath: this.labelsFilterBasePath,
- scopedLabelsDocumentationPath: this.scopedLabelsDocumentationPath,
labelsListTitle: this.labelsListTitle,
labelsCreateTitle: this.labelsCreateTitle,
footerCreateLabelTitle: this.footerCreateLabelTitle,
@@ -154,13 +160,24 @@ export default {
// as the dropdown wrapper is not using `GlDropdown` as
// it will also require us to use `BDropdownForm`
// which is yet to be implemented in GitLab UI.
+ const hasExceptionClass = [
+ 'js-dropdown-button',
+ 'js-btn-cancel-create',
+ 'js-sidebar-dropdown-toggle',
+ ].some(
+ className =>
+ target?.classList.contains(className) ||
+ target?.parentElement.classList.contains(className),
+ );
+
+ const hadExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ className => $(target).parents(className).length,
+ );
+
if (
- this.showDropdownButton &&
this.showDropdownContents &&
- !$(target).parents('.js-btn-back').length &&
- !$(target).parents('.js-labels-list').length &&
- !target?.classList.contains('js-btn-cancel-create') &&
- !target?.classList.contains('js-sidebar-dropdown-toggle') &&
+ !hadExceptionParent &&
+ !hasExceptionClass &&
!this.$refs.dropdownButtonCollapsed?.$el.contains(target) &&
!this.$refs.dropdownContents?.$el.contains(target)
) {
@@ -181,10 +198,12 @@ export default {
</script>
<template>
- <div class="labels-select-wrapper position-relative">
- <div v-if="!dropdownOnly">
+ <div
+ class="labels-select-wrapper position-relative"
+ :class="{ 'is-standalone': isDropdownVariantStandalone }"
+ >
+ <template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
- v-if="allowLabelCreate"
ref="dropdownButtonCollapsed"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
@@ -196,8 +215,18 @@ export default {
<dropdown-value v-show="!showDropdownButton">
<slot></slot>
</dropdown-value>
- <dropdown-button v-show="showDropdownButton" />
- <dropdown-contents v-if="showDropdownButton && showDropdownContents" ref="dropdownContents" />
- </div>
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ />
+ </template>
+ <template v-if="isDropdownVariantStandalone">
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
index c08a8a8ea58..c39222959a9 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js
@@ -1,4 +1,5 @@
import { __, s__, sprintf } from '~/locale';
+import { DropdownVariant } from '../constants';
/**
* Returns string representing current labels
@@ -6,8 +7,11 @@ import { __, s__, sprintf } from '~/locale';
*
* @param {object} state
*/
-export const dropdownButtonText = state => {
- const selectedLabels = state.labels.filter(label => label.set);
+export const dropdownButtonText = (state, getters) => {
+ const selectedLabels = getters.isDropdownVariantSidebar
+ ? state.labels.filter(label => label.set)
+ : state.selectedLabels;
+
if (!selectedLabels.length) {
return __('Label');
} else if (selectedLabels.length > 1) {
@@ -26,5 +30,19 @@ export const dropdownButtonText = state => {
*/
export const selectedLabelsList = state => state.selectedLabels.map(label => label.id);
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {object} state
+ */
+export const isDropdownVariantSidebar = state => state.variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {object} state
+ */
+export const isDropdownVariantStandalone = state => state.variant === DropdownVariant.Standalone;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
index 32a78507e88..54f8c78b4e1 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/mutations.js
@@ -1,4 +1,5 @@
import * as types from './mutation_types';
+import { DropdownVariant } from '../constants';
export default {
[types.SET_INITIAL_STATE](state, props) {
@@ -10,7 +11,7 @@ export default {
},
[types.TOGGLE_DROPDOWN_CONTENTS](state) {
- if (!state.dropdownOnly) {
+ if (state.variant === DropdownVariant.Sidebar) {
state.showDropdownButton = !state.showDropdownButton;
}
state.showDropdownContents = !state.showDropdownContents;
@@ -57,20 +58,13 @@ export default {
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
- // Iterate over all the labels and update
- // `set` prop value to represent their current state.
- const labelIds = labels.map(label => label.id);
- state.labels = state.labels.reduce((allLabels, label) => {
- if (labelIds.includes(label.id)) {
- allLabels.push({
- ...label,
- touched: true,
- set: !label.set,
- });
- } else {
- allLabels.push(label);
- }
- return allLabels;
- }, []);
+ // Find the label to update from all the labels
+ // and change `set` prop value to represent their current state.
+ const labelId = labels.pop()?.id;
+ const candidateLabel = state.labels.find(label => labelId === label.id);
+ if (candidateLabel) {
+ candidateLabel.touched = true;
+ candidateLabel.set = !candidateLabel.set;
+ }
},
};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
index ceabc696693..6a6c0b4c0ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/state.js
@@ -11,13 +11,13 @@ export default () => ({
namespace: '',
labelsFetchPath: '',
labelsFilterBasePath: '',
- scopedLabelsDocumentationPath: '#',
// UI Flags
+ variant: '',
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
- dropdownOnly: false,
+ allowMultiselect: false,
showDropdownButton: false,
showDropdownContents: false,
showDropdownContentsCreateView: false,
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue b/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue
deleted file mode 100644
index 527cbd458e2..00000000000
--- a/app/assets/javascripts/vue_shared/components/user_avatar/badges/gitlab_team_member_badge.vue
+++ /dev/null
@@ -1,27 +0,0 @@
-<script>
-import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-const GITLAB_TEAM_MEMBER_LABEL = __('GitLab Team Member');
-
-export default {
- name: 'GitlabTeamMemberBadge',
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- components: { GlIcon },
- gitlabTeamMemberLabel: GITLAB_TEAM_MEMBER_LABEL,
-};
-</script>
-
-<template>
- <span
- v-gl-tooltip.hover
- :title="$options.gitlabTeamMemberLabel"
- role="img"
- :aria-label="$options.gitlabTeamMemberLabel"
- class="d-inline-block align-middle"
- >
- <gl-icon name="tanuki-verified" class="gl-text-purple d-block" />
- </span>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
deleted file mode 100644
index 7ed4da84120..00000000000
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_svg.vue
+++ /dev/null
@@ -1,38 +0,0 @@
-<script>
-/* This is a re-usable vue component for rendering a user avatar svg (typically
- for a blank state). It will receive styles comparable to the user avatar,
- but no image is loaded, it isn't wrapped in a link, and tooltips aren't supported.
- The svg and avatar size can be configured by props passed to this component.
-
- Sample configuration:
-
- <user-avatar-svg
- :svg="potentialApproverSvg"
- :size="20"
- />
-
-*/
-
-export default {
- props: {
- svg: {
- type: String,
- required: true,
- },
- size: {
- type: Number,
- required: false,
- default: 20,
- },
- },
- computed: {
- avatarSizeClass() {
- return `s${this.size}`;
- },
- },
-};
-</script>
-
-<template>
- <svg :class="avatarSizeClass" :height="size" :width="size" v-html="svg" />
-</template>
diff --git a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
index f9e3f3df0cc..c93b3d37a63 100644
--- a/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/ci_pagination_api_mixin.js
@@ -9,21 +9,46 @@ import { historyPushState, buildUrlWithCurrentLocation } from '../../lib/utils/c
export default {
methods: {
onChangeTab(scope) {
- this.updateContent({ scope, page: '1' });
+ let params = {
+ scope,
+ page: '1',
+ };
+
+ params = this.onChangeWithFilter(params);
+
+ this.updateContent(params);
},
onChangePage(page) {
/* URLS parameters are strings, we need to parse to match types */
- const params = {
+ let params = {
page: Number(page).toString(),
};
if (this.scope) {
params.scope = this.scope;
}
+
+ params = this.onChangeWithFilter(params);
+
this.updateContent(params);
},
+ onChangeWithFilter(params) {
+ const { username, ref } = this.requestData;
+ const paramsData = params;
+
+ if (username) {
+ paramsData.username = username;
+ }
+
+ if (ref) {
+ paramsData.ref = ref;
+ }
+
+ return paramsData;
+ },
+
updateInternalState(parameters) {
// stop polling
this.poll.stop();