summaryrefslogtreecommitdiff
path: root/app/assets/javascripts
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts')
-rw-r--r--app/assets/javascripts/api.js25
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js40
-rw-r--r--app/assets/javascripts/blob/file_template_mediator.js6
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue3
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue4
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue4
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue3
-rw-r--r--app/assets/javascripts/boards/index.js4
-rw-r--r--app/assets/javascripts/boards/models/list.js4
-rw-r--r--app/assets/javascripts/boards/stores/getters.js3
-rw-r--r--app/assets/javascripts/boards/stores/index.js6
-rw-r--r--app/assets/javascripts/boards/stores/state.js2
-rw-r--r--app/assets/javascripts/boards/toggle_labels.js1
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js52
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue191
-rw-r--r--app/assets/javascripts/clusters/components/crossplane_provider_stack.vue93
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue12
-rw-r--r--app/assets/javascripts/clusters/constants.js13
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js2
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js43
-rw-r--r--app/assets/javascripts/commit/image_file.js254
-rw-r--r--app/assets/javascripts/compare_autocomplete.js18
-rw-r--r--app/assets/javascripts/confidential_merge_request/components/project_form_group.vue2
-rw-r--r--app/assets/javascripts/contributors/components/contributors.vue227
-rw-r--r--app/assets/javascripts/contributors/index.js23
-rw-r--r--app/assets/javascripts/contributors/services/contributors_service.js7
-rw-r--r--app/assets/javascripts/contributors/stores/actions.js20
-rw-r--r--app/assets/javascripts/contributors/stores/getters.js33
-rw-r--r--app/assets/javascripts/contributors/stores/index.js18
-rw-r--r--app/assets/javascripts/contributors/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/contributors/stores/mutations.js17
-rw-r--r--app/assets/javascripts/contributors/stores/state.js5
-rw-r--r--app/assets/javascripts/contributors/utils.js30
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue99
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue24
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue182
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue63
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue140
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/constants.js7
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/index.js45
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js134
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/actions.js84
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js15
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js32
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/state.js19
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js (renamed from app/assets/javascripts/projects/gke_cluster_namespace/index.js)0
-rw-r--r--app/assets/javascripts/create_cluster/init_create_cluster.js37
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue44
-rw-r--r--app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue36
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue13
-rw-r--r--app/assets/javascripts/dropzone_input.js2
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue141
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue56
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace.vue33
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue110
-rw-r--r--app/assets/javascripts/error_tracking/details.js25
-rw-r--r--app/assets/javascripts/error_tracking/list.js (renamed from app/assets/javascripts/error_tracking/index.js)0
-rw-r--r--app/assets/javascripts/error_tracking/services/index.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/details/actions.js63
-rw-r--r--app/assets/javascripts/error_tracking/store/details/getters.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/details/mutation_types.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/details/mutations.js16
-rw-r--r--app/assets/javascripts/error_tracking/store/details/state.js6
-rw-r--r--app/assets/javascripts/error_tracking/store/index.js33
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js (renamed from app/assets/javascripts/error_tracking/store/actions.js)4
-rw-r--r--app/assets/javascripts/error_tracking/store/list/getters.js4
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutation_types.js (renamed from app/assets/javascripts/error_tracking/store/mutation_types.js)0
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js (renamed from app/assets/javascripts/error_tracking/store/mutations.js)0
-rw-r--r--app/assets/javascripts/error_tracking/store/list/state.js5
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue31
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue45
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js3
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js3
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/state.js1
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js19
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js11
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js1
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js44
-rw-r--r--app/assets/javascripts/flash.js2
-rw-r--r--app/assets/javascripts/frequent_items/store/mutations.js3
-rw-r--r--app/assets/javascripts/gl_dropdown.js4
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue103
-rw-r--r--app/assets/javascripts/grafana_integration/index.js14
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js42
-rw-r--r--app/assets/javascripts/grafana_integration/store/index.js16
-rw-r--r--app/assets/javascripts/grafana_integration/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/grafana_integration/store/mutations.js13
-rw-r--r--app/assets/javascripts/grafana_integration/store/state.js8
-rw-r--r--app/assets/javascripts/group.js24
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js44
-rw-r--r--app/assets/javascripts/ide/components/jobs/stage.vue3
-rw-r--r--app/assets/javascripts/ide/components/preview/clientside.vue3
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue1
-rw-r--r--app/assets/javascripts/ide/services/index.js31
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js27
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js16
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js7
-rw-r--r--app/assets/javascripts/ide/stores/getters.js12
-rw-r--r--app/assets/javascripts/ide/stores/index.js2
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/actions.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/clientside/index.js6
-rw-r--r--app/assets/javascripts/ide/stores/utils.js9
-rw-r--r--app/assets/javascripts/issuable_bulk_update_sidebar.js19
-rw-r--r--app/assets/javascripts/issuables_list/components/issuable.vue335
-rw-r--r--app/assets/javascripts/issuables_list/components/issuables_list_app.vue277
-rw-r--r--app/assets/javascripts/issuables_list/constants.js33
-rw-r--r--app/assets/javascripts/issuables_list/eventhub.js5
-rw-r--r--app/assets/javascripts/issuables_list/index.js24
-rw-r--r--app/assets/javascripts/issue.js15
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue26
-rw-r--r--app/assets/javascripts/jobs/store/utils.js2
-rw-r--r--app/assets/javascripts/labels_select.js161
-rw-r--r--app/assets/javascripts/lib/graphql.js6
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js17
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js58
-rw-r--r--app/assets/javascripts/lib/utils/notify.js8
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js33
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js23
-rw-r--r--app/assets/javascripts/lib/utils/tick_formats.js39
-rw-r--r--app/assets/javascripts/line_highlighter.js17
-rw-r--r--app/assets/javascripts/main.js20
-rw-r--r--app/assets/javascripts/manual_ordering.js6
-rw-r--r--app/assets/javascripts/merge_request.js26
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue227
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue73
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue96
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue232
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue28
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue7
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue27
-rw-r--r--app/assets/javascripts/monitoring/components/shared/prometheus_header.vue15
-rw-r--r--app/assets/javascripts/monitoring/constants.js15
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js7
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js54
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js38
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js6
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js6
-rw-r--r--app/assets/javascripts/monitoring/utils.js19
-rw-r--r--app/assets/javascripts/network/branch_graph.js193
-rw-r--r--app/assets/javascripts/new_branch_form.js42
-rw-r--r--app/assets/javascripts/new_commit_form.js5
-rw-r--r--app/assets/javascripts/notes.js100
-rw-r--r--app/assets/javascripts/notes/components/diff_discussion_header.vue133
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue111
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue13
-rw-r--r--app/assets/javascripts/notes/mixins/description_version_history.js12
-rw-r--r--app/assets/javascripts/notes/stores/actions.js16
-rw-r--r--app/assets/javascripts/notes/stores/collapse_utils.js44
-rw-r--r--app/assets/javascripts/pages/admin/abuse_reports/index.js7
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index.js20
-rw-r--r--app/assets/javascripts/pages/groups/index.js20
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js7
-rw-r--r--app/assets/javascripts/pages/groups/new/group_path_validator.js91
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/clusters/new/index.js13
-rw-r--r--app/assets/javascripts/pages/projects/clusters/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/commit/show/index.js1
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/details/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/index.js26
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js140
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js379
-rw-r--r--app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js143
-rw-r--r--app/assets/javascripts/pages/projects/index.js19
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js2
-rw-r--r--app/assets/javascripts/pages/projects/network/network.js31
-rw-r--r--app/assets/javascripts/pages/projects/pages_domains/form.js20
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/test_report/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/project.js5
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue83
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js2
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js14
-rw-r--r--app/assets/javascripts/performance_bar/components/add_request.vue48
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue3
-rw-r--r--app/assets/javascripts/performance_bar/index.js14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue6
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_reports.vue81
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue108
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue116
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue129
-rw-r--r--app/assets/javascripts/pipelines/constants.js6
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js25
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/actions.js30
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/getters.js23
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/index.js15
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js4
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/mutations.js19
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/state.js6
-rw-r--r--app/assets/javascripts/pipelines/stores/test_reports/utils.js36
-rw-r--r--app/assets/javascripts/privacy_policy_update_callout.js8
-rw-r--r--app/assets/javascripts/profile/gl_crop.js29
-rw-r--r--app/assets/javascripts/project_find_file.js49
-rw-r--r--app/assets/javascripts/project_select.js109
-rw-r--r--app/assets/javascripts/projects/project_new.js4
-rw-r--r--app/assets/javascripts/registry/components/collapsible_container.vue33
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue95
-rw-r--r--app/assets/javascripts/registry/constants.js19
-rw-r--r--app/assets/javascripts/registry/stores/actions.js6
-rw-r--r--app/assets/javascripts/registry/stores/mutations.js32
-rw-r--r--app/assets/javascripts/releases/detail/components/app.vue46
-rw-r--r--app/assets/javascripts/releases/detail/index.js2
-rw-r--r--app/assets/javascripts/releases/detail/store/state.js1
-rw-r--r--app/assets/javascripts/releases/list/components/release_block.vue22
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_footer.vue112
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue2
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue1
-rw-r--r--app/assets/javascripts/repository/components/directory_download_links.vue47
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue58
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue49
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue109
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue41
-rw-r--r--app/assets/javascripts/repository/components/tree_action_link.vue28
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue115
-rw-r--r--app/assets/javascripts/repository/graphql.js6
-rw-r--r--app/assets/javascripts/repository/index.js84
-rw-r--r--app/assets/javascripts/repository/log_tree.js22
-rw-r--r--app/assets/javascripts/repository/pages/index.vue21
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue24
-rw-r--r--app/assets/javascripts/repository/queries/commit.fragment.graphql8
-rw-r--r--app/assets/javascripts/repository/queries/getCommit.query.graphql9
-rw-r--r--app/assets/javascripts/repository/queries/getCommits.query.graphql9
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.query.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/getReadme.query.graphql5
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql21
-rw-r--r--app/assets/javascripts/repository/router.js2
-rw-r--r--app/assets/javascripts/repository/utils/commit.js13
-rw-r--r--app/assets/javascripts/repository/utils/dom.js4
-rw-r--r--app/assets/javascripts/repository/utils/readme.js21
-rw-r--r--app/assets/javascripts/repository/utils/title.js8
-rw-r--r--app/assets/javascripts/right_sidebar.js52
-rw-r--r--app/assets/javascripts/search_autocomplete.js82
-rw-r--r--app/assets/javascripts/sentry/index.js (renamed from app/assets/javascripts/raven/index.js)8
-rw-r--r--app/assets/javascripts/sentry/sentry_config.js (renamed from app/assets/javascripts/raven/raven_config.js)60
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue26
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js4
-rw-r--r--app/assets/javascripts/sourcegraph/index.js28
-rw-r--r--app/assets/javascripts/sourcegraph/load.js6
-rw-r--r--app/assets/javascripts/tree.js14
-rw-r--r--app/assets/javascripts/user_popovers.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/icon.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue51
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue29
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/slot_switch.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue76
-rw-r--r--app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue4
-rw-r--r--app/assets/javascripts/zen_mode.js32
271 files changed, 6597 insertions, 2930 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 908dc730aa4..aee9990bc0b 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -2,6 +2,8 @@ import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import { joinPaths } from './lib/utils/url_utility';
+import flash from '~/flash';
+import { __ } from '~/locale';
const Api = {
groupsPath: '/api/:version/groups.json',
@@ -29,6 +31,7 @@ const Api = {
usersPath: '/api/:version/users.json',
userPath: '/api/:version/users/:id',
userStatusPath: '/api/:version/users/:id/status',
+ userProjectsPath: '/api/:version/users/:id/projects',
userPostStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
applySuggestionPath: '/api/:version/suggestions/:id/apply',
@@ -110,10 +113,9 @@ const Api = {
.get(url, {
params: Object.assign(defaults, options),
})
- .then(({ data }) => {
+ .then(({ data, headers }) => {
callback(data);
-
- return data;
+ return { data, headers };
});
},
@@ -239,7 +241,8 @@ const Api = {
.get(url, {
params: Object.assign({}, defaults, options),
})
- .then(({ data }) => callback(data));
+ .then(({ data }) => callback(data))
+ .catch(() => flash(__('Something went wrong while fetching projects')));
},
commitMultiple(id, data) {
@@ -348,6 +351,20 @@ const Api = {
});
},
+ userProjects(userId, query, options, callback) {
+ const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId);
+ const defaults = {
+ search: query,
+ per_page: 20,
+ };
+ return axios
+ .get(url, {
+ params: Object.assign({}, defaults, options),
+ })
+ .then(({ data }) => callback(data))
+ .catch(() => flash(__('Something went wrong while fetching projects')));
+ },
+
branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index a07942d87cb..ca91400eac7 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var */
+/* eslint-disable func-names */
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
@@ -12,11 +12,8 @@ import { __ } from '~/locale';
// more than `x` users are referenced.
//
-var lastTextareaPreviewed;
-var lastTextareaHeight = null;
-var markdownPreview;
-var previewButtonSelector;
-var writeButtonSelector;
+let lastTextareaHeight;
+let lastTextareaPreviewed;
function MarkdownPreview() {}
@@ -27,14 +24,13 @@ MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.');
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function($form) {
- var mdText;
- var preview = $form.find('.js-md-preview');
- var url = preview.data('url');
+ const preview = $form.find('.js-md-preview');
+ const url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
- mdText = $form.find('textarea.markdown-area').val();
+ const mdText = $form.find('textarea.markdown-area').val();
if (mdText === undefined) {
return;
@@ -46,7 +42,7 @@ MarkdownPreview.prototype.showPreview = function($form) {
} else {
preview.addClass('md-preview-loading').text(__('Loading...'));
this.fetchMarkdownPreview(mdText, url, response => {
- var body;
+ let body;
if (response.body.length > 0) {
({ body } = response);
} else {
@@ -91,8 +87,7 @@ MarkdownPreview.prototype.hideReferencedUsers = function($form) {
};
MarkdownPreview.prototype.renderReferencedUsers = function(users, $form) {
- var referencedUsers;
- referencedUsers = $form.find('.referenced-users');
+ const referencedUsers = $form.find('.referenced-users');
if (referencedUsers.length) {
if (users.length >= this.referenceThreshold) {
referencedUsers.show();
@@ -108,8 +103,7 @@ MarkdownPreview.prototype.hideReferencedCommands = function($form) {
};
MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) {
- var referencedCommands;
- referencedCommands = $form.find('.referenced-commands');
+ const referencedCommands = $form.find('.referenced-commands');
if (commands.length > 0) {
referencedCommands.html(commands);
referencedCommands.show();
@@ -119,15 +113,15 @@ MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) {
}
};
-markdownPreview = new MarkdownPreview();
+const markdownPreview = new MarkdownPreview();
-previewButtonSelector = '.js-md-preview-button';
-writeButtonSelector = '.js-md-write-button';
+const previewButtonSelector = '.js-md-preview-button';
+const writeButtonSelector = '.js-md-write-button';
lastTextareaPreviewed = null;
const markdownToolbar = $('.md-header-toolbar');
$.fn.setupMarkdownPreview = function() {
- var $form = $(this);
+ const $form = $(this);
$form.find('textarea.markdown-area').on('input', () => {
markdownPreview.hideReferencedUsers($form);
});
@@ -188,7 +182,7 @@ $(document).on('markdown-preview:hide', (e, $form) => {
});
$(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
- var $target;
+ let $target;
$target = $(keyboardEvent.target);
if ($target.is('textarea.markdown-area')) {
$(document).triggerHandler('markdown-preview:show', [$target.closest('form')]);
@@ -201,16 +195,14 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => {
});
$(document).on('click', previewButtonSelector, function(e) {
- var $form;
e.preventDefault();
- $form = $(this).closest('form');
+ const $form = $(this).closest('form');
$(document).triggerHandler('markdown-preview:show', [$form]);
});
$(document).on('click', writeButtonSelector, function(e) {
- var $form;
e.preventDefault();
- $form = $(this).closest('form');
+ const $form = $(this).closest('form');
$(document).triggerHandler('markdown-preview:hide', [$form]);
});
diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js
index b371f6be268..aedd8004ea5 100644
--- a/app/assets/javascripts/blob/file_template_mediator.js
+++ b/app/assets/javascripts/blob/file_template_mediator.js
@@ -118,8 +118,6 @@ export default class FileTemplateMediator {
}
});
- this.setFilename(item.name);
-
if (this.editor.getValue() !== '') {
this.setTypeSelectorToggleText(item.name);
}
@@ -133,14 +131,16 @@ export default class FileTemplateMediator {
selectTemplateFile(selector, query, data) {
const self = this;
+ const { name } = selector.config;
selector.renderLoading();
this.fetchFileTemplate(selector.config.type, query, data)
.then(file => {
this.setEditorContent(file);
+ this.setFilename(name);
selector.renderLoaded();
- this.typeSelector.setToggleText(selector.config.name);
+ this.typeSelector.setToggleText(name);
toast(__(`${query} template applied`), {
action: {
text: __('Undo'),
diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue
index 34560560756..c0df8b72095 100644
--- a/app/assets/javascripts/boards/components/board_form.vue
+++ b/app/assets/javascripts/boards/components/board_form.vue
@@ -133,7 +133,7 @@ export default {
if (this.board.name.length === 0) return;
this.isLoading = true;
if (this.isDeleteForm) {
- gl.boardService
+ boardsStore
.deleteBoard(this.currentBoard)
.then(() => {
visitUrl(boardsStore.rootPath);
@@ -143,7 +143,7 @@ export default {
this.isLoading = false;
});
} else {
- gl.boardService
+ boardsStore
.createBoard(this.board)
.then(resp => resp.data)
.then(data => {
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 1273fcc6a91..b8439bc8741 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -84,7 +84,8 @@ export default {
this.$nextTick(() => {
if (
this.scrollHeight() <= this.listHeight() &&
- this.list.issuesSize > this.list.issues.length
+ this.list.issuesSize > this.list.issues.length &&
+ this.list.isExpanded
) {
this.list.page += 1;
this.list.getIssues(false).catch(() => {
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 334c162954e..32491dfbcb6 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -168,7 +168,7 @@ export default {
}
const recentBoardsPromise = new Promise((resolve, reject) =>
- gl.boardService
+ boardsStore
.recentBoards()
.then(resolve)
.catch(err => {
@@ -184,7 +184,7 @@ export default {
}),
);
- Promise.all([gl.boardService.allBoards(), recentBoardsPromise])
+ Promise.all([boardsStore.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false;
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 40d75d53f75..d37e49bab46 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -1,5 +1,6 @@
<script>
import _ from 'underscore';
+import { mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -63,6 +64,7 @@ export default {
};
},
computed: {
+ ...mapState(['isShowingLabels']),
numberOverLimit() {
return this.issue.assignees.length - this.limitBeforeCounter;
},
@@ -92,7 +94,7 @@ export default {
return false;
},
showLabelFooter() {
- return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
+ return this.isShowingLabels && this.issue.labels.find(this.showLabel);
},
issueReferencePath() {
const { referencePath, groupId } = this.issue;
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index defa1f75ba2..618c2ada1f8 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -1,6 +1,7 @@
<script>
/* global ListIssue */
import { urlParamsToObject } from '~/lib/utils/common_utils';
+import boardsStore from '~/boards/stores/boards_store';
import ModalHeader from './header.vue';
import ModalList from './list.vue';
import ModalFooter from './footer.vue';
@@ -109,7 +110,7 @@ export default {
loadIssues(clearIssues = false) {
if (!this.showAddIssuesModal) return false;
- return gl.boardService
+ return boardsStore
.getBacklog({
...urlParamsToObject(this.filter.path),
page: this.page,
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index befca70eeae..e76e2341dfd 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -13,6 +13,7 @@ import 'ee_else_ce/boards/models/issue';
import 'ee_else_ce/boards/models/list';
import '~/boards/models/milestone';
import '~/boards/models/project';
+import store from '~/boards/stores';
import boardsStore from '~/boards/stores/boards_store';
import ModalStore from '~/boards/stores/modal_store';
import BoardService from 'ee_else_ce/boards/services/board_service';
@@ -29,6 +30,7 @@ import {
} from '~/lib/utils/common_utils';
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,
setWeigthFetchingState,
@@ -67,6 +69,7 @@ export default () => {
BoardSidebar,
BoardAddIssuesModal,
},
+ store,
data: {
state: boardsStore.state,
loading: true,
@@ -314,5 +317,6 @@ export default () => {
}
toggleFocusMode(ModalStore, boardsStore, $boardApp);
+ toggleLabels();
mountMultipleBoardsSwitcher();
};
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index 1e213c324eb..bb8c8e68297 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -50,8 +50,8 @@ class List {
this.page = 1;
this.loading = true;
this.loadingMore = false;
- this.issues = [];
- this.issuesSize = 0;
+ this.issues = obj.issues || [];
+ this.issuesSize = obj.issuesSize ? obj.issuesSize : 0;
this.defaultAvatar = defaultAvatar;
if (obj.label) {
diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js
new file mode 100644
index 00000000000..4de1576099d
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/getters.js
@@ -0,0 +1,3 @@
+export default {
+ getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'),
+};
diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js
index f70395a3771..471b952a212 100644
--- a/app/assets/javascripts/boards/stores/index.js
+++ b/app/assets/javascripts/boards/stores/index.js
@@ -1,14 +1,18 @@
import Vue from 'vue';
import Vuex from 'vuex';
import state from 'ee_else_ce/boards/stores/state';
+import getters from 'ee_else_ce/boards/stores/getters';
import actions from 'ee_else_ce/boards/stores/actions';
import mutations from 'ee_else_ce/boards/stores/mutations';
Vue.use(Vuex);
-export default () =>
+export const createStore = () =>
new Vuex.Store({
state,
+ getters,
actions,
mutations,
});
+
+export default createStore();
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
index dd16abb01a5..24f44dc5629 100644
--- a/app/assets/javascripts/boards/stores/state.js
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -1,3 +1,3 @@
export default () => ({
- // ...
+ isShowingLabels: true,
});
diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/boards/toggle_labels.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index 7ea8901ecbb..75909dd9d20 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -8,11 +8,12 @@ import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
-import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants';
+import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
import setupToggleButtons from '../toggle_buttons';
+import initProjectSelectDropdown from '~/project_select';
const Environments = () => import('ee_component/clusters/components/environments.vue');
@@ -37,6 +38,8 @@ export default class Clusters {
installJupyterPath,
installKnativePath,
updateKnativePath,
+ installElasticStackPath,
+ installCrossplanePath,
installPrometheusPath,
managePrometheusPath,
clusterEnvironmentsPath,
@@ -81,11 +84,13 @@ export default class Clusters {
installHelmEndpoint: installHelmPath,
installIngressEndpoint: installIngressPath,
installCertManagerEndpoint: installCertManagerPath,
+ installCrossplaneEndpoint: installCrossplanePath,
installRunnerEndpoint: installRunnerPath,
installPrometheusEndpoint: installPrometheusPath,
installJupyterEndpoint: installJupyterPath,
installKnativeEndpoint: installKnativePath,
updateKnativeEndpoint: updateKnativePath,
+ installElasticStackEndpoint: installElasticStackPath,
clusterEnvironmentsEndpoint: clusterEnvironmentsPath,
});
@@ -108,8 +113,10 @@ export default class Clusters {
this.ingressDomainHelpText &&
this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet');
+ initProjectSelectDropdown();
Clusters.initDismissableCallout();
initSettingsPanels();
+
const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area');
if (toggleButtonsContainer) {
setupToggleButtons(toggleButtonsContainer);
@@ -222,6 +229,7 @@ export default class Clusters {
eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
eventHub.$on('uninstallApplication', data => this.uninstallApplication(data));
+ eventHub.$on('setCrossplaneProviderStack', data => this.setCrossplaneProviderStack(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
@@ -233,6 +241,7 @@ export default class Clusters {
eventHub.$off('updateApplication', this.updateApplication);
eventHub.$off('saveKnativeDomain');
eventHub.$off('setKnativeHostname');
+ eventHub.$off('setCrossplaneProviderStack');
eventHub.$off('uninstallApplication');
}
@@ -399,18 +408,33 @@ export default class Clusters {
}
installApplication({ id: appId, params }) {
- this.store.updateAppProperty(appId, 'requestReason', null);
- this.store.updateAppProperty(appId, 'statusReason', null);
+ return Clusters.validateInstallation(appId, params)
+ .then(() => {
+ this.store.updateAppProperty(appId, 'requestReason', null);
+ this.store.updateAppProperty(appId, 'statusReason', null);
+ this.store.installApplication(appId);
+
+ // eslint-disable-next-line promise/no-nesting
+ this.service.installApplication(appId, params).catch(() => {
+ this.store.notifyInstallFailure(appId);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin installing failed'),
+ );
+ });
+ })
+ .catch(error => this.store.updateAppProperty(appId, 'validationError', error));
+ }
- this.store.installApplication(appId);
+ static validateInstallation(appId, params) {
+ return new Promise((resolve, reject) => {
+ if (appId === CROSSPLANE && !params.stack) {
+ reject(s__('ClusterIntegration|Select a stack to install Crossplane.'));
+ return;
+ }
- return this.service.installApplication(appId, params).catch(() => {
- this.store.notifyInstallFailure(appId);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin installing failed'),
- );
+ resolve();
});
}
@@ -458,6 +482,12 @@ export default class Clusters {
this.store.updateAppProperty(appId, 'hostname', data.hostname);
}
+ setCrossplaneProviderStack(data) {
+ const appId = data.id;
+ this.store.updateAppProperty(appId, 'stack', data.stack.code);
+ this.store.updateAppProperty(appId, 'validationError', null);
+ }
+
destroy() {
this.destroyed = true;
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index b95f97077f6..a951a6bfeea 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -9,9 +9,11 @@ import jeagerLogo from 'images/cluster_app_logos/jeager.png';
import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png';
import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png';
import certManagerLogo from 'images/cluster_app_logos/cert_manager.png';
+import crossplaneLogo from 'images/cluster_app_logos/crossplane.png';
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 { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
@@ -19,6 +21,7 @@ import KnativeDomainEditor from './knative_domain_editor.vue';
import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '~/clusters/event_hub';
+import CrossplaneProviderStack from './crossplane_provider_stack.vue';
export default {
components: {
@@ -27,6 +30,7 @@ export default {
LoadingButton,
GlLoadingIcon,
KnativeDomainEditor,
+ CrossplaneProviderStack,
},
props: {
type: {
@@ -88,9 +92,11 @@ export default {
jupyterhubLogo,
kubernetesLogo,
certManagerLogo,
+ crossplaneLogo,
knativeLogo,
meltanoLogo,
prometheusLogo,
+ elasticStackLogo,
}),
computed: {
isProjectCluster() {
@@ -114,6 +120,15 @@ export default {
certManagerInstalled() {
return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
},
+ crossplaneInstalled() {
+ return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED;
+ },
+ enableClusterApplicationCrossplane() {
+ return gon.features && gon.features.enableClusterApplicationCrossplane;
+ },
+ enableClusterApplicationElasticStack() {
+ return gon.features && gon.features.enableClusterApplicationElasticStack;
+ },
ingressDescription() {
return sprintf(
_.escape(
@@ -146,6 +161,24 @@ export default {
false,
);
},
+ crossplaneDescription() {
+ return sprintf(
+ _.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.`,
+ ),
+ ),
+ {
+ gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/crossplane.html"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`,
+ kubectl: `<code>kubectl</code>`,
+ },
+ false,
+ );
+ },
+
prometheusDescription() {
return sprintf(
_.escape(
@@ -168,9 +201,18 @@ export default {
jupyterHostname() {
return this.applications.jupyter.hostname;
},
+ elasticStackInstalled() {
+ return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED;
+ },
+ elasticStackKibanaHostname() {
+ return this.applications.elastic_stack.kibana_hostname;
+ },
knative() {
return this.applications.knative;
},
+ crossplane() {
+ return this.applications.crossplane;
+ },
cloudRun() {
return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative;
},
@@ -207,6 +249,12 @@ export default {
hostname,
});
},
+ setCrossplaneProviderStack(stack) {
+ eventHub.$emit('setCrossplaneProviderStack', {
+ id: 'crossplane',
+ stack,
+ });
+ },
},
};
</script>
@@ -217,7 +265,7 @@ export default {
<p class="append-bottom-0">
{{
s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.
- Helm Tiller is required to install any of the following applications.`)
+ Helm Tiller is required to install any of the following applications.`)
}}
<a :href="helpPath">{{ __('More information') }}</a>
</p>
@@ -242,9 +290,9 @@ export default {
<div slot="description">
{{
s__(`ClusterIntegration|Helm streamlines installing
- and managing Kubernetes applications.
- Tiller runs inside of your Kubernetes Cluster,
- and manages releases of your charts.`)
+ and managing Kubernetes applications.
+ Tiller runs inside of your Kubernetes Cluster,
+ and manages releases of your charts.`)
}}
</div>
</application-row>
@@ -252,7 +300,7 @@ export default {
<div class="svg-container" v-html="helmInstallIllustration"></div>
{{
s__(`ClusterIntegration|You must first install Helm Tiller before
- installing the applications below`)
+ installing the applications below`)
}}
</div>
<application-row
@@ -275,8 +323,8 @@ export default {
<p>
{{
s__(`ClusterIntegration|Ingress gives you a way to route
- requests to services based on the request host or path,
- centralizing a number of services into a single entrypoint.`)
+ requests to services based on the request host or path,
+ centralizing a number of services into a single entrypoint.`)
}}
</p>
@@ -308,8 +356,8 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Point a wildcard DNS to this
- generated endpoint in order to access
- your application after it has been deployed.`)
+ generated endpoint in order to access
+ your application after it has been deployed.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@@ -320,8 +368,8 @@ export default {
<p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message">
{{
s__(`ClusterIntegration|The endpoint is in
- the process of being assigned. Please check your Kubernetes
- cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
+ the process of being assigned. Please check your Kubernetes
+ cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@@ -368,7 +416,7 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Issuers represent a certificate authority.
- You must provide an email address for your Issuer. `)
+ You must provide an email address for your Issuer. `)
}}
<a
href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
@@ -424,13 +472,41 @@ export default {
<div slot="description">
{{
s__(`ClusterIntegration|GitLab Runner connects to the
- repository and executes CI/CD jobs,
- pushing results back and deploying
- applications to production.`)
+ repository and executes CI/CD jobs,
+ pushing results back and deploying
+ applications to production.`)
}}
</div>
</application-row>
<application-row
+ v-if="enableClusterApplicationCrossplane"
+ id="crossplane"
+ :logo-url="crossplaneLogo"
+ :title="applications.crossplane.title"
+ :status="applications.crossplane.status"
+ :status-reason="applications.crossplane.statusReason"
+ :request-status="applications.crossplane.requestStatus"
+ :request-reason="applications.crossplane.requestReason"
+ :installed="applications.crossplane.installed"
+ :install-failed="applications.crossplane.installFailed"
+ :uninstallable="applications.crossplane.uninstallable"
+ :uninstall-successful="applications.crossplane.uninstallSuccessful"
+ :uninstall-failed="applications.crossplane.uninstallFailed"
+ :install-application-request-params="{ stack: applications.crossplane.stack }"
+ :disabled="!helmInstalled"
+ title-link="https://crossplane.io"
+ >
+ <template>
+ <div slot="description">
+ <p v-html="crossplaneDescription"></p>
+ <div class="form-group">
+ <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" />
+ </div>
+ </div>
+ </template>
+ </application-row>
+
+ <application-row
id="jupyter"
:logo-url="jupyterhubLogo"
:title="applications.jupyter.title"
@@ -451,10 +527,10 @@ export default {
<p>
{{
s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
- manages, and proxies multiple instances of the single-user
- Jupyter notebook server. JupyterHub can be used to serve
- notebooks to a class of students, a corporate data science group,
- or a scientific research group.`)
+ manages, and proxies multiple instances of the single-user
+ Jupyter notebook server. JupyterHub can be used to serve
+ notebooks to a class of students, a corporate data science group,
+ or a scientific research group.`)
}}
</p>
@@ -481,7 +557,7 @@ export default {
<p v-if="ingressInstalled" class="form-text text-muted">
{{
s__(`ClusterIntegration|Replace this with your own hostname if you want.
- If you do so, point hostname to Ingress IP Address from above.`)
+ If you do so, point hostname to Ingress IP Address from above.`)
}}
<a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
{{ __('More information') }}
@@ -527,9 +603,9 @@ export default {
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
- a set of middleware components that are essential to build modern,
- source-centric, and container-based applications that can run
- anywhere: on premises, in the cloud, or even in a third-party data center.`)
+ a set of middleware components that are essential to build modern,
+ source-centric, and container-based applications that can run
+ anywhere: on premises, in the cloud, or even in a third-party data center.`)
}}
</p>
@@ -542,6 +618,75 @@ export default {
/>
</div>
</application-row>
+ <application-row
+ v-if="enableClusterApplicationElasticStack"
+ id="elastic_stack"
+ :logo-url="elasticStackLogo"
+ :title="applications.elastic_stack.title"
+ :status="applications.elastic_stack.status"
+ :status-reason="applications.elastic_stack.statusReason"
+ :request-status="applications.elastic_stack.requestStatus"
+ :request-reason="applications.elastic_stack.requestReason"
+ :version="applications.elastic_stack.version"
+ :chart-repo="applications.elastic_stack.chartRepo"
+ :update-available="applications.elastic_stack.updateAvailable"
+ :installed="applications.elastic_stack.installed"
+ :install-failed="applications.elastic_stack.installFailed"
+ :update-successful="applications.elastic_stack.updateSuccessful"
+ :update-failed="applications.elastic_stack.updateFailed"
+ :uninstallable="applications.elastic_stack.uninstallable"
+ :uninstall-successful="applications.elastic_stack.uninstallSuccessful"
+ :uninstall-failed="applications.elastic_stack.uninstallFailed"
+ :disabled="!helmInstalled"
+ :install-application-request-params="{
+ kibana_hostname: applications.elastic_stack.kibana_hostname,
+ }"
+ title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack"
+ >
+ <div slot="description">
+ <p>
+ {{
+ s__(
+ `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`,
+ )
+ }}
+ </p>
+
+ <template v-if="ingressExternalEndpoint">
+ <div class="form-group">
+ <label for="elastic-stack-kibana-hostname">{{
+ s__('ClusterIntegration|Kibana Hostname')
+ }}</label>
+
+ <div class="input-group">
+ <input
+ v-model="applications.elastic_stack.kibana_hostname"
+ :readonly="elasticStackInstalled"
+ type="text"
+ class="form-control js-hostname"
+ />
+ <span class="input-group-btn">
+ <clipboard-button
+ :text="elasticStackKibanaHostname"
+ :title="s__('ClusterIntegration|Copy Kibana Hostname')"
+ class="js-clipboard-btn"
+ />
+ </span>
+ </div>
+
+ <p v-if="ingressInstalled" class="form-text text-muted">
+ {{
+ s__(`ClusterIntegration|Replace this with your own hostname if you want.
+ If you do so, point hostname to Ingress IP Address from above.`)
+ }}
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
+ {{ __('More information') }}
+ </a>
+ </p>
+ </div>
+ </template>
+ </div>
+ </application-row>
</div>
</section>
</template>
diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
new file mode 100644
index 00000000000..966918ae636
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue
@@ -0,0 +1,93 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { s__ } from '../../locale';
+
+export default {
+ name: 'CrossplaneProviderStack',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ Icon,
+ },
+ props: {
+ stacks: {
+ type: Array,
+ required: false,
+ default: () => [
+ {
+ name: s__('Google Cloud Platform'),
+ code: 'gcp',
+ },
+ {
+ name: s__('Amazon Web Services'),
+ code: 'aws',
+ },
+ {
+ name: s__('Microsoft Azure'),
+ code: 'azure',
+ },
+ {
+ name: s__('Rook'),
+ code: 'rook',
+ },
+ ],
+ },
+ crossplane: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownText() {
+ const result = this.stacks.reduce((map, obj) => {
+ // eslint-disable-next-line no-param-reassign
+ map[obj.code] = obj.name;
+ return map;
+ }, {});
+ const { stack } = this.crossplane;
+ if (stack !== '') {
+ return result[stack];
+ }
+ return s__('Select Stack');
+ },
+ validationError() {
+ return this.crossplane.validationError;
+ },
+ },
+ methods: {
+ selectStack(stack) {
+ this.$emit('set', stack);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <label>
+ {{ s__('ClusterIntegration|Enabled stack') }}
+ </label>
+ <gl-dropdown
+ :disabled="crossplane.installed"
+ :text="dropdownText"
+ toggle-class="dropdown-menu-toggle gl-field-error-outline"
+ class="w-100"
+ :class="{ 'gl-show-field-errors': validationError }"
+ >
+ <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)">
+ <span class="ml-1">{{ stack.name }}</span>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ <span v-if="validationError" class="gl-field-error">{{ validationError }}</span>
+ <p class="form-text text-muted">
+ {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }}
+ <a
+ href="https://crossplane.io/docs/master/stacks-guide.html"
+ target="_blank"
+ rel="noopener noreferrer"
+ >{{ __('Crossplane') }}</a
+ >
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index f1925c243f2..125bcaacc1c 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -2,7 +2,16 @@
import { GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
-import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
+import {
+ HELM,
+ INGRESS,
+ CERT_MANAGER,
+ PROMETHEUS,
+ RUNNER,
+ KNATIVE,
+ JUPYTER,
+ ELASTIC_STACK,
+} from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
[HELM]: sprintf(
@@ -28,6 +37,7 @@ const CUSTOM_APP_WARNING_TEXT = {
[JUPYTER]: s__(
'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.',
),
+ [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
};
export default {
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index c6e4b7951cf..9f98f170fb0 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -50,8 +50,19 @@ export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
export const RUNNER = 'runner';
export const CERT_MANAGER = 'cert_manager';
+export const CROSSPLANE = 'crossplane';
export const PROMETHEUS = 'prometheus';
+export const ELASTIC_STACK = 'elastic_stack';
-export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS];
+export const APPLICATIONS = [
+ HELM,
+ INGRESS,
+ JUPYTER,
+ KNATIVE,
+ RUNNER,
+ CERT_MANAGER,
+ PROMETHEUS,
+ ELASTIC_STACK,
+];
export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index fa12802b3de..333fb293a15 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -7,10 +7,12 @@ export default class ClusterService {
helm: this.options.installHelmEndpoint,
ingress: this.options.installIngressEndpoint,
cert_manager: this.options.installCertManagerEndpoint,
+ crossplane: this.options.installCrossplaneEndpoint,
runner: this.options.installRunnerEndpoint,
prometheus: this.options.installPrometheusEndpoint,
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
+ elastic_stack: this.options.installElasticStackEndpoint,
};
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 6464461ea0c..35dbf951551 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -5,6 +5,8 @@ import {
JUPYTER,
KNATIVE,
CERT_MANAGER,
+ ELASTIC_STACK,
+ CROSSPLANE,
RUNNER,
APPLICATION_INSTALLED_STATUSES,
APPLICATION_STATUS,
@@ -25,6 +27,7 @@ const applicationInitialState = {
uninstallable: false,
uninstallFailed: false,
uninstallSuccessful: false,
+ validationError: null,
};
export default class ClusterStore {
@@ -57,6 +60,11 @@ export default class ClusterStore {
title: s__('ClusterIntegration|Cert-Manager'),
email: null,
},
+ crossplane: {
+ ...applicationInitialState,
+ title: s__('ClusterIntegration|Crossplane'),
+ stack: null,
+ },
runner: {
...applicationInitialState,
title: s__('ClusterIntegration|GitLab Runner'),
@@ -85,6 +93,11 @@ export default class ClusterStore {
updateSuccessful: false,
updateFailed: false,
},
+ elastic_stack: {
+ ...applicationInitialState,
+ title: s__('ClusterIntegration|Elastic Stack'),
+ kibana_hostname: null,
+ },
},
environments: [],
fetchingEnvironments: false,
@@ -197,13 +210,15 @@ export default class ClusterStore {
} else if (appId === CERT_MANAGER) {
this.state.applications.cert_manager.email =
this.state.applications.cert_manager.email || serverAppEntry.email;
+ } else if (appId === CROSSPLANE) {
+ this.state.applications.crossplane.stack =
+ this.state.applications.crossplane.stack || serverAppEntry.stack;
} else if (appId === JUPYTER) {
- this.state.applications.jupyter.hostname =
- this.state.applications.jupyter.hostname ||
- serverAppEntry.hostname ||
- (this.state.applications.ingress.externalIp
- ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io`
- : '');
+ this.state.applications.jupyter.hostname = this.updateHostnameIfUnset(
+ this.state.applications.jupyter.hostname,
+ serverAppEntry.hostname,
+ 'jupyter',
+ );
} else if (appId === KNATIVE) {
if (!this.state.applications.knative.isEditingHostName) {
this.state.applications.knative.hostname =
@@ -216,10 +231,26 @@ export default class ClusterStore {
} else if (appId === RUNNER) {
this.state.applications.runner.version = version;
this.state.applications.runner.updateAvailable = updateAvailable;
+ } else if (appId === ELASTIC_STACK) {
+ this.state.applications.elastic_stack.kibana_hostname = this.updateHostnameIfUnset(
+ this.state.applications.elastic_stack.kibana_hostname,
+ serverAppEntry.kibana_hostname,
+ 'kibana',
+ );
}
});
}
+ updateHostnameIfUnset(current, updated, fallback) {
+ return (
+ current ||
+ updated ||
+ (this.state.applications.ingress.externalIp
+ ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io`
+ : '')
+ );
+ }
+
toggleFetchEnvironments(isFetching) {
this.state.fetchingEnvironments = isFetching;
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index 6c04e0beb4d..60c2059a876 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */
+/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */
import $ from 'jquery';
@@ -9,40 +9,29 @@ const viewModes = ['two-up', 'swipe'];
export default class ImageFile {
constructor(file) {
this.file = file;
- this.requestImageInfo(
- $('.two-up.view .frame.deleted img', this.file),
- (function(_this) {
- return function() {
- return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => {
- _this.initViewModes();
-
- // Load two-up view after images are loaded
- // so that we can display the correct width and height information
- const $images = $('.two-up.view img', _this.file);
-
- $images.waitForImages(() => {
- _this.initView('two-up');
- });
- });
- };
- })(this),
+ this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), () =>
+ this.requestImageInfo($('.two-up.view .frame.added img', this.file), () => {
+ this.initViewModes();
+
+ // Load two-up view after images are loaded
+ // so that we can display the correct width and height information
+ const $images = $('.two-up.view img', this.file);
+
+ $images.waitForImages(() => {
+ this.initView('two-up');
+ });
+ }),
);
}
initViewModes() {
const viewMode = viewModes[0];
$('.view-modes', this.file).removeClass('hide');
- $('.view-modes-menu', this.file).on(
- 'click',
- 'li',
- (function(_this) {
- return function(event) {
- if (!$(event.currentTarget).hasClass('active')) {
- return _this.activateViewMode(event.currentTarget.className);
- }
- };
- })(this),
- );
+ $('.view-modes-menu', this.file).on('click', 'li', event => {
+ if (!$(event.currentTarget).hasClass('active')) {
+ return this.activateViewMode(event.currentTarget.className);
+ }
+ });
return this.activateViewMode(viewMode);
}
@@ -51,15 +40,10 @@ export default class ImageFile {
.removeClass('active')
.filter(`.${viewMode}`)
.addClass('active');
- return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(
- 200,
- (function(_this) {
- return function() {
- $(`.view.${viewMode}`, _this.file).fadeIn(200);
- return _this.initView(viewMode);
- };
- })(this),
- );
+ return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => {
+ $(`.view.${viewMode}`, this.file).fadeIn(200);
+ return this.initView(viewMode);
+ });
}
initView(viewMode) {
@@ -103,22 +87,18 @@ export default class ImageFile {
.on('touchmove', dragMove);
}
- prepareFrames(view) {
+ static prepareFrames(view) {
var maxHeight, maxWidth;
maxWidth = 0;
maxHeight = 0;
$('.frame', view)
- .each(
- (function() {
- return function(index, frame) {
- var height, width;
- width = $(frame).width();
- height = $(frame).height();
- maxWidth = width > maxWidth ? width : maxWidth;
- return (maxHeight = height > maxHeight ? height : maxHeight);
- };
- })(this),
- )
+ .each((index, frame) => {
+ var height, width;
+ width = $(frame).width();
+ height = $(frame).height();
+ maxWidth = width > maxWidth ? width : maxWidth;
+ return (maxHeight = height > maxHeight ? height : maxHeight);
+ })
.css({
width: maxWidth,
height: maxHeight,
@@ -128,104 +108,95 @@ export default class ImageFile {
views = {
'two-up': function() {
- return $('.two-up.view .wrap', this.file).each(
- (function(_this) {
- return function(index, wrap) {
- $('img', wrap).each(function() {
- var currentWidth;
- currentWidth = $(this).width();
- if (currentWidth > availWidth / 2) {
- return $(this).width(availWidth / 2);
- }
- });
- return _this.requestImageInfo($('img', wrap), (width, height) => {
- $('.image-info .meta-width', wrap).text(`${width}px`);
- $('.image-info .meta-height', wrap).text(`${height}px`);
- return $('.image-info', wrap).removeClass('hide');
- });
- };
- })(this),
- );
+ return $('.two-up.view .wrap', this.file).each((index, wrap) => {
+ $('img', wrap).each(function() {
+ var currentWidth;
+ currentWidth = $(this).width();
+ if (currentWidth > availWidth / 2) {
+ return $(this).width(availWidth / 2);
+ }
+ });
+ return this.requestImageInfo($('img', wrap), (width, height) => {
+ $('.image-info .meta-width', wrap).text(`${width}px`);
+ $('.image-info .meta-height', wrap).text(`${height}px`);
+ return $('.image-info', wrap).removeClass('hide');
+ });
+ });
},
swipe() {
var maxHeight, maxWidth;
maxWidth = 0;
maxHeight = 0;
- return $('.swipe.view', this.file).each(
- (function(_this) {
- return function(index, view) {
- var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref;
- (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref);
- $swipeFrame = $('.swipe-frame', view);
- $swipeWrap = $('.swipe-wrap', view);
- $swipeBar = $('.swipe-bar', view);
-
- $swipeFrame.css({
- width: maxWidth + 16,
- height: maxHeight + 28,
- });
- $swipeWrap.css({
- width: maxWidth + 1,
- height: maxHeight + 2,
- });
- // Set swipeBar left position to match image frame
- $swipeBar.css({
- left: 1,
- });
-
- wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
-
- _this.initDraggable($swipeBar, wrapPadding, (e, left) => {
- if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) {
- $swipeWrap.width(maxWidth + 1 - left);
- $swipeBar.css('left', left);
- }
- });
- };
- })(this),
- );
+ return $('.swipe.view', this.file).each((index, view) => {
+ var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding;
+ const ref = ImageFile.prepareFrames(view);
+ [maxWidth, maxHeight] = ref;
+ $swipeFrame = $('.swipe-frame', view);
+ $swipeWrap = $('.swipe-wrap', view);
+ $swipeBar = $('.swipe-bar', view);
+
+ $swipeFrame.css({
+ width: maxWidth + 16,
+ height: maxHeight + 28,
+ });
+ $swipeWrap.css({
+ width: maxWidth + 1,
+ height: maxHeight + 2,
+ });
+ // Set swipeBar left position to match image frame
+ $swipeBar.css({
+ left: 1,
+ });
+
+ wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10);
+
+ this.initDraggable($swipeBar, wrapPadding, (e, left) => {
+ if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) {
+ $swipeWrap.width(maxWidth + 1 - left);
+ $swipeBar.css('left', left);
+ }
+ });
+ });
},
'onion-skin': function() {
var dragTrackWidth, maxHeight, maxWidth;
maxWidth = 0;
maxHeight = 0;
dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width();
- return $('.onion-skin.view', this.file).each(
- (function(_this) {
- return function(index, view) {
- var $frame, $track, $dragger, $frameAdded, framePadding, ref;
- (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref);
- $frame = $('.onion-skin-frame', view);
- $frameAdded = $('.frame.added', view);
- $track = $('.drag-track', view);
- $dragger = $('.dragger', $track);
-
- $frame.css({
- width: maxWidth + 16,
- height: maxHeight + 28,
- });
- $('.swipe-wrap', view).css({
- width: maxWidth + 1,
- height: maxHeight + 2,
- });
- $dragger.css({
- left: dragTrackWidth,
- });
-
- $frameAdded.css('opacity', 1);
- framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
-
- _this.initDraggable($dragger, framePadding, (e, left) => {
- var opacity = left / dragTrackWidth;
-
- if (opacity >= 0 && opacity <= 1) {
- $dragger.css('left', left);
- $frameAdded.css('opacity', opacity);
- }
- });
- };
- })(this),
- );
+ return $('.onion-skin.view', this.file).each((index, view) => {
+ var $frame, $track, $dragger, $frameAdded, framePadding;
+
+ const ref = ImageFile.prepareFrames(view);
+ [maxWidth, maxHeight] = ref;
+ $frame = $('.onion-skin-frame', view);
+ $frameAdded = $('.frame.added', view);
+ $track = $('.drag-track', view);
+ $dragger = $('.dragger', $track);
+
+ $frame.css({
+ width: maxWidth + 16,
+ height: maxHeight + 28,
+ });
+ $('.swipe-wrap', view).css({
+ width: maxWidth + 1,
+ height: maxHeight + 2,
+ });
+ $dragger.css({
+ left: dragTrackWidth,
+ });
+
+ $frameAdded.css('opacity', 1);
+ framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10);
+
+ this.initDraggable($dragger, framePadding, (e, left) => {
+ var opacity = left / dragTrackWidth;
+
+ if (opacity >= 0 && opacity <= 1) {
+ $dragger.css('left', left);
+ $frameAdded.css('opacity', opacity);
+ }
+ });
+ });
},
};
@@ -235,14 +206,7 @@ export default class ImageFile {
if (domImg.complete) {
return callback.call(this, domImg.naturalWidth, domImg.naturalHeight);
} else {
- return img.on(
- 'load',
- (function(_this) {
- return function() {
- return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight);
- };
- })(this),
- );
+ return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight));
}
}
}
diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js
index 81ba15577fb..a23707209dc 100644
--- a/app/assets/javascripts/compare_autocomplete.js
+++ b/app/assets/javascripts/compare_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, one-var, no-var, no-else-return */
+/* eslint-disable func-names, no-else-return */
import $ from 'jquery';
import { __ } from './locale';
@@ -8,9 +8,8 @@ import { capitalizeFirstCharacter } from './lib/utils/text_utility';
export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) {
$('.js-compare-dropdown').each(function() {
- var $dropdown, selected;
- $dropdown = $(this);
- selected = $dropdown.data('selected');
+ const $dropdown = $(this);
+ const selected = $dropdown.data('selected');
const $dropdownContainer = $dropdown.closest('.dropdown');
const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer);
const $filterInput = $('input[type="search"]', $dropdownContainer);
@@ -44,17 +43,16 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = (
fieldName: $dropdown.data('fieldName'),
filterInput: 'input[type="search"]',
renderRow(ref) {
- var link;
+ const link = $('<a />')
+ .attr('href', '#')
+ .addClass(ref === selected ? 'is-active' : '')
+ .text(ref)
+ .attr('data-ref', ref);
if (ref.header != null) {
return $('<li />')
.addClass('dropdown-header')
.text(ref.header);
} else {
- link = $('<a />')
- .attr('href', '#')
- .addClass(ref === selected ? 'is-active' : '')
- .text(ref)
- .attr('data-ref', ref);
return $('<li />').append(link);
}
},
diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
index 197a0706062..4fa18b19556 100644
--- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
+++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue
@@ -41,7 +41,7 @@ export default {
noForkText() {
return sprintf(
__(
- "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.",
+ "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.",
),
{ link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' },
false,
diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue
new file mode 100644
index 00000000000..7dd6b051cb4
--- /dev/null
+++ b/app/assets/javascripts/contributors/components/contributors.vue
@@ -0,0 +1,227 @@
+<script>
+import { __ } from '~/locale';
+import _ from 'underscore';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { getDatesInRange } from '~/lib/utils/datetime_utility';
+import { xAxisLabelFormatter, dateFormatter } from '../utils';
+
+export default {
+ components: {
+ GlAreaChart,
+ GlLoadingIcon,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ branch: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ masterChart: null,
+ individualCharts: [],
+ svgs: {},
+ masterChartHeight: 264,
+ individualChartHeight: 216,
+ };
+ },
+ computed: {
+ ...mapState(['chartData', 'loading']),
+ ...mapGetters(['showChart', 'parsedData']),
+ masterChartData() {
+ const data = {};
+ this.xAxisRange.forEach(date => {
+ data[date] = this.parsedData.total[date] || 0;
+ });
+ return [
+ {
+ name: __('Commits'),
+ data: Object.entries(data),
+ },
+ ];
+ },
+ masterChartOptions() {
+ return {
+ ...this.getCommonChartOptions(true),
+ yAxis: {
+ name: __('Number of commits'),
+ },
+ grid: {
+ bottom: 64,
+ left: 64,
+ right: 20,
+ top: 20,
+ },
+ };
+ },
+ individualChartsData() {
+ const maxNumberOfIndividualContributorsCharts = 100;
+
+ return Object.keys(this.parsedData.byAuthor)
+ .map(name => {
+ const author = this.parsedData.byAuthor[name];
+ return {
+ name,
+ email: author.email,
+ commits: author.commits,
+ dates: [
+ {
+ name: __('Commits'),
+ data: this.xAxisRange.map(date => [date, author.dates[date] || 0]),
+ },
+ ],
+ };
+ })
+ .sort((a, b) => b.commits - a.commits)
+ .slice(0, maxNumberOfIndividualContributorsCharts);
+ },
+ individualChartOptions() {
+ return {
+ ...this.getCommonChartOptions(false),
+ yAxis: {
+ name: __('Commits'),
+ max: this.individualChartYAxisMax,
+ },
+ grid: {
+ bottom: 27,
+ left: 64,
+ right: 20,
+ top: 8,
+ },
+ };
+ },
+ individualChartYAxisMax() {
+ return this.individualChartsData.reduce((acc, item) => {
+ const values = item.dates[0].data.map(value => value[1]);
+ return Math.max(acc, ...values);
+ }, 0);
+ },
+ xAxisRange() {
+ const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b));
+
+ const firstContributionDate = new Date(dates[0]);
+ const lastContributionDate = new Date(dates[dates.length - 1]);
+
+ return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter);
+ },
+ firstContributionDate() {
+ return this.xAxisRange[0];
+ },
+ lastContributionDate() {
+ return this.xAxisRange[this.xAxisRange.length - 1];
+ },
+ charts() {
+ return _.uniq(this.individualCharts);
+ },
+ },
+ mounted() {
+ this.fetchChartData(this.endpoint);
+ },
+ methods: {
+ ...mapActions(['fetchChartData']),
+ getCommonChartOptions(isMasterChart) {
+ return {
+ xAxis: {
+ type: 'time',
+ name: '',
+ data: this.xAxisRange,
+ axisLabel: {
+ formatter: xAxisLabelFormatter,
+ showMaxLabel: false,
+ showMinLabel: false,
+ },
+ boundaryGap: false,
+ splitNumber: isMasterChart ? 24 : 18,
+ // 28 days
+ minInterval: 28 * 86400 * 1000,
+ min: this.firstContributionDate,
+ max: this.lastContributionDate,
+ },
+ };
+ },
+ setSvg(name) {
+ return getSvgIconPathContent(name)
+ .then(path => {
+ if (path) {
+ this.$set(this.svgs, name, `path://${path}`);
+ }
+ })
+ .catch(() => {});
+ },
+ onMasterChartCreated(chart) {
+ this.masterChart = chart;
+ this.setSvg('scroll-handle')
+ .then(() => {
+ this.masterChart.setOption({
+ dataZoom: [
+ {
+ type: 'slider',
+ handleIcon: this.svgs['scroll-handle'],
+ },
+ ],
+ });
+ })
+ .catch(() => {});
+ this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200));
+ },
+ onIndividualChartCreated(chart) {
+ this.individualCharts.push(chart);
+ },
+ setIndividualChartsZoom(options) {
+ this.charts.forEach(chart =>
+ chart.setOption(
+ {
+ dataZoom: {
+ start: options.start,
+ end: options.end,
+ show: false,
+ },
+ },
+ { lazyUpdate: true },
+ ),
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="loading" class="contributors-loader text-center">
+ <gl-loading-icon :inline="true" :size="4" />
+ </div>
+
+ <div v-else-if="showChart" class="contributors-charts">
+ <h4>{{ __('Commits to') }} {{ branch }}</h4>
+ <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span>
+ <div>
+ <gl-area-chart
+ :data="masterChartData"
+ :option="masterChartOptions"
+ :height="masterChartHeight"
+ @created="onMasterChartCreated"
+ />
+ </div>
+
+ <div class="row">
+ <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6">
+ <h4>{{ contributor.name }}</h4>
+ <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p>
+ <gl-area-chart
+ :data="contributor.dates"
+ :option="individualChartOptions"
+ :height="individualChartHeight"
+ @created="onIndividualChartCreated"
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js
new file mode 100644
index 00000000000..b6063589734
--- /dev/null
+++ b/app/assets/javascripts/contributors/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ContributorsGraphs from './components/contributors.vue';
+import store from './stores';
+
+export default () => {
+ const el = document.querySelector('.js-contributors-graph');
+
+ if (!el) return null;
+
+ return new Vue({
+ el,
+ store,
+
+ render(createElement) {
+ return createElement(ContributorsGraphs, {
+ props: {
+ endpoint: el.dataset.projectGraphPath,
+ branch: el.dataset.projectBranch,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/contributors/services/contributors_service.js b/app/assets/javascripts/contributors/services/contributors_service.js
new file mode 100644
index 00000000000..5a8bbb66511
--- /dev/null
+++ b/app/assets/javascripts/contributors/services/contributors_service.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ fetchChartData(endpoint) {
+ return axios.get(endpoint);
+ },
+};
diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js
new file mode 100644
index 00000000000..4138ff24f1d
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/actions.js
@@ -0,0 +1,20 @@
+import flash from '~/flash';
+import { __ } from '~/locale';
+import service from '../services/contributors_service';
+import * as types from './mutation_types';
+
+export const fetchChartData = ({ commit }, endpoint) => {
+ commit(types.SET_LOADING_STATE, true);
+
+ return service
+ .fetchChartData(endpoint)
+ .then(res => res.data)
+ .then(data => {
+ commit(types.SET_CHART_DATA, data);
+ commit(types.SET_LOADING_STATE, false);
+ })
+ .catch(() => flash(__('An error occurred while loading chart data')));
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js
new file mode 100644
index 00000000000..9e02e3ed9e7
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/getters.js
@@ -0,0 +1,33 @@
+export const showChart = state => Boolean(!state.loading && state.chartData);
+
+export const parsedData = state => {
+ const byAuthor = {};
+ const total = {};
+
+ state.chartData.forEach(({ date, author_name, author_email }) => {
+ total[date] = total[date] ? total[date] + 1 : 1;
+
+ const authorData = byAuthor[author_name];
+
+ if (!authorData) {
+ byAuthor[author_name] = {
+ email: author_email.toLowerCase(),
+ commits: 1,
+ dates: {
+ [date]: 1,
+ },
+ };
+ } else {
+ authorData.commits += 1;
+ authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1;
+ }
+ });
+
+ return {
+ total,
+ byAuthor,
+ };
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js
new file mode 100644
index 00000000000..bc739851aa7
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import mutations from './mutations';
+import * as getters from './getters';
+import * as actions from './actions';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ getters,
+ state: state(),
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/contributors/stores/mutation_types.js b/app/assets/javascripts/contributors/stores/mutation_types.js
new file mode 100644
index 00000000000..62e0a51d5f8
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/mutation_types.js
@@ -0,0 +1,3 @@
+export const SET_CHART_DATA = 'SET_CHART_DATA';
+export const SET_LOADING_STATE = 'SET_LOADING_STATE';
+export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH';
diff --git a/app/assets/javascripts/contributors/stores/mutations.js b/app/assets/javascripts/contributors/stores/mutations.js
new file mode 100644
index 00000000000..f1f460d072d
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/mutations.js
@@ -0,0 +1,17 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_LOADING_STATE](state, value) {
+ state.loading = value;
+ },
+ [types.SET_CHART_DATA](state, chartData) {
+ Object.assign(state, {
+ chartData,
+ });
+ },
+ [types.SET_ACTIVE_BRANCH](state, branch) {
+ Object.assign(state, {
+ branch,
+ });
+ },
+};
diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js
new file mode 100644
index 00000000000..1dc1a3c7b75
--- /dev/null
+++ b/app/assets/javascripts/contributors/stores/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ loading: false,
+ chartData: null,
+ branch: 'master',
+});
diff --git a/app/assets/javascripts/contributors/utils.js b/app/assets/javascripts/contributors/utils.js
new file mode 100644
index 00000000000..7d8932ce495
--- /dev/null
+++ b/app/assets/javascripts/contributors/utils.js
@@ -0,0 +1,30 @@
+import { getMonthNames } from '~/lib/utils/datetime_utility';
+
+/**
+ * Converts provided string to date and returns formatted value as a year for date in January and month name for the rest
+ * @param {String}
+ * @returns {String} - formatted value
+ *
+ * xAxisLabelFormatter('01-12-2019') will return '2019'
+ * xAxisLabelFormatter('02-12-2019') will return 'Feb'
+ * xAxisLabelFormatter('07-12-2019') will return 'Jul'
+ */
+export const xAxisLabelFormatter = val => {
+ const date = new Date(val);
+ const month = date.getUTCMonth();
+ const year = date.getUTCFullYear();
+ return month === 0 ? `${year}` : getMonthNames(true)[month];
+};
+
+/**
+ * Formats provided date to YYYY-MM-DD format
+ * @param {Date}
+ * @returns {String} - formatted value
+ */
+export const dateFormatter = date => {
+ const year = date.getUTCFullYear();
+ const month = date.getUTCMonth();
+ const day = date.getUTCDate();
+
+ return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`;
+};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
index 3c6da43c4c4..e6893c14cda 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
@@ -2,14 +2,19 @@
import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue';
import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
+import { GlIcon } from '@gitlab/ui';
-const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value);
+const toArray = value => [].concat(value);
+const itemsProp = (items, prop) => items.map(item => item[prop]);
+const defaultSearchFn = (searchQuery, labelProp) => item =>
+ item[labelProp].toLowerCase().indexOf(searchQuery) > -1;
export default {
components: {
DropdownButton,
DropdownSearchInput,
DropdownHiddenInput,
+ GlIcon,
},
props: {
fieldName: {
@@ -28,7 +33,7 @@ export default {
default: '',
},
value: {
- type: [Object, String],
+ type: [Object, Array, String],
required: false,
default: () => null,
},
@@ -72,6 +77,11 @@ export default {
required: false,
default: false,
},
+ multiple: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
errorMessage: {
type: String,
required: false,
@@ -90,12 +100,11 @@ export default {
searchFn: {
type: Function,
required: false,
- default: searchQuery => item => item.name.toLowerCase().indexOf(searchQuery) > -1,
+ default: defaultSearchFn,
},
},
data() {
return {
- selectedItem: findItem(this.items, this.value),
searchQuery: '',
};
},
@@ -109,36 +118,52 @@ export default {
return this.disabledText;
}
- if (!this.selectedItem) {
+ if (!this.selectedItems.length) {
return this.placeholder;
}
- return this.selectedItemLabel;
+ return this.selectedItemsLabels;
},
results() {
- if (!this.items) {
- return [];
- }
-
- return this.items.filter(this.searchFn(this.searchQuery));
+ return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty));
},
- selectedItemLabel() {
- return this.selectedItem && this.selectedItem[this.labelProperty];
+ selectedItems() {
+ const valueProp = this.valueProperty;
+ const valueList = toArray(this.value);
+ const items = this.getItemsOrEmptyList();
+
+ return items.filter(item => valueList.some(value => item[valueProp] === value));
},
- selectedItemValue() {
- return (this.selectedItem && this.selectedItem[this.valueProperty]) || '';
+ selectedItemsLabels() {
+ return itemsProp(this.selectedItems, this.labelProperty).join(', ');
},
- },
- watch: {
- value(value) {
- this.selectedItem = findItem(this.items, this.valueProperty, value);
+ selectedItemsValues() {
+ return itemsProp(this.selectedItems, this.valueProperty).join(', ');
},
},
methods: {
- select(item) {
- this.selectedItem = item;
+ getItemsOrEmptyList() {
+ return this.items || [];
+ },
+ selectSingle(item) {
this.$emit('input', item[this.valueProperty]);
},
+ selectMultiple(item) {
+ const value = toArray(this.value);
+ const itemValue = item[this.valueProperty];
+ const itemValueIndex = value.indexOf(itemValue);
+
+ if (itemValueIndex > -1) {
+ value.splice(itemValueIndex, 1);
+ } else {
+ value.push(itemValue);
+ }
+
+ this.$emit('input', value);
+ },
+ isSelected(item) {
+ return this.selectedItems.includes(item);
+ },
},
};
</script>
@@ -146,7 +171,7 @@ export default {
<template>
<div>
<div class="js-gcp-machine-type-dropdown dropdown">
- <dropdown-hidden-input :name="fieldName" :value="selectedItemValue" />
+ <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" />
<dropdown-button
:class="{ 'border-danger': hasErrors }"
:is-disabled="disabled"
@@ -158,15 +183,28 @@ export default {
<div class="dropdown-content">
<ul>
<li v-if="!results.length">
- <span class="js-empty-text menu-item">
- {{ emptyText }}
- </span>
+ <span class="js-empty-text menu-item">{{ emptyText }}</span>
</li>
<li v-for="item in results" :key="item.id">
- <button class="js-dropdown-item" type="button" @click.prevent="select(item)">
- <slot name="item" :item="item">
- {{ item.name }}
- </slot>
+ <button
+ v-if="multiple"
+ class="js-dropdown-item d-flex align-items-center"
+ type="button"
+ @click.stop.prevent="selectMultiple(item)"
+ >
+ <gl-icon
+ :class="[{ invisible: !isSelected(item) }, 'mr-1']"
+ name="mobile-issue-close"
+ />
+ <slot name="item" :item="item">{{ item.name }}</slot>
+ </button>
+ <button
+ v-else
+ class="js-dropdown-item"
+ type="button"
+ @click.prevent="selectSingle(item)"
+ >
+ <slot name="item" :item="item">{{ item.name }}</slot>
</button>
</li>
</ul>
@@ -182,8 +220,7 @@ export default {
'text-muted': !hasErrors,
},
]"
+ >{{ errorMessage }}</span
>
- {{ errorMessage }}
- </span>
</div>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
index 22ee368b8e0..3f7c2204b9f 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
+++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue
@@ -1,4 +1,5 @@
<script>
+import { mapState } from 'vuex';
import ServiceCredentialsForm from './service_credentials_form.vue';
import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue';
@@ -16,14 +17,37 @@ export default {
type: String,
required: true,
},
+ accountAndExternalIdsHelpPath: {
+ type: String,
+ required: true,
+ },
+ createRoleArnHelpPath: {
+ type: String,
+ required: true,
+ },
+ externalLinkIcon: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['hasCredentials']),
},
};
</script>
<template>
<div class="js-create-eks-cluster">
<eks-cluster-configuration-form
+ v-if="hasCredentials"
:gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath"
:kubernetes-integration-help-path="kubernetesIntegrationHelpPath"
+ :external-link-icon="externalLinkIcon"
+ />
+ <service-credentials-form
+ v-else
+ :create-role-arn-help-path="createRoleArnHelpPath"
+ :account-and-external-ids-help-path="accountAndExternalIdsHelpPath"
+ :external-link-icon="externalLinkIcon"
/>
</div>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue
index 1188cf08850..57d5f4f541b 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
@@ -4,8 +4,8 @@ import { sprintf, s__ } from '~/locale';
import _ from 'underscore';
import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
import ClusterFormDropdown from './cluster_form_dropdown.vue';
-import RegionDropdown from './region_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles');
const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers(
@@ -22,13 +22,17 @@ const {
mapState: mapSecurityGroupsState,
mapActions: mapSecurityGroupsActions,
} = createNamespacedHelpers('securityGroups');
+const {
+ mapState: mapInstanceTypesState,
+ mapActions: mapInstanceTypesActions,
+} = createNamespacedHelpers('instanceTypes');
export default {
components: {
ClusterFormDropdown,
- RegionDropdown,
GlFormInput,
GlFormCheckbox,
+ LoadingButton,
},
props: {
gitlabManagedClusterHelpPath: {
@@ -39,6 +43,10 @@ export default {
type: String,
required: true,
},
+ externalLinkIcon: {
+ type: String,
+ required: true,
+ },
},
computed: {
...mapState([
@@ -51,7 +59,10 @@ export default {
'selectedSubnet',
'selectedRole',
'selectedSecurityGroup',
+ 'selectedInstanceType',
+ 'nodeCount',
'gitlabManagedCluster',
+ 'isCreatingCluster',
]),
...mapRolesState({
roles: 'items',
@@ -83,6 +94,11 @@ export default {
isLoadingSecurityGroups: 'isLoadingItems',
loadingSecurityGroupsError: 'loadingItemsError',
}),
+ ...mapInstanceTypesState({
+ instanceTypes: 'items',
+ isLoadingInstanceTypes: 'isLoadingItems',
+ loadingInstanceTypesError: 'loadingItemsError',
+ }),
kubernetesVersions() {
return KUBERNETES_VERSIONS;
},
@@ -98,6 +114,27 @@ export default {
securityGroupDropdownDisabled() {
return !this.selectedVpc;
},
+ createClusterButtonDisabled() {
+ return (
+ !this.clusterName ||
+ !this.environmentScope ||
+ !this.kubernetesVersion ||
+ !this.selectedRegion ||
+ !this.selectedKeyPair ||
+ !this.selectedVpc ||
+ !this.selectedSubnet ||
+ !this.selectedRole ||
+ !this.selectedSecurityGroup ||
+ !this.selectedInstanceType ||
+ !this.nodeCount ||
+ this.isCreatingCluster
+ );
+ },
+ createClusterButtonLabel() {
+ return this.isCreatingCluster
+ ? s__('ClusterIntegration|Creating Kubernetes cluster')
+ : s__('ClusterIntegration|Create Kubernetes cluster');
+ },
kubernetesIntegrationHelpText() {
const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath);
@@ -115,11 +152,26 @@ export default {
roleDropdownHelpText() {
return sprintf(
s__(
- 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ regionsDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.',
),
{
startLink:
- '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
+ '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
false,
@@ -128,11 +180,12 @@ export default {
keyPairDropdownHelpText() {
return sprintf(
s__(
- 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
),
{
startLink:
'<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
false,
@@ -141,11 +194,12 @@ export default {
vpcDropdownHelpText() {
return sprintf(
s__(
- 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.',
+ 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.',
),
{
startLink:
- '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">',
+ '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
false,
@@ -154,11 +208,12 @@ export default {
subnetDropdownHelpText() {
return sprintf(
s__(
- 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.',
+ 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.',
),
{
startLink:
'<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
false,
@@ -167,11 +222,26 @@ export default {
securityGroupDropdownHelpText() {
return sprintf(
s__(
- 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
+ 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.',
),
{
startLink:
'<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ instanceTypesDropdownHelpText() {
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.',
+ ),
+ {
+ startLink:
+ '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">',
+ externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
false,
@@ -195,9 +265,12 @@ export default {
mounted() {
this.fetchRegions();
this.fetchRoles();
+ this.fetchInstanceTypes();
},
methods: {
...mapActions([
+ 'createCluster',
+ 'signOut',
'setClusterName',
'setEnvironmentScope',
'setKubernetesVersion',
@@ -207,6 +280,8 @@ export default {
'setRole',
'setKeyPair',
'setSecurityGroup',
+ 'setInstanceType',
+ 'setNodeCount',
'setGitlabManagedCluster',
]),
...mapRegionsActions({ fetchRegions: 'fetchItems' }),
@@ -215,15 +290,22 @@ export default {
...mapRolesActions({ fetchRoles: 'fetchItems' }),
...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }),
...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }),
+ ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }),
setRegionAndFetchVpcsAndKeyPairs(region) {
this.setRegion({ region });
+ this.setVpc({ vpc: null });
+ this.setKeyPair({ keyPair: null });
+ this.setSubnet({ subnet: null });
+ this.setSecurityGroup({ securityGroup: null });
this.fetchVpcs({ region });
this.fetchKeyPairs({ region });
},
setVpcAndFetchSubnets(vpc) {
this.setVpc({ vpc });
- this.fetchSubnets({ vpc });
- this.fetchSecurityGroups({ vpc });
+ this.setSubnet({ subnet: null });
+ this.setSecurityGroup({ securityGroup: null });
+ this.fetchSubnets({ vpc, region: this.selectedRegion });
+ this.fetchSecurityGroups({ vpc, region: this.selectedRegion });
},
},
};
@@ -233,7 +315,12 @@ export default {
<h2>
{{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }}
</h2>
- <p v-html="kubernetesIntegrationHelpText"></p>
+ <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div>
+ <div class="mb-3">
+ <button class="btn btn-link js-sign-out" @click.prevent="signOut()">
+ {{ s__('ClusterIntegration|Select a different AWS role') }}
+ </button>
+ </div>
<div class="form-group">
<label class="label-bold" for="eks-cluster-name">{{
s__('ClusterIntegration|Kubernetes cluster name')
@@ -273,7 +360,7 @@ export default {
<cluster-form-dropdown
field-id="eks-role"
field-name="eks-role"
- :input="selectedRole"
+ :value="selectedRole"
:items="roles"
:loading="isLoadingRoles"
:loading-text="s__('ClusterIntegration|Loading IAM Roles')"
@@ -288,13 +375,21 @@ export default {
</div>
<div class="form-group">
<label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label>
- <region-dropdown
+ <cluster-form-dropdown
+ field-id="eks-region"
+ field-name="eks-region"
:value="selectedRegion"
- :regions="regions"
- :error="loadingRegionsError"
+ :items="regions"
:loading="isLoadingRegions"
+ :loading-text="s__('ClusterIntegration|Loading Regions')"
+ :placeholder="s__('ClusterIntergation|Select a region')"
+ :search-field-placeholder="s__('ClusterIntegration|Search regions')"
+ :empty-text="s__('ClusterIntegration|No region found')"
+ :has-errors="Boolean(loadingRegionsError)"
+ :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
@input="setRegionAndFetchVpcsAndKeyPairs($event)"
/>
+ <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p>
</div>
<div class="form-group">
<label class="label-bold" for="eks-key-pair">{{
@@ -303,7 +398,7 @@ export default {
<cluster-form-dropdown
field-id="eks-key-pair"
field-name="eks-key-pair"
- :input="selectedKeyPair"
+ :value="selectedKeyPair"
:items="keyPairs"
:disabled="keyPairDropdownDisabled"
:disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')"
@@ -323,7 +418,7 @@ export default {
<cluster-form-dropdown
field-id="eks-vpc"
field-name="eks-vpc"
- :input="selectedVpc"
+ :value="selectedVpc"
:items="vpcs"
:loading="isLoadingVpcs"
:disabled="vpcDropdownDisabled"
@@ -339,11 +434,12 @@ export default {
<p class="form-text text-muted" v-html="vpcDropdownHelpText"></p>
</div>
<div class="form-group">
- <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label>
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label>
<cluster-form-dropdown
field-id="eks-subnet"
field-name="eks-subnet"
- :input="selectedSubnet"
+ multiple
+ :value="selectedSubnet"
:items="subnets"
:loading="isLoadingSubnets"
:disabled="subnetDropdownDisabled"
@@ -360,12 +456,12 @@ export default {
</div>
<div class="form-group">
<label class="label-bold" for="eks-security-group">{{
- s__('ClusterIntegration|Security groups')
+ s__('ClusterIntegration|Security group')
}}</label>
<cluster-form-dropdown
field-id="eks-security-group"
field-name="eks-security-group"
- :input="selectedSecurityGroup"
+ :value="selectedSecurityGroup"
:items="securityGroups"
:loading="isLoadingSecurityGroups"
:disabled="securityGroupDropdownDisabled"
@@ -383,6 +479,39 @@ export default {
<p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p>
</div>
<div class="form-group">
+ <label class="label-bold" for="eks-instance-type">{{
+ s__('ClusterIntegration|Instance type')
+ }}</label>
+ <cluster-form-dropdown
+ field-id="eks-instance-type"
+ field-name="eks-instance-type"
+ :value="selectedInstanceType"
+ :items="instanceTypes"
+ :loading="isLoadingInstanceTypes"
+ :loading-text="s__('ClusterIntegration|Loading instance types')"
+ :placeholder="s__('ClusterIntergation|Select an instance type')"
+ :search-field-placeholder="s__('ClusterIntegration|Search instance types')"
+ :empty-text="s__('ClusterIntegration|No instance type found')"
+ :has-errors="Boolean(loadingInstanceTypesError)"
+ :error-message="s__('ClusterIntegration|Could not load instance types')"
+ @input="setInstanceType({ instanceType: $event })"
+ />
+ <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p>
+ </div>
+ <div class="form-group">
+ <label class="label-bold" for="eks-node-count">{{
+ s__('ClusterIntegration|Number of nodes')
+ }}</label>
+ <gl-form-input
+ id="eks-node-count"
+ type="number"
+ min="1"
+ step="1"
+ :value="nodeCount"
+ @input="setNodeCount({ nodeCount: $event })"
+ />
+ </div>
+ <div class="form-group">
<gl-form-checkbox
:checked="gitlabManagedCluster"
@input="setGitlabManagedCluster({ gitlabManagedCluster: $event })"
@@ -390,5 +519,14 @@ export default {
>
<p class="form-text text-muted" v-html="gitlabManagedHelpText"></p>
</div>
+ <div class="form-group">
+ <loading-button
+ class="js-create-cluster btn-success"
+ :disabled="createClusterButtonDisabled"
+ :loading="isCreatingCluster"
+ :label="createClusterButtonLabel"
+ @click="createCluster()"
+ />
+ </div>
</form>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
deleted file mode 100644
index 765955305c8..00000000000
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue
+++ /dev/null
@@ -1,63 +0,0 @@
-<script>
-import { sprintf, s__ } from '~/locale';
-
-import ClusterFormDropdown from './cluster_form_dropdown.vue';
-
-export default {
- components: {
- ClusterFormDropdown,
- },
- props: {
- regions: {
- type: Array,
- required: false,
- default: () => [],
- },
- loading: {
- type: Boolean,
- required: false,
- default: false,
- },
- error: {
- type: Object,
- required: false,
- default: null,
- },
- },
- computed: {
- hasErrors() {
- return Boolean(this.error);
- },
- helpText() {
- return sprintf(
- s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'),
- {
- startLink:
- '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">',
- endLink: '</a>',
- },
- false,
- );
- },
- },
-};
-</script>
-<template>
- <div>
- <cluster-form-dropdown
- field-id="eks-region"
- field-name="eks-region"
- :items="regions"
- :loading="loading"
- :loading-text="s__('ClusterIntegration|Loading Regions')"
- :placeholder="s__('ClusterIntergation|Select a region')"
- :search-field-placeholder="s__('ClusterIntegration|Search regions')"
- :empty-text="s__('ClusterIntegration|No region found')"
- :has-errors="hasErrors"
- :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')"
- v-bind="$attrs"
- v-on="$listeners"
- />
- <p class="form-text text-muted" v-html="helpText"></p>
- </div>
-</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue
index 79029b8cfa8..ab33e9fbc95 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,3 +1,141 @@
+<script>
+import { GlFormInput } from '@gitlab/ui';
+import { sprintf, s__, __ } from '~/locale';
+import _ from 'underscore';
+import { mapState, mapActions } from 'vuex';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+
+export default {
+ components: {
+ GlFormInput,
+ LoadingButton,
+ ClipboardButton,
+ },
+ props: {
+ accountAndExternalIdsHelpPath: {
+ type: String,
+ required: true,
+ },
+ createRoleArnHelpPath: {
+ type: String,
+ required: true,
+ },
+ externalLinkIcon: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ roleArn: '',
+ };
+ },
+ computed: {
+ ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']),
+ submitButtonDisabled() {
+ return this.isCreatingRole || !this.roleArn;
+ },
+ submitButtonLabel() {
+ return this.isCreatingRole
+ ? __('Authenticating')
+ : s__('ClusterIntegration|Authenticate with AWS');
+ },
+ accountAndExternalIdsHelpText() {
+ const escapedUrl = _.escape(this.accountAndExternalIdsHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}',
+ ),
+ {
+ startAwsLink:
+ '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
+ startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ externalLinkIcon: this.externalLinkIcon,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ provisionRoleArnHelpText() {
+ const escapedUrl = _.escape(this.createRoleArnHelpPath);
+
+ return sprintf(
+ s__(
+ 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}',
+ ),
+ {
+ startAwsLink:
+ '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">',
+ startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ externalLinkIcon: this.externalLinkIcon,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ ...mapActions(['createRole']),
+ },
+};
+</script>
<template>
- <form name="service-credentials-form"></form>
+ <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })">
+ <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2>
+ <p>
+ {{
+ s__(
+ 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.',
+ )
+ }}
+ </p>
+ <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger">
+ {{ createRoleError }}
+ </div>
+ <div class="form-row">
+ <div class="form-group col-md-6">
+ <label for="gitlab-account-id">{{ __('Account ID') }}</label>
+ <div class="input-group">
+ <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" />
+ <div class="input-group-append">
+ <clipboard-button
+ :text="accountId"
+ :title="__('Copy Account ID to clipboard')"
+ class="input-group-text js-copy-account-id-button"
+ />
+ </div>
+ </div>
+ </div>
+ <div class="form-group col-md-6">
+ <label for="eks-external-id">{{ __('External ID') }}</label>
+ <div class="input-group">
+ <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" />
+ <div class="input-group-append">
+ <clipboard-button
+ :text="externalId"
+ :title="__('Copy External ID to clipboard')"
+ class="input-group-text js-copy-external-id-button"
+ />
+ </div>
+ </div>
+ </div>
+ <div class="col-12 mb-3 mt-n3">
+ <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p>
+ </div>
+ </div>
+ <div class="form-group">
+ <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label>
+ <gl-form-input id="eks-provision-role-arn" v-model="roleArn" />
+ <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p>
+ </div>
+ <loading-button
+ class="js-submit-service-credentials btn-success"
+ type="submit"
+ :disabled="submitButtonDisabled"
+ :loading="isCreatingRole"
+ :label="submitButtonLabel"
+ />
+ </form>
</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
index 339642f991e..a850ba89818 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js
@@ -1,7 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
-export const KUBERNETES_VERSIONS = [
- { name: '1.14', value: '1.14' },
- { name: '1.13', value: '1.13' },
- { name: '1.12', value: '1.12' },
- { name: '1.11', value: '1.11' },
-];
+export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }];
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js
index 1f595e9b2df..27f859d8972 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js
@@ -1,16 +1,54 @@
import Vue from 'vue';
import Vuex from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
import CreateEksCluster from './components/create_eks_cluster.vue';
import createStore from './store';
Vue.use(Vuex);
export default el => {
- const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset;
+ const {
+ gitlabManagedClusterHelpPath,
+ kubernetesIntegrationHelpPath,
+ accountAndExternalIdsHelpPath,
+ createRoleArnHelpPath,
+ getRolesPath,
+ getRegionsPath,
+ getKeyPairsPath,
+ getVpcsPath,
+ getSubnetsPath,
+ getSecurityGroupsPath,
+ getInstanceTypesPath,
+ externalId,
+ accountId,
+ hasCredentials,
+ createRolePath,
+ createClusterPath,
+ signOutPath,
+ externalLinkIcon,
+ } = el.dataset;
return new Vue({
el,
- store: createStore(),
+ store: createStore({
+ initialState: {
+ hasCredentials: parseBoolean(hasCredentials),
+ externalId,
+ accountId,
+ createRolePath,
+ createClusterPath,
+ signOutPath,
+ },
+ apiPaths: {
+ getRolesPath,
+ getRegionsPath,
+ getKeyPairsPath,
+ getVpcsPath,
+ getSubnetsPath,
+ getSecurityGroupsPath,
+ getInstanceTypesPath,
+ },
+ }),
components: {
CreateEksCluster,
},
@@ -19,6 +57,9 @@ export default el => {
props: {
gitlabManagedClusterHelpPath,
kubernetesIntegrationHelpPath,
+ accountAndExternalIdsHelpPath,
+ createRoleArnHelpPath,
+ externalLinkIcon,
},
});
},
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
index d982e4db4c1..21b87d525cf 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js
@@ -1,84 +1,58 @@
-import EC2 from 'aws-sdk/clients/ec2';
-import IAM from 'aws-sdk/clients/iam';
-
-export const fetchRoles = () => {
- const iam = new IAM();
-
- return iam
- .listRoles()
- .promise()
- .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name })));
-};
-
-export const fetchKeyPairs = () => {
- const ec2 = new EC2();
-
- return ec2
- .describeKeyPairs()
- .promise()
- .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name })));
-};
-
-export const fetchRegions = () => {
- const ec2 = new EC2();
-
- return ec2
- .describeRegions()
- .promise()
- .then(({ Regions: regions }) =>
- regions.map(({ RegionName: name }) => ({
- name,
- value: name,
+import axios from '~/lib/utils/axios_utils';
+
+export default apiPaths => ({
+ fetchRoles() {
+ return axios
+ .get(apiPaths.getRolesPath)
+ .then(({ data: { roles } }) =>
+ roles.map(({ role_name: name, arn: value }) => ({ name, value })),
+ );
+ },
+ fetchKeyPairs({ region }) {
+ return axios
+ .get(apiPaths.getKeyPairsPath, { params: { region } })
+ .then(({ data: { key_pairs: keyPairs } }) =>
+ keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })),
+ );
+ },
+ fetchRegions() {
+ return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) =>
+ regions.map(({ region_name }) => ({
+ name: region_name,
+ value: region_name,
})),
);
-};
-
-export const fetchVpcs = () => {
- const ec2 = new EC2();
-
- return ec2
- .describeVpcs()
- .promise()
- .then(({ Vpcs: vpcs }) =>
- vpcs.map(({ VpcId: id }) => ({
- value: id,
- name: id,
+ },
+ fetchVpcs({ region }) {
+ return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) =>
+ vpcs.map(({ vpc_id }) => ({
+ value: vpc_id,
+ name: vpc_id,
})),
);
-};
-
-export const fetchSubnets = ({ vpc }) => {
- const ec2 = new EC2();
-
- return ec2
- .describeSubnets({
- Filters: [
- {
- Name: 'vpc-id',
- Values: [vpc],
- },
- ],
- })
- .promise()
- .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id })));
-};
-
-export const fetchSecurityGroups = ({ vpc }) => {
- const ec2 = new EC2();
-
- return ec2
- .describeSecurityGroups({
- Filters: [
- {
- Name: 'vpc-id',
- Values: [vpc],
- },
- ],
- })
- .promise()
- .then(({ SecurityGroups: securityGroups }) =>
- securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })),
- );
-};
-
-export default () => {};
+ },
+ fetchSubnets({ vpc, region }) {
+ return axios
+ .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } })
+ .then(({ data: { subnets } }) =>
+ subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })),
+ );
+ },
+ fetchSecurityGroups({ vpc, region }) {
+ return axios
+ .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } })
+ .then(({ data: { security_groups: securityGroups } }) =>
+ securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })),
+ );
+ },
+ fetchInstanceTypes() {
+ return axios
+ .get(apiPaths.getInstanceTypesPath)
+ .then(({ data: { instance_types: instanceTypes } }) =>
+ instanceTypes.map(({ instance_type_name }) => ({
+ name: instance_type_name,
+ value: instance_type_name,
+ })),
+ );
+ },
+});
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
index 917c8da6c3e..72f15263a8f 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js
@@ -1,4 +1,12 @@
import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+
+const getErrorMessage = data => {
+ const errorKey = Object.keys(data)[0];
+
+ return data[errorKey][0];
+};
export const setClusterName = ({ commit }, payload) => {
commit(types.SET_CLUSTER_NAME, payload);
@@ -12,6 +20,68 @@ export const setKubernetesVersion = ({ commit }, payload) => {
commit(types.SET_KUBERNETES_VERSION, payload);
};
+export const createRole = ({ dispatch, state: { createRolePath } }, payload) => {
+ dispatch('requestCreateRole');
+
+ return axios
+ .post(createRolePath, {
+ role_arn: payload.roleArn,
+ role_external_id: payload.externalId,
+ })
+ .then(() => dispatch('createRoleSuccess'))
+ .catch(error => dispatch('createRoleError', { error }));
+};
+
+export const requestCreateRole = ({ commit }) => {
+ commit(types.REQUEST_CREATE_ROLE);
+};
+
+export const createRoleSuccess = ({ commit }) => {
+ commit(types.CREATE_ROLE_SUCCESS);
+};
+
+export const createRoleError = ({ commit }, payload) => {
+ commit(types.CREATE_ROLE_ERROR, payload);
+};
+
+export const createCluster = ({ dispatch, state }) => {
+ dispatch('requestCreateCluster');
+
+ return axios
+ .post(state.createClusterPath, {
+ name: state.clusterName,
+ environment_scope: state.environmentScope,
+ managed: state.gitlabManagedCluster,
+ provider_aws_attributes: {
+ region: state.selectedRegion,
+ vpc_id: state.selectedVpc,
+ subnet_ids: state.selectedSubnet,
+ role_arn: state.selectedRole,
+ key_name: state.selectedKeyPair,
+ security_group_id: state.selectedSecurityGroup,
+ instance_type: state.selectedInstanceType,
+ num_nodes: state.nodeCount,
+ },
+ })
+ .then(({ headers: { location } }) => dispatch('createClusterSuccess', location))
+ .catch(({ response: { data } }) => {
+ dispatch('createClusterError', data);
+ });
+};
+
+export const requestCreateCluster = ({ commit }) => {
+ commit(types.REQUEST_CREATE_CLUSTER);
+};
+
+export const createClusterSuccess = (_, location) => {
+ window.location.assign(location);
+};
+
+export const createClusterError = ({ commit }, error) => {
+ commit(types.CREATE_CLUSTER_ERROR, error);
+ createFlash(getErrorMessage(error));
+};
+
export const setRegion = ({ commit }, payload) => {
commit(types.SET_REGION, payload);
};
@@ -40,4 +110,16 @@ export const setGitlabManagedCluster = ({ commit }, payload) => {
commit(types.SET_GITLAB_MANAGED_CLUSTER, payload);
};
-export default () => {};
+export const setInstanceType = ({ commit }, payload) => {
+ commit(types.SET_INSTANCE_TYPE, payload);
+};
+
+export const setNodeCount = ({ commit }, payload) => {
+ commit(types.SET_NODE_COUNT, payload);
+};
+
+export const signOut = ({ commit, state: { signOutPath } }) =>
+ axios
+ .delete(signOutPath)
+ .then(() => commit(types.SIGN_OUT))
+ .catch(({ response: { data } }) => createFlash(getErrorMessage(data)));
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
index d575deafd19..5982fc8a2fd 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -6,14 +6,16 @@ import state from './state';
import clusterDropdownStore from './cluster_dropdown';
-import * as awsServices from '../services/aws_services_facade';
+import awsServicesFactory from '../services/aws_services_facade';
-const createStore = () =>
- new Vuex.Store({
+const createStore = ({ initialState, apiPaths }) => {
+ const awsServices = awsServicesFactory(apiPaths);
+
+ return new Vuex.Store({
actions,
getters,
mutations,
- state: state(),
+ state: Object.assign(state(), initialState),
modules: {
roles: {
namespaced: true,
@@ -39,7 +41,12 @@ const createStore = () =>
namespaced: true,
...clusterDropdownStore(awsServices.fetchSecurityGroups),
},
+ instanceTypes: {
+ namespaced: true,
+ ...clusterDropdownStore(awsServices.fetchInstanceTypes),
+ },
},
});
+};
export default createStore;
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
index 82eb512ac07..f9204cc2207 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js
@@ -7,4 +7,13 @@ export const SET_KEY_PAIR = 'SET_KEY_PAIR';
export const SET_SUBNET = 'SET_SUBNET';
export const SET_ROLE = 'SET_ROLE';
export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP';
+export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE';
+export const SET_NODE_COUNT = 'SET_NODE_COUNT';
export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER';
+export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE';
+export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS';
+export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR';
+export const SIGN_OUT = 'SIGN_OUT';
+export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER';
+export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS';
+export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR';
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
index 79950ac7dce..aa04c8f7079 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js
@@ -28,7 +28,39 @@ export default {
[types.SET_SECURITY_GROUP](state, { securityGroup }) {
state.selectedSecurityGroup = securityGroup;
},
+ [types.SET_INSTANCE_TYPE](state, { instanceType }) {
+ state.selectedInstanceType = instanceType;
+ },
+ [types.SET_NODE_COUNT](state, { nodeCount }) {
+ state.nodeCount = nodeCount;
+ },
[types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) {
state.gitlabManagedCluster = gitlabManagedCluster;
},
+ [types.REQUEST_CREATE_ROLE](state) {
+ state.isCreatingRole = true;
+ state.createRoleError = null;
+ state.hasCredentials = false;
+ },
+ [types.CREATE_ROLE_SUCCESS](state) {
+ state.isCreatingRole = false;
+ state.createRoleError = null;
+ state.hasCredentials = true;
+ },
+ [types.CREATE_ROLE_ERROR](state, { error }) {
+ state.isCreatingRole = false;
+ state.createRoleError = error;
+ state.hasCredentials = false;
+ },
+ [types.REQUEST_CREATE_CLUSTER](state) {
+ state.isCreatingCluster = true;
+ state.createClusterError = null;
+ },
+ [types.CREATE_CLUSTER_ERROR](state, { error }) {
+ state.isCreatingCluster = false;
+ state.createClusterError = error;
+ },
+ [types.SIGN_OUT](state) {
+ state.hasCredentials = false;
+ },
};
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
index bf74213bdce..2e3a05a9187 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js
@@ -1,18 +1,31 @@
import { KUBERNETES_VERSIONS } from '../constants';
+const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS;
+
export default () => ({
- isValidatingCredentials: false,
- validCredentials: false,
+ createRolePath: null,
+
+ isCreatingRole: false,
+ roleCreated: false,
+ createRoleError: false,
+
+ accountId: '',
+ externalId: '',
clusterName: '',
environmentScope: '*',
- kubernetesVersion: [KUBERNETES_VERSIONS].value,
+ kubernetesVersion,
selectedRegion: '',
selectedRole: '',
selectedKeyPair: '',
selectedVpc: '',
selectedSubnet: '',
selectedSecurityGroup: '',
+ selectedInstanceType: 'm5.large',
+ nodeCount: '3',
+
+ isCreatingCluster: false,
+ createClusterError: false,
gitlabManagedCluster: true,
});
diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js
index 0ec4d8807b0..0ec4d8807b0 100644
--- a/app/assets/javascripts/projects/gke_cluster_namespace/index.js
+++ b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js
diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js
new file mode 100644
index 00000000000..7c984582fd8
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/init_create_cluster.js
@@ -0,0 +1,37 @@
+import initGkeDropdowns from './gke_cluster';
+import initGkeNamespace from './gke_cluster_namespace';
+import PersistentUserCallout from '~/persistent_user_callout';
+
+const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user'];
+
+const isProjectLevelCluster = page => page.startsWith('project:clusters');
+
+export default (document, gon) => {
+ const { page } = document.body.dataset;
+ const isNewClusterView = newClusterViews.some(view => page.endsWith(view));
+
+ if (!isNewClusterView) {
+ return;
+ }
+
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+
+ initGkeDropdowns();
+
+ if (gon.features.createEksClusters) {
+ import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
+ .then(({ default: initCreateEKSCluster }) => {
+ const el = document.querySelector('.js-create-eks-cluster-form-container');
+
+ if (el) {
+ initCreateEKSCluster(el);
+ }
+ })
+ .catch(() => {});
+ }
+
+ if (isProjectLevelCluster(page)) {
+ initGkeNamespace();
+ }
+};
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
deleted file mode 100644
index fc6d83bf96c..00000000000
--- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue
+++ /dev/null
@@ -1,44 +0,0 @@
-<script>
-import Icon from '~/vue_shared/components/icon.vue';
-import { GlButton } from '@gitlab/ui';
-
-export default {
- name: 'StageCardListItem',
- components: {
- Icon,
- GlButton,
- },
- props: {
- isActive: {
- type: Boolean,
- required: true,
- },
- canEdit: {
- type: Boolean,
- default: false,
- required: false,
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="{ active: isActive }"
- class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px"
- >
- <slot></slot>
- <div v-if="canEdit" class="dropdown">
- <gl-button
- :title="__('More actions')"
- class="more-actions-toggle btn btn-transparent p-0"
- data-toggle="dropdown"
- >
- <icon class="icon" name="ellipsis_v" />
- </gl-button>
- <ul class="more-actions-dropdown dropdown-menu dropdown-open-left">
- <slot name="dropdown-options"></slot>
- </ul>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
index 004d335f572..1b09fe1b370 100644
--- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
+++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue
@@ -1,11 +1,6 @@
<script>
-import StageCardListItem from './stage_card_list_item.vue';
-
export default {
name: 'StageNavItem',
- components: {
- StageCardListItem,
- },
props: {
isDefaultStage: {
type: Boolean,
@@ -40,16 +35,16 @@ export default {
hasValue() {
return this.value && this.value.length > 0;
},
- editable() {
- return this.isUserAllowed && this.canEdit;
- },
},
};
</script>
<template>
<li @click="$emit('select')">
- <stage-card-list-item :is-active="isActive" :can-edit="editable">
+ <div
+ :class="{ active: isActive }"
+ class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px"
+ >
<div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }">
{{ title }}
</div>
@@ -62,27 +57,6 @@ export default {
<span class="not-available">{{ __('Not available') }}</span>
</template>
</div>
- <template v-slot:dropdown-options>
- <template v-if="isDefaultStage">
- <li>
- <button type="button" class="btn-default btn-transparent">
- {{ __('Hide stage') }}
- </button>
- </li>
- </template>
- <template v-else>
- <li>
- <button type="button" class="btn-default btn-transparent">
- {{ __('Edit stage') }}
- </button>
- </li>
- <li>
- <button type="button" class="btn-danger danger">
- {{ __('Remove stage') }}
- </button>
- </li>
- </template>
- </template>
- </stage-card-list-item>
+ </div>
</li>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 9a1e59ec045..a5ffa84e3fb 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -124,8 +124,10 @@ export default {
:diff-viewer-mode="diffViewerMode"
:new-path="diffFile.new_path"
:new-sha="diffFile.diff_refs.head_sha"
+ :new-size="diffFile.new_size"
:old-path="diffFile.old_path"
:old-sha="diffFile.diff_refs.base_sha"
+ :old-size="diffFile.old_size"
:file-hash="diffFileHash"
:project-path="projectPath"
:a-mode="diffFile.a_mode"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 2514274224d..9236f0d5349 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -54,11 +54,12 @@ export default {
showLoadingIcon() {
return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
},
- hasDiffLines() {
+ hasDiff() {
return (
- this.file.highlighted_diff_lines &&
- this.file.parallel_diff_lines &&
- this.file.parallel_diff_lines.length > 0
+ (this.file.highlighted_diff_lines &&
+ this.file.parallel_diff_lines &&
+ this.file.parallel_diff_lines.length > 0) ||
+ !this.file.blob.readable_text
);
},
isFileTooLarge() {
@@ -82,7 +83,7 @@ export default {
},
watch: {
isCollapsed: function fileCollapsedWatch(newVal, oldVal) {
- if (!newVal && oldVal && !this.hasDiffLines) {
+ if (!newVal && oldVal && !this.hasDiff) {
this.handleLoadCollapsedDiff();
}
@@ -103,7 +104,7 @@ export default {
'setFileCollapsed',
]),
handleToggle() {
- if (!this.hasDiffLines) {
+ if (!this.hasDiff) {
this.handleLoadCollapsedDiff();
} else {
this.isCollapsed = !this.isCollapsed;
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 0ff26445a6a..62b390a46d7 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -290,5 +290,5 @@ export default function dropzoneInput(form) {
formTextarea.focus();
});
- return Dropzone.forElement($formDropzone.get(0));
+ return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null;
}
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
new file mode 100644
index 00000000000..37c9818f869
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -0,0 +1,141 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import dateFormat from 'dateformat';
+import { __, sprintf } from '~/locale';
+import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import Stacktrace from './stacktrace.vue';
+import TrackEventDirective from '~/vue_shared/directives/track_event';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { trackClickErrorLinkToSentryOptions } from '../utils';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ TooltipOnTruncate,
+ Icon,
+ Stacktrace,
+ },
+ directives: {
+ TrackEvent: TrackEventDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ issueDetailsPath: {
+ type: String,
+ required: true,
+ },
+ issueStackTracePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']),
+ ...mapGetters('details', ['stacktrace']),
+ reported() {
+ return sprintf(
+ __('Reported %{timeAgo} by %{reportedBy}'),
+ {
+ reportedBy: `<strong>${this.error.culprit}</strong>`,
+ timeAgo: this.timeFormated(this.stacktraceData.date_received),
+ },
+ false,
+ );
+ },
+ firstReleaseLink() {
+ return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`;
+ },
+ lastReleaseLink() {
+ return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`;
+ },
+ showDetails() {
+ return Boolean(!this.loading && this.error && this.error.id);
+ },
+ showStacktrace() {
+ return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
+ },
+ },
+ mounted() {
+ this.startPollingDetails(this.issueDetailsPath);
+ this.startPollingStacktrace(this.issueStackTracePath);
+ },
+ methods: {
+ ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']),
+ trackClickErrorLinkToSentryOptions,
+ formatDate(date) {
+ return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="loading" class="py-3">
+ <gl-loading-icon :size="3" />
+ </div>
+
+ <div v-else-if="showDetails" class="error-details">
+ <div class="top-area align-items-center justify-content-between py-3">
+ <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span>
+ <!-- <gl-button class="my-3 ml-auto" variant="success">
+ {{ __('Create Issue') }}
+ </gl-button>-->
+ </div>
+ <div>
+ <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
+ <h2 class="text-truncate">{{ error.title }}</h2>
+ </tooltip-on-truncate>
+ <h3>{{ __('Error details') }}</h3>
+ <ul>
+ <li>
+ <span class="bold">{{ __('Sentry event') }}:</span>
+ <gl-link
+ v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)"
+ :href="error.external_url"
+ target="_blank"
+ >
+ <span class="text-truncate">{{ error.external_url }}</span>
+ <icon name="external-link" class="ml-1 flex-shrink-0" />
+ </gl-link>
+ </li>
+ <li v-if="error.first_release_short_version">
+ <span class="bold">{{ __('First seen') }}:</span>
+ {{ formatDate(error.first_seen) }}
+ <gl-link :href="firstReleaseLink" target="_blank">
+ <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span>
+ </gl-link>
+ </li>
+ <li v-if="error.last_release_short_version">
+ <span class="bold">{{ __('Last seen') }}:</span>
+ {{ formatDate(error.last_seen) }}
+ <gl-link :href="lastReleaseLink" target="_blank">
+ <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span>
+ </gl-link>
+ </li>
+ <li>
+ <span class="bold">{{ __('Events') }}:</span>
+ <span>{{ error.count }}</span>
+ </li>
+ <li>
+ <span class="bold">{{ __('Users') }}:</span>
+ <span>{{ error.user_count }}</span>
+ </li>
+ </ul>
+
+ <div v-if="loadingStacktrace" class="py-3">
+ <gl-loading-icon :size="3" />
+ </div>
+
+ <template v-if="showStacktrace">
+ <h3 class="my-4">{{ __('Stack trace') }}</h3>
+ <stacktrace :entries="stacktrace" />
+ </template>
+ </div>
+ </div>
+ </div>
+</template>
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 cd298e2c692..88139ce7403 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -1,11 +1,19 @@
<script>
-import { mapActions, mapState } from 'vuex';
-import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import {
+ GlEmptyState,
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { visitUrl } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { __ } from '~/locale';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils';
+import { trackViewInSentryOptions } from '../utils';
export default {
fields: [
@@ -20,6 +28,7 @@ export default {
GlLink,
GlLoadingIcon,
GlTable,
+ GlSearchBoxByType,
Icon,
TimeAgo,
},
@@ -48,8 +57,17 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ errorSearchQuery: '',
+ };
+ },
computed: {
- ...mapState(['errors', 'externalUrl', 'loading']),
+ ...mapState('list', ['errors', 'externalUrl', 'loading']),
+ ...mapGetters('list', ['filterErrorsByTitle']),
+ filteredErrors() {
+ return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors;
+ },
},
created() {
if (this.errorTrackingEnabled) {
@@ -57,9 +75,11 @@ export default {
}
},
methods: {
- ...mapActions(['startPolling', 'restartPolling']),
+ ...mapActions('list', ['startPolling', 'restartPolling']),
trackViewInSentryOptions,
- trackClickErrorLinkToSentryOptions,
+ viewDetails(errorId) {
+ visitUrl(`error_tracking/${errorId}/details`);
+ },
},
};
</script>
@@ -71,10 +91,17 @@ export default {
<gl-loading-icon :size="3" />
</div>
<div v-else>
- <div class="d-flex justify-content-end">
+ <div class="d-flex flex-row justify-content-around bg-secondary border">
+ <gl-search-box-by-type
+ v-model="errorSearchQuery"
+ class="col-lg-10 m-3 p-0"
+ :placeholder="__('Search or filter results...')"
+ type="search"
+ autofocus
+ />
<gl-button
v-track-event="trackViewInSentryOptions(externalUrl)"
- class="my-3 ml-auto"
+ class="m-3"
variant="primary"
:href="externalUrl"
target="_blank"
@@ -84,7 +111,14 @@ export default {
</gl-button>
</div>
- <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm">
+ <gl-table
+ class="mt-3"
+ :items="filteredErrors"
+ :fields="$options.fields"
+ :show-empty="true"
+ fixed
+ stacked="sm"
+ >
<template slot="HEAD_events" slot-scope="data">
<div class="text-md-right">{{ data.label }}</div>
</template>
@@ -94,13 +128,11 @@ export default {
<template slot="error" slot-scope="errors">
<div class="d-flex flex-column">
<gl-link
- v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)"
- :href="errors.item.externalUrl"
class="d-flex text-dark"
target="_blank"
+ @click="viewDetails(errors.item.id)"
>
<strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
- <icon name="external-link" class="ml-1 flex-shrink-0" />
</gl-link>
<span class="text-secondary text-truncate">
{{ errors.item.culprit }}
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue
new file mode 100644
index 00000000000..6b71967624f
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue
@@ -0,0 +1,33 @@
+<script>
+import StackTraceEntry from './stacktrace_entry.vue';
+
+export default {
+ components: {
+ StackTraceEntry,
+ },
+ props: {
+ entries: {
+ type: Array,
+ required: true,
+ },
+ },
+ methods: {
+ isFirstEntry(index) {
+ return index === 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="stacktrace">
+ <stack-trace-entry
+ v-for="(entry, index) in entries"
+ :key="`stacktrace-entry-${index}`"
+ :lines="entry.context"
+ :file-path="entry.filename"
+ :error-line="entry.lineNo"
+ :expanded="isFirstEntry(index)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
new file mode 100644
index 00000000000..ad542c579a9
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlTooltip } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ ClipboardButton,
+ FileIcon,
+ Icon,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ lines: {
+ type: Array,
+ required: true,
+ },
+ filePath: {
+ type: String,
+ required: true,
+ },
+ errorLine: {
+ type: Number,
+ required: true,
+ },
+ expanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ isExpanded: this.expanded,
+ };
+ },
+ computed: {
+ linesLength() {
+ return this.lines.length;
+ },
+ collapseIcon() {
+ return this.isExpanded ? 'chevron-down' : 'chevron-right';
+ },
+ },
+ methods: {
+ isHighlighted(lineNum) {
+ return lineNum === this.errorLine;
+ },
+ toggle() {
+ this.isExpanded = !this.isExpanded;
+ },
+ lineNum(line) {
+ return line[0];
+ },
+ lineCode(line) {
+ return line[1];
+ },
+ },
+ userColorScheme: window.gon.user_color_scheme,
+};
+</script>
+
+<template>
+ <div class="file-holder">
+ <div ref="header" class="file-title file-title-flex-parent">
+ <div class="file-header-content ">
+ <div class="d-inline-block cursor-pointer" @click="toggle()">
+ <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" />
+ </div>
+ <div class="d-inline-block append-right-4">
+ <file-icon
+ :file-name="filePath"
+ :size="18"
+ aria-hidden="true"
+ css-classes="append-right-5"
+ />
+ <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body">
+ {{ filePath }}
+ </strong>
+ </div>
+
+ <clipboard-button
+ :title="__('Copy file path')"
+ :text="filePath"
+ css-class="btn-default btn-transparent btn-clipboard"
+ />
+ </div>
+ </div>
+
+ <table v-if="isExpanded" :class="$options.userColorScheme" class="code js-syntax-highlight">
+ <tbody>
+ <template v-for="(line, index) in lines">
+ <tr :key="`stacktrace-line-${index}`" class="line_holder">
+ <td class="diff-line-num" :class="{ old: isHighlighted(lineNum(line)) }">
+ {{ lineNum(line) }}
+ </td>
+ <td
+ class="line_content"
+ :class="{ old: isHighlighted(lineNum(line)) }"
+ v-html="lineCode(line)"
+ ></td>
+ </tr>
+ </template>
+ </tbody>
+ </table>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js
new file mode 100644
index 00000000000..b9b51a6539f
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/details.js
@@ -0,0 +1,25 @@
+import Vue from 'vue';
+import store from './store';
+import ErrorDetails from './components/error_details.vue';
+
+export default () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-error_details',
+ components: {
+ ErrorDetails,
+ },
+ store,
+ render(createElement) {
+ const domEl = document.querySelector(this.$options.el);
+ const { issueDetailsPath, issueStackTracePath } = domEl.dataset;
+
+ return createElement('error-details', {
+ props: {
+ issueDetailsPath,
+ issueStackTracePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/list.js
index 073e2c8f1c7..073e2c8f1c7 100644
--- a/app/assets/javascripts/error_tracking/index.js
+++ b/app/assets/javascripts/error_tracking/list.js
diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js
index ab89521dc46..68988296cc2 100644
--- a/app/assets/javascripts/error_tracking/services/index.js
+++ b/app/assets/javascripts/error_tracking/services/index.js
@@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
export default {
- getErrorList({ endpoint }) {
+ getSentryData({ endpoint }) {
return axios.get(endpoint);
},
};
diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js
new file mode 100644
index 00000000000..0390bca7175
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/details/actions.js
@@ -0,0 +1,63 @@
+import service from '../../services';
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import Poll from '~/lib/utils/poll';
+import { __ } from '~/locale';
+
+let stackTracePoll;
+let detailPoll;
+
+const stopPolling = poll => {
+ if (poll) poll.stop();
+};
+
+export function startPollingDetails({ commit }, endpoint) {
+ detailPoll = new Poll({
+ resource: service,
+ method: 'getSentryData',
+ data: { endpoint },
+ successCallback: ({ data }) => {
+ if (!data) {
+ detailPoll.restart();
+ return;
+ }
+
+ commit(types.SET_ERROR, data.error);
+ commit(types.SET_LOADING, false);
+
+ stopPolling(detailPoll);
+ },
+ errorCallback: () => {
+ commit(types.SET_LOADING, false);
+ createFlash(__('Failed to load error details from Sentry.'));
+ },
+ });
+
+ detailPoll.makeRequest();
+}
+
+export function startPollingStacktrace({ commit }, endpoint) {
+ stackTracePoll = new Poll({
+ resource: service,
+ method: 'getSentryData',
+ data: { endpoint },
+ successCallback: ({ data }) => {
+ if (!data) {
+ stackTracePoll.restart();
+ return;
+ }
+ commit(types.SET_STACKTRACE_DATA, data.error);
+ commit(types.SET_LOADING_STACKTRACE, false);
+
+ stopPolling(stackTracePoll);
+ },
+ errorCallback: () => {
+ commit(types.SET_LOADING_STACKTRACE, false);
+ createFlash(__('Failed to load stacktrace.'));
+ },
+ });
+
+ stackTracePoll.makeRequest();
+}
+
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js
new file mode 100644
index 00000000000..7d13439d721
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/details/getters.js
@@ -0,0 +1,3 @@
+export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse();
+
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js
new file mode 100644
index 00000000000..a2592253a2d
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_ERROR = 'SET_ERRORS';
+export const SET_LOADING = 'SET_LOADING';
+export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE';
+export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA';
diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js
new file mode 100644
index 00000000000..6f4720444e0
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/details/mutations.js
@@ -0,0 +1,16 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ERROR](state, data) {
+ state.error = data;
+ },
+ [types.SET_LOADING](state, loading) {
+ state.loading = loading;
+ },
+ [types.SET_LOADING_STACKTRACE](state, data) {
+ state.loadingStacktrace = data;
+ },
+ [types.SET_STACKTRACE_DATA](state, data) {
+ state.stacktraceData = data;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js
new file mode 100644
index 00000000000..95fb0ba0558
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/details/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ error: {},
+ stacktraceData: {},
+ loading: true,
+ loadingStacktrace: true,
+});
diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js
index 3136682fb64..941c752e96a 100644
--- a/app/assets/javascripts/error_tracking/store/index.js
+++ b/app/assets/javascripts/error_tracking/store/index.js
@@ -1,19 +1,36 @@
import Vue from 'vue';
import Vuex from 'vuex';
-import * as actions from './actions';
-import mutations from './mutations';
+
+import * as listActions from './list/actions';
+import listMutations from './list/mutations';
+import listState from './list/state';
+import * as listGetters from './list/getters';
+
+import * as detailsActions from './details/actions';
+import detailsMutations from './details/mutations';
+import detailsState from './details/state';
+import * as detailsGetters from './details/getters';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
- state: {
- errors: [],
- externalUrl: '',
- loading: true,
+ modules: {
+ list: {
+ namespaced: true,
+ state: listState(),
+ actions: listActions,
+ mutations: listMutations,
+ getters: listGetters,
+ },
+ details: {
+ namespaced: true,
+ state: detailsState(),
+ actions: detailsActions,
+ mutations: detailsMutations,
+ getters: detailsGetters,
+ },
},
- actions,
- mutations,
});
export default createStore();
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index 1e754a4f54f..18c6e5e9695 100644
--- a/app/assets/javascripts/error_tracking/store/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/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 Poll from '~/lib/utils/poll';
@@ -9,7 +9,7 @@ let eTagPoll;
export function startPolling({ commit, dispatch }, endpoint) {
eTagPoll = new Poll({
resource: Service,
- method: 'getErrorList',
+ method: 'getSentryData',
data: { endpoint },
successCallback: ({ data }) => {
if (!data) {
diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js
new file mode 100644
index 00000000000..1a2ec62f79f
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/list/getters.js
@@ -0,0 +1,4 @@
+export const filterErrorsByTitle = state => errorQuery =>
+ state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i')));
+
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
index f9d77a6b08e..f9d77a6b08e 100644
--- a/app/assets/javascripts/error_tracking/store/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index e4bd81db9c9..e4bd81db9c9 100644
--- a/app/assets/javascripts/error_tracking/store/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js
new file mode 100644
index 00000000000..d371350ef0e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/list/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ errors: [],
+ externalUrl: '',
+ loading: true,
+});
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
index 50eb3e63b7c..786abc8ce49 100644
--- a/app/assets/javascripts/error_tracking_settings/components/app.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -43,16 +43,7 @@ export default {
'isProjectInvalid',
'projectSelectionLabel',
]),
- ...mapState([
- 'apiHost',
- 'connectError',
- 'connectSuccessful',
- 'enabled',
- 'projects',
- 'selectedProject',
- 'settingsLoading',
- 'token',
- ]),
+ ...mapState(['enabled', 'projects', 'selectedProject', 'settingsLoading', 'token']),
},
created() {
this.setInitialState({
@@ -65,15 +56,7 @@ export default {
});
},
methods: {
- ...mapActions([
- 'fetchProjects',
- 'setInitialState',
- 'updateApiHost',
- 'updateEnabled',
- 'updateSelectedProject',
- 'updateSettings',
- 'updateToken',
- ]),
+ ...mapActions(['setInitialState', 'updateEnabled', 'updateSelectedProject', 'updateSettings']),
handleSubmit() {
this.updateSettings();
},
@@ -95,15 +78,7 @@ export default {
s__('ErrorTracking|Active')
}}</label>
</div>
- <error-tracking-form
- :api-host="apiHost"
- :connect-error="connectError"
- :connect-successful="connectSuccessful"
- :token="token"
- @handle-connect="fetchProjects"
- @update-api-host="updateApiHost"
- @update-token="updateToken"
- />
+ <error-tracking-form />
<div class="form-group">
<project-dropdown
:has-projects="hasProjects"
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index a734e8527dd..d86116aa315 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -1,32 +1,20 @@
<script>
-import { GlButton, GlFormInput } from '@gitlab/ui';
+import { mapActions, mapState } from 'vuex';
+import { GlFormInput } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
export default {
- components: { GlButton, GlFormInput, Icon },
- props: {
- apiHost: {
- type: String,
- required: true,
- },
- connectError: {
- type: Boolean,
- required: true,
- },
- connectSuccessful: {
- type: Boolean,
- required: true,
- },
- token: {
- type: String,
- required: true,
- },
- },
+ components: { GlFormInput, Icon, LoadingButton },
computed: {
+ ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']),
tokenInputState() {
return this.connectError ? false : null;
},
},
+ methods: {
+ ...mapActions(['fetchProjects', 'updateApiHost', 'updateToken']),
+ },
};
</script>
@@ -40,8 +28,9 @@ export default {
<gl-form-input
id="error-tracking-api-host"
:value="apiHost"
+ :disabled="isLoadingProjects"
placeholder="https://mysentryserver.com"
- @input="$emit('update-api-host', $event)"
+ @input="updateApiHost"
/>
<!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings -->
</div>
@@ -60,15 +49,17 @@ export default {
id="error-tracking-token"
:value="token"
:state="tokenInputState"
- @input="$emit('update-token', $event)"
+ :disabled="isLoadingProjects"
+ @input="updateToken"
/>
</div>
<div class="col-4 col-md-3 gl-pl-0">
- <gl-button
- class="js-error-tracking-connect prepend-left-5"
- @click="$emit('handle-connect')"
- >{{ __('Connect') }}</gl-button
- >
+ <loading-button
+ class="js-error-tracking-connect prepend-left-5 d-inline-flex"
+ :label="isLoadingProjects ? __('Connecting') : __('Connect')"
+ :loading="isLoadingProjects"
+ @click="fetchProjects"
+ />
<icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index 95105797807..6b540ea7dfd 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -6,17 +6,20 @@ import { transformFrontendSettings } from '../utils';
import * as types from './mutation_types';
export const requestProjects = ({ commit }) => {
+ commit(types.SET_PROJECTS_LOADING, true);
commit(types.RESET_CONNECT);
};
export const receiveProjectsSuccess = ({ commit }, projects) => {
commit(types.UPDATE_CONNECT_SUCCESS);
commit(types.RECEIVE_PROJECTS, projects);
+ commit(types.SET_PROJECTS_LOADING, false);
};
export const receiveProjectsError = ({ commit }) => {
commit(types.UPDATE_CONNECT_ERROR);
commit(types.CLEAR_PROJECTS);
+ commit(types.SET_PROJECTS_LOADING, false);
};
export const fetchProjects = ({ dispatch, state }) => {
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
index b4f8a237947..bf3df383ddc 100644
--- a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
+++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
@@ -9,3 +9,4 @@ export const UPDATE_ENABLED = 'UPDATE_ENABLED';
export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
export const UPDATE_TOKEN = 'UPDATE_TOKEN';
+export const SET_PROJECTS_LOADING = 'SET_PROJECTS_LOADING';
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
index 4089d1ee94e..133f25264b9 100644
--- a/app/assets/javascripts/error_tracking_settings/store/mutations.js
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -58,4 +58,7 @@ export default {
state.connectSuccessful = false;
state.connectError = true;
},
+ [types.SET_PROJECTS_LOADING](state, loading) {
+ state.isLoadingProjects = loading;
+ },
};
diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js
index 98219d33f4d..ab616f11e83 100644
--- a/app/assets/javascripts/error_tracking_settings/store/state.js
+++ b/app/assets/javascripts/error_tracking_settings/store/state.js
@@ -3,6 +3,7 @@ export default () => ({
enabled: false,
token: '',
projects: [],
+ isLoadingProjects: false,
selectedProject: null,
settingsLoading: false,
connectSuccessful: false,
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index f280f3cd26c..5fa07045d5e 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -13,6 +13,7 @@ export default class AvailableDropdownMappings {
runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
+ releasesEndpoint,
groupsOnly,
includeAncestorGroups,
includeDescendantGroups,
@@ -21,6 +22,7 @@ export default class AvailableDropdownMappings {
this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
+ this.releasesEndpoint = releasesEndpoint;
this.groupsOnly = groupsOnly;
this.includeAncestorGroups = includeAncestorGroups;
this.includeDescendantGroups = includeDescendantGroups;
@@ -70,6 +72,19 @@ export default class AvailableDropdownMappings {
},
element: this.container.querySelector('#js-dropdown-milestone'),
},
+ release: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getReleasesEndpoint(),
+ symbol: '',
+
+ // The DropdownNonUser class is hardcoded to look for and display a
+ // "title" property, so we need to add this property to each release object
+ preprocessing: releases => releases.map(r => ({ ...r, title: r.tag })),
+ },
+ element: this.container.querySelector('#js-dropdown-release'),
+ },
label: {
reference: null,
gl: DropdownNonUser,
@@ -130,6 +145,10 @@ export default class AvailableDropdownMappings {
return `${this.milestonesEndpoint}.json`;
}
+ getReleasesEndpoint() {
+ return `${this.releasesEndpoint}.json`;
+ }
+
getLabelsEndpoint() {
let endpoint = `${this.labelsEndpoint}.json?`;
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 835d3bf8a53..5ff95f45be4 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager {
runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
+ releasesEndpoint = '',
tokenizer,
page,
isGroup,
@@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager {
isGroupDecendent,
filteredSearchTokenKeys,
}) {
+ const removeTrailingSlash = url => url.replace(/\/$/, '');
+
this.container = FilteredSearchContainer.container;
- this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, '');
- this.labelsEndpoint = labelsEndpoint.replace(/\/$/, '');
- this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, '');
+ this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint);
+ this.labelsEndpoint = removeTrailingSlash(labelsEndpoint);
+ this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint);
+ this.releasesEndpoint = removeTrailingSlash(releasesEndpoint);
this.tokenizer = tokenizer;
this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
@@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager {
this.runnerTagsEndpoint,
this.labelsEndpoint,
this.milestonesEndpoint,
+ this.releasesEndpoint,
this.groupsOnly,
this.includeAncestorGroups,
this.includeDescendantGroups,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index fd335362e5b..5c2d32f4e85 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -89,6 +89,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '',
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '',
+ releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '',
tokenizer: this.tokenizer,
page: this.page,
isGroup: this.isGroup,
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index 6c3d9e33420..414bcf186a3 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,7 +1,9 @@
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
-export const tokenKeys = [
+export const tokenKeys = [];
+
+tokenKeys.push(
{
key: 'author',
type: 'string',
@@ -26,15 +28,27 @@ export const tokenKeys = [
icon: 'clock',
tag: '%milestone',
},
- {
- key: 'label',
- type: 'array',
- param: 'name[]',
- symbol: '~',
- icon: 'labels',
- tag: '~label',
- },
-];
+);
+
+if (gon && gon.features && gon.features.releaseSearchFilter) {
+ tokenKeys.push({
+ key: 'release',
+ type: 'string',
+ param: 'tag',
+ symbol: '',
+ icon: 'rocket',
+ tag: __('tag name'),
+ });
+}
+
+tokenKeys.push({
+ key: 'label',
+ type: 'array',
+ param: 'name[]',
+ symbol: '~',
+ icon: 'labels',
+ tag: '~label',
+});
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
@@ -89,6 +103,16 @@ export const conditions = [
value: __('Started'),
},
{
+ url: 'release_tag=None',
+ tokenKey: 'release',
+ value: __('None'),
+ },
+ {
+ url: 'release_tag=Any',
+ tokenKey: 'release',
+ value: __('Any'),
+ },
+ {
url: 'label_name[]=None',
tokenKey: 'label',
value: __('None'),
diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js
index fc9c5827ed4..2c3320b5e79 100644
--- a/app/assets/javascripts/flash.js
+++ b/app/assets/javascripts/flash.js
@@ -37,7 +37,7 @@ const createAction = config => `
`;
const createFlashEl = (message, type) => `
- <div class="flash-content flash-${type} rounded">
+ <div class="flash-${type}">
<div class="flash-text">
${_.escape(message)}
<div class="close-icon-wrapper js-close-icon">
diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js
index 41b660a243f..92ac3a2c94d 100644
--- a/app/assets/javascripts/frequent_items/store/mutations.js
+++ b/app/assets/javascripts/frequent_items/store/mutations.js
@@ -47,7 +47,8 @@ export default {
hasSearchQuery: true,
});
},
- [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) {
+ [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) {
+ const rawItems = results.data;
Object.assign(state, {
items: rawItems.map(rawItem => ({
id: rawItem.id,
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index 4e1b4f2652c..045f77af7ea 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -617,7 +617,7 @@ GitLabDropdown = (function() {
GitLabDropdown.prototype.hidden = function(e) {
var $input;
this.resetRows();
- this.removeArrayKeyEvent();
+ this.removeArrowKeyEvent();
$input = this.dropdown.find('.dropdown-input-field');
if (this.options.filterable) {
$input.blur();
@@ -900,7 +900,7 @@ GitLabDropdown = (function() {
);
};
- GitLabDropdown.prototype.removeArrayKeyEvent = function() {
+ GitLabDropdown.prototype.removeArrowKeyEvent = function() {
return $('body').off('keydown');
};
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
new file mode 100644
index 00000000000..bd504d95ee2
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -0,0 +1,103 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlFormCheckbox,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ Icon,
+ },
+ data() {
+ return { placeholderUrl: 'https://my-url.grafana.net/' };
+ },
+ computed: {
+ ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']),
+ integrationEnabled: {
+ get() {
+ return this.grafanaEnabled;
+ },
+ set(grafanaEnabled) {
+ this.setGrafanaEnabled(grafanaEnabled);
+ },
+ },
+ localGrafanaToken: {
+ get() {
+ return this.grafanaToken;
+ },
+ set(token) {
+ this.setGrafanaToken(token);
+ },
+ },
+ localGrafanaUrl: {
+ get() {
+ return this.grafanaUrl;
+ },
+ set(url) {
+ this.setGrafanaUrl(url);
+ },
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'setGrafanaUrl',
+ 'setGrafanaToken',
+ 'setGrafanaEnabled',
+ 'updateGrafanaIntegration',
+ ]),
+ },
+};
+</script>
+
+<template>
+ <section id="grafana" class="settings no-animate js-grafana-integration">
+ <div class="settings-header">
+ <h4 class="js-section-header">
+ {{ s__('GrafanaIntegration|Grafana Authentication') }}
+ </h4>
+ <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
+ <p class="js-section-sub-header">
+ {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }}
+ </p>
+ </div>
+ <div class="settings-content">
+ <form>
+ <gl-form-checkbox
+ id="grafana-integration-enabled"
+ v-model="integrationEnabled"
+ class="mb-4"
+ >
+ {{ s__('GrafanaIntegration|Active') }}
+ </gl-form-checkbox>
+ <gl-form-group
+ :label="s__('GrafanaIntegration|Grafana URL')"
+ label-for="grafana-url"
+ :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')"
+ >
+ <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" />
+ </gl-form-group>
+ <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token">
+ <gl-form-input id="grafana-token" v-model="localGrafanaToken" />
+ <p class="form-text text-muted">
+ {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }}
+ <a
+ href="https://grafana.com/docs/http_api/auth/#create-api-token"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {{ __('More information') }}
+ <icon name="external-link" class="vertical-align-middle" />
+ </a>
+ </p>
+ </gl-form-group>
+ <gl-button variant="success" @click="updateGrafanaIntegration">
+ {{ __('Save Changes') }}
+ </gl-button>
+ </form>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js
new file mode 100644
index 00000000000..a93edab4388
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import store from './store';
+import GrafanaIntegration from './components/grafana_integration.vue';
+
+export default () => {
+ const el = document.querySelector('.js-grafana-integration');
+ return new Vue({
+ el,
+ store: store(el.dataset),
+ render(createElement) {
+ return createElement(GrafanaIntegration);
+ },
+ });
+};
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js
new file mode 100644
index 00000000000..d83f1e0831c
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/store/actions.js
@@ -0,0 +1,42 @@
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import createFlash from '~/flash';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import * as mutationTypes from './mutation_types';
+
+export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url);
+
+export const setGrafanaToken = ({ commit }, token) =>
+ commit(mutationTypes.SET_GRAFANA_TOKEN, token);
+
+export const setGrafanaEnabled = ({ commit }, enabled) =>
+ commit(mutationTypes.SET_GRAFANA_ENABLED, enabled);
+
+export const updateGrafanaIntegration = ({ state, dispatch }) =>
+ axios
+ .patch(state.operationsSettingsEndpoint, {
+ project: {
+ grafana_integration_attributes: {
+ grafana_url: state.grafanaUrl,
+ token: state.grafanaToken,
+ enabled: state.grafanaEnabled,
+ },
+ },
+ })
+ .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess'))
+ .catch(error => dispatch('receiveGrafanaIntegrationUpdateError', error));
+
+export const receiveGrafanaIntegrationUpdateSuccess = () => {
+ /**
+ * The operations_controller currently handles successful requests
+ * by creating a flash banner messsage to notify the user.
+ */
+ refreshCurrentPage();
+};
+
+export const receiveGrafanaIntegrationUpdateError = (_, error) => {
+ const { response } = error;
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
+};
diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js
new file mode 100644
index 00000000000..e96bb1e8aad
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export const createStore = initialState =>
+ new Vuex.Store({
+ state: createState(initialState),
+ actions,
+ mutations,
+ });
+
+export default createStore;
diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js
new file mode 100644
index 00000000000..314c3a4039a
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/store/mutation_types.js
@@ -0,0 +1,3 @@
+export const SET_GRAFANA_URL = 'SET_GRAFANA_URL';
+export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN';
+export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED';
diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js
new file mode 100644
index 00000000000..0992030d404
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/store/mutations.js
@@ -0,0 +1,13 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_GRAFANA_URL](state, url) {
+ state.grafanaUrl = url;
+ },
+ [types.SET_GRAFANA_TOKEN](state, token) {
+ state.grafanaToken = token;
+ },
+ [types.SET_GRAFANA_ENABLED](state, enabled) {
+ state.grafanaEnabled = enabled;
+ },
+};
diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js
new file mode 100644
index 00000000000..a912eb58327
--- /dev/null
+++ b/app/assets/javascripts/grafana_integration/store/state.js
@@ -0,0 +1,8 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default (initialState = {}) => ({
+ operationsSettingsEndpoint: initialState.operationsSettingsEndpoint,
+ grafanaToken: initialState.grafanaIntegrationToken || '',
+ grafanaUrl: initialState.grafanaIntegrationUrl || '',
+ grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false,
+});
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 460174caf4d..eda0f5d1d23 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,15 +1,23 @@
import $ from 'jquery';
import { slugify } from './lib/utils/text_utility';
+import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
+import flash from '~/flash';
+import { __ } from '~/locale';
export default class Group {
constructor() {
this.groupPath = $('#group_path');
this.groupName = $('#group_name');
+ this.parentId = $('#group_parent_id');
this.updateHandler = this.update.bind(this);
this.resetHandler = this.reset.bind(this);
+ this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this);
if (this.groupName.val() === '') {
this.groupName.on('keyup', this.updateHandler);
this.groupPath.on('keydown', this.resetHandler);
+ if (!this.parentId.val()) {
+ this.groupName.on('blur', this.updateGroupPathSlugHandler);
+ }
}
}
@@ -21,5 +29,21 @@ export default class Group {
reset() {
this.groupName.off('keyup', this.updateHandler);
this.groupPath.off('keydown', this.resetHandler);
+ this.groupName.off('blur', this.checkPathHandler);
+ }
+
+ updateGroupPathSlug() {
+ const slug = this.groupPath.val() || slugify(this.groupName.val());
+ if (!slug) return;
+
+ fetchGroupPathAvailability(slug)
+ .then(({ data }) => data)
+ .then(data => {
+ if (data.exists && data.suggests.length > 0) {
+ const suggestedSlug = data.suggests[0];
+ this.groupPath.val(suggestedSlug);
+ }
+ })
+ .catch(() => flash(__('An error occurred while checking group path')));
}
}
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
index 2c2a04d5b5e..d172aa8a444 100644
--- a/app/assets/javascripts/helpers/monitor_helper.js
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -1,17 +1,31 @@
-/* eslint-disable import/prefer-default-export */
-
+/**
+ * @param {Array} queryResults - Array of Result objects
+ * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name)
+ * @returns {Array} The formatted values
+ */
+// eslint-disable-next-line import/prefer-default-export
export const makeDataSeries = (queryResults, defaultConfig) =>
- queryResults.reduce((acc, result) => {
- const data = result.values.filter(([, value]) => !Number.isNaN(value));
- if (!data.length) {
- return acc;
- }
- const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_');
- const name = result.metric[relevantMetric];
- const series = { data };
- if (name) {
- series.name = `${defaultConfig.name}: ${name}`;
- }
+ queryResults
+ .map(result => {
+ const data = result.values.filter(([, value]) => !Number.isNaN(value));
+ if (!data.length) {
+ return null;
+ }
+ const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_');
+ const name = result.metric[relevantMetric];
+ const series = { data };
+ if (name) {
+ series.name = `${defaultConfig.name}: ${name}`;
+ } else {
+ series.name = defaultConfig.name;
+ Object.keys(result.metric).forEach(templateVar => {
+ const value = result.metric[templateVar];
+ const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g');
+
+ series.name = series.name.replace(regex, value);
+ });
+ }
- return acc.concat({ ...defaultConfig, ...series });
- }, []);
+ return { ...defaultConfig, ...series };
+ })
+ .filter(series => series !== null);
diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue
index 9ad9d4455b5..52ca61c06b0 100644
--- a/app/assets/javascripts/ide/components/jobs/stage.vue
+++ b/app/assets/javascripts/ide/components/jobs/stage.vue
@@ -58,6 +58,7 @@ export default {
<template>
<div class="ide-stage card prepend-top-default">
<div
+ ref="cardHeader"
:class="{
'border-bottom-0': stage.isCollapsed,
}"
@@ -79,7 +80,7 @@ export default {
</div>
<icon :name="collapseIcon" class="ide-stage-collapse-icon" />
</div>
- <div v-show="!stage.isCollapsed" class="card-body">
+ <div v-show="!stage.isCollapsed" ref="jobList" class="card-body">
<gl-loading-icon v-if="showLoadingIcon" />
<template v-else>
<item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" />
diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue
index 6999746f115..beb179d0411 100644
--- a/app/assets/javascripts/ide/components/preview/clientside.vue
+++ b/app/assets/javascripts/ide/components/preview/clientside.vue
@@ -92,6 +92,7 @@ export default {
},
methods: {
...mapActions(['getFileData', 'getRawFileData']),
+ ...mapActions('clientside', ['pingUsage']),
loadFileContent(path) {
return this.getFileData({ path, makeFileActive: false }).then(() =>
this.getRawFileData({ path }),
@@ -100,6 +101,8 @@ export default {
initPreview() {
if (!this.mainEntry) return null;
+ this.pingUsage();
+
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
.then(() => {
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 3bf8308ccea..08b3e8a34d6 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -301,6 +301,7 @@ export default {
v-if="showContentViewer"
:content="file.content || file.raw"
:path="file.rawPath || file.path"
+ :file-path="file.path"
:file-size="file.size"
:project-path="file.projectId"
:type="fileType"
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index ba33b6826d6..f6ad2f9c7d1 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -1,4 +1,6 @@
import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { escapeFileUrl } from '../stores/utils';
import Api from '~/api';
export default {
@@ -23,18 +25,25 @@ export default {
.then(({ data }) => data);
},
getBaseRawFileData(file, sha) {
- if (file.tempFile) {
- return Promise.resolve(file.baseRaw);
- }
+ if (file.tempFile || file.baseRaw) return Promise.resolve(file.baseRaw);
- if (file.baseRaw) {
- return Promise.resolve(file.baseRaw);
- }
+ // if files are renamed, their base path has changed
+ const filePath =
+ file.mrChange && file.mrChange.renamed_file ? file.mrChange.old_path : file.path;
return axios
- .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), {
- transformResponse: [f => f],
- })
+ .get(
+ joinPaths(
+ gon.relative_url_root || '/',
+ file.projectId,
+ 'raw',
+ sha,
+ escapeFileUrl(filePath),
+ ),
+ {
+ transformResponse: [f => f],
+ },
+ )
.then(({ data }) => data);
},
getProjectData(namespace, project) {
@@ -58,8 +67,8 @@ export default {
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
- getFiles(projectUrl, branchId) {
- const url = `${projectUrl}/files/${branchId}`;
+ getFiles(projectUrl, ref) {
+ const url = `${projectUrl}/files/${ref}`;
return axios.get(url, { params: { format: 'json' } });
},
lastCommitPipelines({ getters }) {
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 59445afc7a4..9af0b50d1a5 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,11 +1,10 @@
import { joinPaths } from '~/lib/utils/url_utility';
-import { normalizeHeaders } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
import router from '../../ide_router';
-import { setPageTitle, replaceFileUrl } from '../utils';
+import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils';
import { viewerTypes, stageKeys } from '../../constants';
export const closeFile = ({ commit, state, dispatch }, file) => {
@@ -58,7 +57,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => {
};
export const getFileData = (
- { state, commit, dispatch },
+ { state, commit, dispatch, getters },
{ path, makeFileActive = true, openFile = makeFileActive },
) => {
const file = state.entries[path];
@@ -67,15 +66,18 @@ export const getFileData = (
commit(types.TOGGLE_LOADING, { entry: file });
- const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url;
+ const url = joinPaths(
+ gon.relative_url_root || '/',
+ state.currentProjectId,
+ file.type,
+ getters.lastCommit && getters.lastCommit.id,
+ escapeFileUrl(file.prevPath || file.path),
+ );
return service
- .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
- .then(({ data, headers }) => {
- const normalizedHeaders = normalizeHeaders(headers);
- let title = normalizedHeaders['PAGE-TITLE'];
- title = file.prevPath ? title.replace(file.prevPath, file.path) : title;
- setPageTitle(decodeURI(title));
+ .getFileData(url)
+ .then(({ data }) => {
+ setPageTitleForFile(state, file);
if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
@@ -140,7 +142,10 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => {
const file = state.entries[path];
- commit(types.UPDATE_FILE_CONTENT, { path, content });
+ commit(types.UPDATE_FILE_CONTENT, {
+ path,
+ content: addFinalNewlineIfNeeded(content),
+ });
const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 1273e375859..6790c0fbdaa 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -152,15 +152,17 @@ export const openMergeRequest = (
.then(mr => {
dispatch('setCurrentBranchId', mr.source_branch);
- dispatch('getBranchData', {
+ // getFiles needs to be called after getting the branch data
+ // since files are fetched using the last commit sha of the branch
+ return dispatch('getBranchData', {
projectId,
branchId: mr.source_branch,
- });
-
- return dispatch('getFiles', {
- projectId,
- branchId: mr.source_branch,
- });
+ }).then(() =>
+ dispatch('getFiles', {
+ projectId,
+ branchId: mr.source_branch,
+ }),
+ );
})
.then(() =>
dispatch('getMergeRequestVersions', {
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 75511574d3e..72cd099c5a5 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -46,7 +46,7 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL
});
};
-export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) =>
+export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) =>
new Promise((resolve, reject) => {
if (
!state.trees[`${projectId}/${branchId}`] ||
@@ -54,10 +54,11 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } =
state.trees[`${projectId}/${branchId}`].tree.length === 0)
) {
const selectedProject = state.projects[projectId];
- commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
+ const selectedBranch = getters.findBranch(projectId, branchId);
+ commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
service
- .getFiles(selectedProject.web_url, branchId)
+ .getFiles(selectedProject.web_url, selectedBranch.commit.id)
.then(({ data }) => {
const { entries, treeList } = decorateFiles({
data,
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 85fd45358be..a176fd0aca8 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -34,7 +34,9 @@ export const currentMergeRequest = state => {
return null;
};
-export const currentProject = state => state.projects[state.currentProjectId];
+export const findProject = state => projectId => state.projects[projectId];
+
+export const currentProject = (state, getters) => getters.findProject(state.currentProjectId);
export const emptyRepo = state =>
state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo;
@@ -94,8 +96,14 @@ export const lastCommit = (state, getters) => {
return branch ? branch.commit : null;
};
+export const findBranch = (state, getters) => (projectId, branchId) => {
+ const project = getters.findProject(projectId);
+
+ return project && project.branches[branchId];
+};
+
export const currentBranch = (state, getters) =>
- getters.currentProject && getters.currentProject.branches[state.currentBranchId];
+ getters.findBranch(state.currentProjectId, state.currentBranchId);
export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name;
diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js
index f1f544b52b2..85550578e94 100644
--- a/app/assets/javascripts/ide/stores/index.js
+++ b/app/assets/javascripts/ide/stores/index.js
@@ -10,6 +10,7 @@ import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
+import clientsideModule from './modules/clientside';
Vue.use(Vuex);
@@ -26,6 +27,7 @@ export const createStore = () =>
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
+ clientside: clientsideModule(),
},
});
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
new file mode 100644
index 00000000000..eb3bcdff2ae
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js
@@ -0,0 +1,12 @@
+import axios from '~/lib/utils/axios_utils';
+
+export const pingUsage = ({ rootGetters }) => {
+ const { web_url: projectUrl } = rootGetters.currentProject;
+
+ const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`;
+
+ return axios.post(url);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js
new file mode 100644
index 00000000000..b28f7b935a8
--- /dev/null
+++ b/app/assets/javascripts/ide/stores/modules/clientside/index.js
@@ -0,0 +1,6 @@
+import * as actions from './actions';
+
+export default () => ({
+ namespaced: true,
+ actions,
+});
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index a8d8ff31afe..be7ee80656f 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -113,6 +113,11 @@ export const setPageTitle = title => {
document.title = title;
};
+export const setPageTitleForFile = (state, file) => {
+ const title = [file.path, state.currentBranchId, state.currentProjectId, 'GitLab'].join(' · ');
+ setPageTitle(title);
+};
+
export const commitActionForFile = file => {
if (file.prevPath) {
return commitActionTypes.move;
@@ -269,3 +274,7 @@ export const pathsAreEqual = (a, b) => {
return cleanA === cleanB;
};
+
+// 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;
diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js
index 74150ce3a8b..bd6e8433544 100644
--- a/app/assets/javascripts/issuable_bulk_update_sidebar.js
+++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js
@@ -1,11 +1,13 @@
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
+import { property } from 'underscore';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select';
+import issueableEventHub from './issuables_list/eventhub';
const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content';
@@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si
export default class IssuableBulkUpdateSidebar {
constructor() {
+ this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window);
+
this.initDomElements();
this.bindEvents();
this.initDropdowns();
@@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar {
this.$issuesList.on('change', () => this.updateFormState());
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState());
+
+ if (this.vueIssuablesListFeature) {
+ issueableEventHub.$on('issuables:updateBulkEdit', () => {
+ // Danger! Strong coupling ahead!
+ // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue
+ // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
+ // explicitly, but this component is used in too many places right now to refactor straight away.
+
+ this.updateFormState();
+ });
+ }
}
initDropdowns() {
@@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar {
toggleBulkEdit(e, enable) {
e.preventDefault();
+ issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
+
this.toggleSidebarDisplay(enable);
this.toggleBulkEditButtonDisabled(enable);
this.toggleOtherFiltersDisabled(enable);
@@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleCheckboxDisplay(show) {
- this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
+ this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature);
this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
}
diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue
new file mode 100644
index 00000000000..eb924609a8a
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/components/issuable.vue
@@ -0,0 +1,335 @@
+<script>
+/*
+ * This is tightly coupled to projects/issues/_issue.html.haml,
+ * any changes done to the haml need to be reflected here.
+ */
+import { escape, isNumber } from 'underscore';
+import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
+import {
+ dateInWords,
+ formatDate,
+ getDayDifference,
+ getTimeago,
+ timeFor,
+ newDateAsLocaleTime,
+} from '~/lib/utils/datetime_utility';
+import { sprintf, __ } from '~/locale';
+import initUserPopovers from '~/user_popovers';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import Icon from '~/vue_shared/components/icon.vue';
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+
+const ISSUE_TOKEN = '#';
+
+export default {
+ components: {
+ Icon,
+ IssueAssignees,
+ GlLink,
+ },
+ directives: {
+ GlTooltip,
+ },
+ props: {
+ issuable: {
+ type: Object,
+ required: true,
+ },
+ isBulkEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selected: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ baseUrl: {
+ type: String,
+ required: false,
+ default() {
+ return window.location.href;
+ },
+ },
+ },
+ computed: {
+ milestoneLink() {
+ const { title } = this.issuable.milestone;
+
+ return this.issuableLink({ milestone_title: title });
+ },
+ hasLabels() {
+ return Boolean(this.issuable.labels && this.issuable.labels.length);
+ },
+ hasWeight() {
+ return isNumber(this.issuable.weight);
+ },
+ dueDate() {
+ return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined;
+ },
+ dueDateWords() {
+ return this.dueDate ? dateInWords(this.dueDate, true) : undefined;
+ },
+ hasNoComments() {
+ return !this.userNotesCount;
+ },
+ isOverdue() {
+ return this.dueDate ? this.dueDate < new Date() : false;
+ },
+ isClosed() {
+ return this.issuable.state === 'closed';
+ },
+ issueCreatedToday() {
+ return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
+ },
+ labelIdsString() {
+ return JSON.stringify(this.issuable.labels.map(l => l.id));
+ },
+ milestoneDueDate() {
+ const { due_date: dueDate } = this.issuable.milestone || {};
+
+ return dueDate ? newDateAsLocaleTime(dueDate) : undefined;
+ },
+ milestoneTooltipText() {
+ if (this.milestoneDueDate) {
+ return sprintf(__('%{primary} (%{secondary})'), {
+ primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'),
+ secondary: timeFor(this.milestoneDueDate),
+ });
+ }
+ return __('Milestone');
+ },
+ openedAgoByString() {
+ const { author, created_at } = this.issuable;
+
+ return sprintf(
+ __('opened %{timeAgoString} by %{user}'),
+ {
+ timeAgoString: escape(getTimeago().format(created_at)),
+ user: `<a href="${escape(author.web_url)}"
+ data-user-id=${escape(author.id)}
+ data-username=${escape(author.username)}
+ data-name=${escape(author.name)}
+ data-avatar-url="${escape(author.avatar_url)}">
+ ${escape(author.name)}
+ </a>`,
+ },
+ false,
+ );
+ },
+ referencePath() {
+ // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301
+ return `${ISSUE_TOKEN}${this.issuable.iid}`;
+ },
+ updatedDateString() {
+ return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt');
+ },
+ updatedDateAgo() {
+ // snake_case because it's the same i18n string as the HAML view
+ return sprintf(__('updated %{time_ago}'), {
+ time_ago: escape(getTimeago().format(this.issuable.updated_at)),
+ });
+ },
+ userNotesCount() {
+ return this.issuable.user_notes_count;
+ },
+ issuableMeta() {
+ return [
+ {
+ key: 'merge-requests',
+ value: this.issuable.merge_requests_count,
+ title: __('Related merge requests'),
+ class: 'js-merge-requests',
+ icon: 'merge-request',
+ },
+ {
+ key: 'upvotes',
+ value: this.issuable.upvotes,
+ title: __('Upvotes'),
+ class: 'js-upvotes',
+ faicon: 'fa-thumbs-up',
+ },
+ {
+ key: 'downvotes',
+ value: this.issuable.downvotes,
+ title: __('Downvotes'),
+ class: 'js-downvotes',
+ faicon: 'fa-thumbs-down',
+ },
+ ];
+ },
+ },
+ mounted() {
+ // TODO: Refactor user popover to use its own component instead of
+ // spawning event listeners on Vue-rendered elements.
+ initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]);
+ },
+ methods: {
+ labelStyle(label) {
+ return {
+ backgroundColor: label.color,
+ color: label.text_color,
+ };
+ },
+ issuableLink(params) {
+ return mergeUrlParams(params, this.baseUrl);
+ },
+ labelHref({ name }) {
+ return this.issuableLink({ 'label_name[]': name });
+ },
+ onSelect(ev) {
+ this.$emit('select', {
+ issuable: this.issuable,
+ selected: ev.target.checked,
+ });
+ },
+ },
+
+ confidentialTooltipText: __('Confidential'),
+};
+</script>
+<template>
+ <li
+ :id="`issue_${issuable.id}`"
+ class="issue"
+ :class="{ today: issueCreatedToday, closed: isClosed }"
+ :data-id="issuable.id"
+ :data-labels="labelIdsString"
+ :data-url="issuable.web_url"
+ >
+ <div class="d-flex">
+ <!-- Bulk edit checkbox -->
+ <div v-if="isBulkEditing" class="mr-2">
+ <input
+ :checked="selected"
+ class="selected-issuable"
+ type="checkbox"
+ :data-id="issuable.id"
+ @input="onSelect"
+ />
+ </div>
+
+ <!-- Issuable info container -->
+ <!-- Issuable main info -->
+ <div class="flex-grow-1">
+ <div class="title">
+ <span class="issue-title-text">
+ <i
+ v-if="issuable.confidential"
+ v-gl-tooltip
+ class="fa fa-eye-slash"
+ :title="$options.confidentialTooltipText"
+ :aria-label="$options.confidentialTooltipText"
+ ></i>
+ <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
+ </span>
+ <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">{{
+ issuable.task_status
+ }}</span>
+ </div>
+
+ <div class="issuable-info">
+ <span>{{ referencePath }}</span>
+
+ <span class="d-none d-sm-inline-block mr-1">
+ &middot;
+ <span ref="openedAgoByContainer" v-html="openedAgoByString"></span>
+ </span>
+
+ <gl-link
+ v-if="issuable.milestone"
+ v-gl-tooltip
+ class="d-none d-sm-inline-block mr-1 js-milestone"
+ :href="milestoneLink"
+ :title="milestoneTooltipText"
+ >
+ <i class="fa fa-clock-o"></i>
+ {{ issuable.milestone.title }}
+ </gl-link>
+
+ <span
+ v-if="dueDate"
+ v-gl-tooltip
+ class="d-none d-sm-inline-block mr-1 js-due-date"
+ :class="{ cred: isOverdue }"
+ :title="__('Due date')"
+ >
+ <i class="fa fa-calendar"></i>
+ {{ dueDateWords }}
+ </span>
+
+ <span v-if="hasLabels" class="js-labels">
+ <gl-link
+ v-for="label in issuable.labels"
+ :key="label.id"
+ class="label-link mr-1"
+ :href="labelHref(label)"
+ >
+ <span
+ v-gl-tooltip
+ class="badge color-label"
+ :style="labelStyle(label)"
+ :title="label.description"
+ >{{ label.name }}</span
+ >
+ </gl-link>
+ </span>
+
+ <span
+ v-if="hasWeight"
+ v-gl-tooltip
+ :title="__('Weight')"
+ class="d-none d-sm-inline-block js-weight"
+ >
+ <icon name="weight" class="align-text-bottom" />
+ {{ issuable.weight }}
+ </span>
+ </div>
+ </div>
+
+ <!-- Issuable meta -->
+ <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center">
+ <div class="controls d-flex">
+ <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
+
+ <issue-assignees
+ :assignees="issuable.assignees"
+ class="align-items-center d-flex ml-2"
+ :icon-size="16"
+ img-css-classes="mr-1"
+ :max-visible="4"
+ />
+
+ <template v-for="meta in issuableMeta">
+ <span
+ v-if="meta.value"
+ :key="meta.key"
+ v-gl-tooltip
+ :class="['d-none d-sm-inline-block ml-2', meta.class]"
+ :title="meta.title"
+ >
+ <icon v-if="meta.icon" :name="meta.icon" />
+ <i v-else :class="['fa', meta.faicon]"></i>
+ {{ meta.value }}
+ </span>
+ </template>
+
+ <gl-link
+ v-gl-tooltip
+ class="ml-2 js-notes"
+ :href="`${issuable.web_url}#notes`"
+ :title="__('Comments')"
+ :class="{ 'no-comments': hasNoComments }"
+ >
+ <i class="fa fa-comments"></i>
+ {{ userNotesCount }}
+ </gl-link>
+ </div>
+ <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString">
+ {{ updatedDateAgo }}
+ </div>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
new file mode 100644
index 00000000000..6b6a8bd4068
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue
@@ -0,0 +1,277 @@
+<script>
+import { omit } from 'underscore';
+import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
+import flash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+import initManualOrdering from '~/manual_ordering';
+import Issuable from './issuable.vue';
+import {
+ sortOrderMap,
+ RELATIVE_POSITION,
+ PAGE_SIZE,
+ PAGE_SIZE_MANUAL,
+ LOADING_LIST_ITEMS_LENGTH,
+} from '../constants';
+import issueableEventHub from '../eventhub';
+
+export default {
+ LOADING_LIST_ITEMS_LENGTH,
+ components: {
+ GlEmptyState,
+ GlPagination,
+ GlSkeletonLoading,
+ Issuable,
+ },
+ props: {
+ canBulkEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ createIssuePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ emptySvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ sortKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ filters: {},
+ isBulkEditing: false,
+ issuables: [],
+ loading: false,
+ page: 1,
+ selection: {},
+ totalItems: 0,
+ };
+ },
+ computed: {
+ allIssuablesSelected() {
+ // WARNING: Because we are only keeping track of selected values
+ // this works, we will need to rethink this if we start tracking
+ // [id]: false for not selected values.
+ return this.issuables.length === Object.keys(this.selection).length;
+ },
+ emptyState() {
+ if (this.issuables.length) {
+ return {}; // Empty state shouldn't be shown here
+ } else if (this.hasFilters) {
+ return {
+ title: __('Sorry, your filter produced no results'),
+ description: __('To widen your search, change or remove filters above'),
+ };
+ } else if (this.filters.state === 'opened') {
+ return {
+ title: __('There are no open issues'),
+ description: __('To keep this project going, create a new issue'),
+ primaryLink: this.createIssuePath,
+ primaryText: __('New issue'),
+ };
+ } else if (this.filters.state === 'closed') {
+ return {
+ title: __('There are no closed issues'),
+ };
+ }
+
+ return {
+ title: __('There are no issues to show'),
+ description: __(
+ 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
+ ),
+ };
+ },
+ hasFilters() {
+ const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort'];
+ return Object.keys(omit(this.filters, ignored)).length > 0;
+ },
+ isManualOrdering() {
+ return this.sortKey === RELATIVE_POSITION;
+ },
+ itemsPerPage() {
+ return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE;
+ },
+ baseUrl() {
+ return window.location.href.replace(/(\?.*)?(#.*)?$/, '');
+ },
+ },
+ watch: {
+ selection() {
+ // We need to call nextTick here to wait for all of the boxes to be checked and rendered
+ // before we query the dom in issuable_bulk_update_actions.js.
+ this.$nextTick(() => {
+ issueableEventHub.$emit('issuables:updateBulkEdit');
+ });
+ },
+ issuables() {
+ this.$nextTick(() => {
+ initManualOrdering();
+ });
+ },
+ },
+ mounted() {
+ if (this.canBulkEdit) {
+ this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => {
+ this.isBulkEditing = val;
+ });
+ }
+ this.fetchIssuables();
+ },
+ beforeDestroy() {
+ issueableEventHub.$off('issuables:toggleBulkEdit');
+ },
+ methods: {
+ isSelected(issuableId) {
+ return Boolean(this.selection[issuableId]);
+ },
+ setSelection(ids) {
+ ids.forEach(id => {
+ this.select(id, true);
+ });
+ },
+ clearSelection() {
+ this.selection = {};
+ },
+ select(id, isSelect = true) {
+ if (isSelect) {
+ this.$set(this.selection, id, true);
+ } else {
+ this.$delete(this.selection, id);
+ }
+ },
+ fetchIssuables(pageToFetch) {
+ this.loading = true;
+
+ this.clearSelection();
+
+ this.setFilters();
+
+ return axios
+ .get(this.endpoint, {
+ params: {
+ ...this.filters,
+
+ with_labels_details: true,
+ page: pageToFetch || this.page,
+ per_page: this.itemsPerPage,
+ },
+ })
+ .then(response => {
+ this.loading = false;
+ this.issuables = response.data;
+ this.totalItems = Number(response.headers['x-total']);
+ this.page = Number(response.headers['x-page']);
+ })
+ .catch(() => {
+ this.loading = false;
+ return flash(__('An error occurred while loading issues'));
+ });
+ },
+ getQueryObject() {
+ return urlParamsToObject(window.location.search);
+ },
+ onPaginate(newPage) {
+ if (newPage === this.page) return;
+
+ scrollToElement('#content-body');
+ this.fetchIssuables(newPage);
+ },
+ onSelectAll() {
+ if (this.allIssuablesSelected) {
+ this.selection = {};
+ } else {
+ this.setSelection(this.issuables.map(({ id }) => id));
+ }
+ },
+ onSelectIssuable({ issuable, selected }) {
+ if (!this.canBulkEdit) return;
+
+ this.select(issuable.id, selected);
+ },
+ setFilters() {
+ const {
+ label_name: labels,
+ milestone_title: milestoneTitle,
+ ...filters
+ } = this.getQueryObject();
+
+ if (milestoneTitle) {
+ filters.milestone = milestoneTitle;
+ }
+ if (Array.isArray(labels)) {
+ filters.labels = labels.join(',');
+ }
+ if (!filters.state) {
+ filters.state = 'opened';
+ }
+
+ Object.assign(filters, sortOrderMap[this.sortKey]);
+
+ this.filters = filters;
+ },
+ },
+};
+</script>
+
+<template>
+ <ul v-if="loading" class="content-list">
+ <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue">
+ <gl-skeleton-loading />
+ </li>
+ </ul>
+ <div v-else-if="issuables.length">
+ <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light">
+ <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" />
+ <strong>{{ __('Select all') }}</strong>
+ </div>
+ <ul
+ class="content-list issuable-list issues-list"
+ :class="{ 'manual-ordering': isManualOrdering }"
+ >
+ <issuable
+ v-for="issuable in issuables"
+ :key="issuable.id"
+ class="pr-3"
+ :class="{ 'user-can-drag': isManualOrdering }"
+ :issuable="issuable"
+ :is-bulk-editing="isBulkEditing"
+ :selected="isSelected(issuable.id)"
+ :base-url="baseUrl"
+ @select="onSelectIssuable"
+ />
+ </ul>
+ <div class="mt-3">
+ <gl-pagination
+ v-if="totalItems"
+ :value="page"
+ :per-page="itemsPerPage"
+ :total-items="totalItems"
+ class="justify-content-center"
+ @input="onPaginate"
+ />
+ </div>
+ </div>
+ <gl-empty-state
+ v-else
+ :title="emptyState.title"
+ :description="emptyState.description"
+ :svg-path="emptySvgPath"
+ :primary-button-link="emptyState.primaryLink"
+ :primary-button-text="emptyState.primaryText"
+ />
+</template>
diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js
new file mode 100644
index 00000000000..71b9c52c703
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/constants.js
@@ -0,0 +1,33 @@
+// Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
+const PRIORITY = 'priority';
+const ASC = 'asc';
+const DESC = 'desc';
+const CREATED_AT = 'created_at';
+const UPDATED_AT = 'updated_at';
+const DUE_DATE = 'due_date';
+const MILESTONE_DUE = 'milestone_due';
+const POPULARITY = 'popularity';
+const WEIGHT = 'weight';
+const LABEL_PRIORITY = 'label_priority';
+export const RELATIVE_POSITION = 'relative_position';
+export const LOADING_LIST_ITEMS_LENGTH = 8;
+export const PAGE_SIZE = 20;
+export const PAGE_SIZE_MANUAL = 100;
+
+export const sortOrderMap = {
+ priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason
+ created_date: { order_by: CREATED_AT, sort: DESC },
+ created_asc: { order_by: CREATED_AT, sort: ASC },
+ updated_desc: { order_by: UPDATED_AT, sort: DESC },
+ updated_asc: { order_by: UPDATED_AT, sort: ASC },
+ milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC },
+ milestone: { order_by: MILESTONE_DUE, sort: ASC },
+ due_date_desc: { order_by: DUE_DATE, sort: DESC },
+ due_date: { order_by: DUE_DATE, sort: ASC },
+ popularity: { order_by: POPULARITY, sort: DESC },
+ popularity_asc: { order_by: POPULARITY, sort: ASC },
+ label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped
+ relative_position: { order_by: RELATIVE_POSITION, sort: ASC },
+ weight_desc: { order_by: WEIGHT, sort: DESC },
+ weight: { order_by: WEIGHT, sort: ASC },
+};
diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js
new file mode 100644
index 00000000000..d1601a7d8f3
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/eventhub.js
@@ -0,0 +1,5 @@
+import Vue from 'vue';
+
+const issueablesEventBus = new Vue();
+
+export default issueablesEventBus;
diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js
new file mode 100644
index 00000000000..9fc7fa837ff
--- /dev/null
+++ b/app/assets/javascripts/issuables_list/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import IssuablesListApp from './components/issuables_list_app.vue';
+
+export default function initIssuablesList() {
+ if (!gon.features || !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 });
+ },
+ });
+ });
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index a9e086fade8..9136a47d542 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-var, one-var, consistent-return */
+/* eslint-disable consistent-return */
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
@@ -91,18 +91,17 @@ export default class Issue {
'click',
'.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen',
e => {
- var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
- $button = $(e.currentTarget);
- shouldSubmit = $button.hasClass('btn-comment');
+ const $button = $(e.currentTarget);
+ const shouldSubmit = $button.hasClass('btn-comment');
if (shouldSubmit) {
Issue.submitNoteForm($button.closest('form'));
}
this.disableCloseReopenButton($button);
- url = $button.attr('href');
+ const url = $button.attr('href');
return axios
.put(url)
.then(({ data }) => {
@@ -139,16 +138,14 @@ export default class Issue {
}
static submitNoteForm(form) {
- var noteText;
- noteText = form.find('textarea.js-note-text').val();
+ const noteText = form.find('textarea.js-note-text').val();
if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
static initRelatedBranches() {
- var $container;
- $container = $('#related-branches');
+ const $container = $('#related-branches');
return axios
.get($container.data('url'))
.then(({ data }) => {
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index ef126166e8b..03a697d11ed 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -11,11 +11,35 @@ export default {
computed: {
...mapState(['traceEndpoint', 'trace', 'isTraceComplete']),
},
+ updated() {
+ this.$nextTick(() => {
+ this.handleScrollDown();
+ });
+ },
+ mounted() {
+ this.$nextTick(() => {
+ this.handleScrollDown();
+ });
+ },
methods: {
- ...mapActions(['toggleCollapsibleLine']),
+ ...mapActions(['toggleCollapsibleLine', 'scrollBottom']),
handleOnClickCollapsibleLine(section) {
this.toggleCollapsibleLine(section);
},
+ /**
+ * The job log is sent in HTML, which means we need to use `v-html` to render it
+ * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated
+ * in this case because it runs before `v-html` has finished running, since there's no
+ * Vue binding.
+ * In order to scroll the page down after `v-html` has finished, we need to use setTimeout
+ */
+ handleScrollDown() {
+ if (this.isScrolledToBottomBeforeReceivingTrace) {
+ setTimeout(() => {
+ this.scrollBottom();
+ }, 0);
+ }
+ },
},
};
</script>
diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js
index 58e49f54d96..179d0bc4e0f 100644
--- a/app/assets/javascripts/jobs/store/utils.js
+++ b/app/assets/javascripts/jobs/store/utils.js
@@ -17,7 +17,7 @@ export const parseLine = (line = {}, lineNumber) => ({
* @param Number lineNumber
*/
export const parseHeaderLine = (line = {}, lineNumber) => ({
- isClosed: true,
+ isClosed: false,
isHeader: true,
line: parseLine(line, lineNumber),
lines: [],
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 72de3b5d726..6abf723be9a 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */
+/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */
/* global Issuable */
/* global ListLabel */
@@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils';
export default class LabelsSelect {
constructor(els, options = {}) {
- var _this, $els;
- _this = this;
+ const _this = this;
- $els = $(els);
+ let $els = $(els);
if (!els) {
$els = $('.js-label-select');
}
$els.each((i, dropdown) => {
- var $block,
- $dropdown,
- $form,
- $loading,
- $selectbox,
- $sidebarCollapsedValue,
- $value,
- $dropdownMenu,
- abilityName,
- defaultLabel,
- issueUpdateURL,
- labelUrl,
- namespacePath,
- projectPath,
- saveLabelData,
- selectedLabel,
- showAny,
- showNo,
- $sidebarLabelTooltip,
- initialSelected,
- fieldName,
- showMenuAbove,
- $dropdownContainer;
- $dropdown = $(dropdown);
- $dropdownContainer = $dropdown.closest('.labels-filter');
- namespacePath = $dropdown.data('namespacePath');
- projectPath = $dropdown.data('projectPath');
- issueUpdateURL = $dropdown.data('issueUpdate');
- selectedLabel = $dropdown.data('selected');
+ const $dropdown = $(dropdown);
+ const $dropdownContainer = $dropdown.closest('.labels-filter');
+ const namespacePath = $dropdown.data('namespacePath');
+ const projectPath = $dropdown.data('projectPath');
+ const issueUpdateURL = $dropdown.data('issueUpdate');
+ let selectedLabel = $dropdown.data('selected');
if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) {
selectedLabel = selectedLabel.split(',');
}
- showNo = $dropdown.data('showNo');
- showAny = $dropdown.data('showAny');
- showMenuAbove = $dropdown.data('showMenuAbove');
- defaultLabel = $dropdown.data('defaultLabel') || __('Label');
- abilityName = $dropdown.data('abilityName');
- $selectbox = $dropdown.closest('.selectbox');
- $block = $selectbox.closest('.block');
- $form = $dropdown.closest('form, .js-issuable-update');
- $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
- $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
- $value = $block.find('.value');
- $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
- $loading = $block.find('.block-loading').fadeOut();
- fieldName = $dropdown.data('fieldName');
- initialSelected = $selectbox
+ const showNo = $dropdown.data('showNo');
+ const showAny = $dropdown.data('showAny');
+ const showMenuAbove = $dropdown.data('showMenuAbove');
+ const defaultLabel = $dropdown.data('defaultLabel') || __('Label');
+ const abilityName = $dropdown.data('abilityName');
+ const $selectbox = $dropdown.closest('.selectbox');
+ const $block = $selectbox.closest('.block');
+ const $form = $dropdown.closest('form, .js-issuable-update');
+ const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span');
+ const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
+ const $value = $block.find('.value');
+ const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
+ const $loading = $block.find('.block-loading').fadeOut();
+ const fieldName = $dropdown.data('fieldName');
+ let initialSelected = $selectbox
.find(`input[name="${$dropdown.data('fieldName')}"]`)
.map(function() {
return this.value;
@@ -90,9 +66,8 @@ export default class LabelsSelect {
);
}
- saveLabelData = function() {
- var data, selected;
- selected = $dropdown
+ const saveLabelData = function() {
+ const selected = $dropdown
.closest('.selectbox')
.find(`input[name='${fieldName}']`)
.map(function() {
@@ -103,7 +78,7 @@ export default class LabelsSelect {
if (_.isEqual(initialSelected, selected)) return;
initialSelected = selected;
- data = {};
+ const data = {};
data[abilityName] = {};
data[abilityName].label_ids = selected;
if (!selected.length) {
@@ -114,12 +89,13 @@ export default class LabelsSelect {
axios
.put(issueUpdateURL, data)
.then(({ data }) => {
- var labelCount, template, labelTooltipTitle, labelTitles;
+ let labelTooltipTitle;
+ let template;
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
data.issueUpdateURL = issueUpdateURL;
- labelCount = 0;
+ let labelCount = 0;
if (data.labels.length && issueUpdateURL) {
template = LabelsSelect.getLabelTemplate({
labels: _.sortBy(data.labels, 'title'),
@@ -174,7 +150,7 @@ export default class LabelsSelect {
$sidebarCollapsedValue.text(labelCount);
if (data.labels.length) {
- labelTitles = data.labels.map(label => label.title);
+ let labelTitles = data.labels.map(label => label.title);
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
@@ -199,13 +175,13 @@ export default class LabelsSelect {
$dropdown.glDropdown({
showMenuAbove,
data(term, callback) {
- labelUrl = $dropdown.attr('data-labels');
+ const labelUrl = $dropdown.attr('data-labels');
axios
.get(labelUrl)
.then(res => {
let { data } = res;
if ($dropdown.hasClass('js-extra-options')) {
- var extraData = [];
+ const extraData = [];
if (showNo) {
extraData.unshift({
id: 0,
@@ -232,22 +208,14 @@ export default class LabelsSelect {
.catch(() => flash(__('Error fetching labels.')));
},
renderRow(label) {
- var linkEl,
- listItemEl,
- colorEl,
- indeterminate,
- removesAll,
- selectedClass,
- i,
- marked,
- dropdownValue;
-
- selectedClass = [];
- removesAll = label.id <= 0 || label.id == null;
+ let colorEl;
+
+ const selectedClass = [];
+ const removesAll = label.id <= 0 || label.id == null;
if ($dropdown.hasClass('js-filter-bulk-update')) {
- indeterminate = $dropdown.data('indeterminate') || [];
- marked = $dropdown.data('marked') || [];
+ const indeterminate = $dropdown.data('indeterminate') || [];
+ const marked = $dropdown.data('marked') || [];
if (indeterminate.indexOf(label.id) !== -1) {
selectedClass.push('is-indeterminate');
@@ -255,7 +223,7 @@ export default class LabelsSelect {
if (marked.indexOf(label.id) !== -1) {
// Remove is-indeterminate class if the item will be marked as active
- i = selectedClass.indexOf('is-indeterminate');
+ const i = selectedClass.indexOf('is-indeterminate');
if (i !== -1) {
selectedClass.splice(i, 1);
}
@@ -263,7 +231,7 @@ export default class LabelsSelect {
}
} else {
if (this.id(label)) {
- dropdownValue = this.id(label)
+ const dropdownValue = this.id(label)
.toString()
.replace(/'/g, "\\'");
@@ -287,7 +255,7 @@ export default class LabelsSelect {
colorEl = '';
}
- linkEl = document.createElement('a');
+ const linkEl = document.createElement('a');
linkEl.href = '#';
// We need to identify which items are actually labels
@@ -300,7 +268,7 @@ export default class LabelsSelect {
linkEl.className = selectedClass.join(' ');
linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
- listItemEl = document.createElement('li');
+ const listItemEl = document.createElement('li');
listItemEl.appendChild(linkEl);
return listItemEl;
@@ -312,12 +280,12 @@ export default class LabelsSelect {
filterable: true,
selected: $dropdown.data('selected') || [],
toggleLabel(selected, el) {
- var $dropdownParent = $dropdown.parent();
- var $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
- var isSelected = el !== null ? el.hasClass('is-active') : false;
+ const $dropdownParent = $dropdown.parent();
+ const $dropdownInputField = $dropdownParent.find('.dropdown-input-field');
+ const isSelected = el !== null ? el.hasClass('is-active') : false;
- var title = selected ? selected.title : null;
- var selectedLabels = this.selected;
+ const title = selected ? selected.title : null;
+ const selectedLabels = this.selected;
if ($dropdownInputField.length && $dropdownInputField.val().length) {
$dropdownParent.find('.dropdown-input-clear').trigger('click');
@@ -329,7 +297,7 @@ export default class LabelsSelect {
} else if (isSelected) {
this.selected.push(title);
} else if (!isSelected && title) {
- var index = this.selected.indexOf(title);
+ const index = this.selected.indexOf(title);
this.selected.splice(index, 1);
}
@@ -359,10 +327,9 @@ export default class LabelsSelect {
}
},
hidden() {
- var isIssueIndex, isMRIndex, page;
- page = $('body').attr('data-page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = page === 'projects:merge_requests:index';
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = page === 'projects:merge_requests:index';
$selectbox.hide();
// display:block overrides the hide-collapse rule
$value.removeAttr('style');
@@ -393,14 +360,13 @@ export default class LabelsSelect {
const { $el, e, isMarking } = clickEvent;
const label = clickEvent.selectedObj;
- var isIssueIndex, isMRIndex, page, boardsModel;
- var fadeOutLoader = () => {
+ const fadeOutLoader = () => {
$loading.fadeOut();
};
- page = $('body').attr('data-page');
- isIssueIndex = page === 'projects:issues:index';
- isMRIndex = page === 'projects:merge_requests:index';
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = page === 'projects:merge_requests:index';
if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) {
$dropdown
@@ -419,6 +385,7 @@ export default class LabelsSelect {
return;
}
+ let boardsModel;
if ($dropdown.closest('.add-issues-modal').length) {
boardsModel = ModalStore.store.filter;
}
@@ -450,7 +417,7 @@ export default class LabelsSelect {
}),
);
} else {
- var { labels } = boardsStore.detail.issue;
+ let { labels } = boardsStore.detail.issue;
labels = labels.filter(selectedLabel => selectedLabel.id !== label.id);
boardsStore.detail.issue.labels = labels;
}
@@ -578,16 +545,14 @@ export default class LabelsSelect {
}
// eslint-disable-next-line class-methods-use-this
setDropdownData($dropdown, isMarking, value) {
- var i, markedIds, unmarkedIds, indeterminateIds;
-
- markedIds = $dropdown.data('marked') || [];
- unmarkedIds = $dropdown.data('unmarked') || [];
- indeterminateIds = $dropdown.data('indeterminate') || [];
+ const markedIds = $dropdown.data('marked') || [];
+ const unmarkedIds = $dropdown.data('unmarked') || [];
+ const indeterminateIds = $dropdown.data('indeterminate') || [];
if (isMarking) {
markedIds.push(value);
- i = indeterminateIds.indexOf(value);
+ let i = indeterminateIds.indexOf(value);
if (i > -1) {
indeterminateIds.splice(i, 1);
}
@@ -598,7 +563,7 @@ export default class LabelsSelect {
}
} else {
// If marked item (not common) is unmarked
- i = markedIds.indexOf(value);
+ const i = markedIds.indexOf(value);
if (i > -1) {
markedIds.splice(i, 1);
}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index c05db4a5c71..2c5278d16ae 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -26,7 +26,11 @@ export default (resolvers = {}, config = {}) => {
createUploadLink(httpOptions),
new BatchHttpLink(httpOptions),
),
- cache: new InMemoryCache(config.cacheConfig),
+ cache: new InMemoryCache({
+ ...config.cacheConfig,
+ freezeResults: config.assumeImmutableResults,
+ }),
resolvers,
+ assumeImmutableResults: config.assumeImmutableResults,
});
};
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js
index 0f78756aac8..4a1e6c5d68c 100644
--- a/app/assets/javascripts/lib/utils/chart_utils.js
+++ b/app/assets/javascripts/lib/utils/chart_utils.js
@@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }
},
},
});
+
+/**
+ * Takes a dataset and returns an array containing the y-values of it's first and last entry.
+ * (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3'])
+ *
+ * @param {Array} data
+ * @returns {[*, *]}
+ */
+export const firstAndLastY = data => {
+ const [firstEntry] = data;
+ const [lastEntry] = data.slice(-1);
+
+ const firstY = firstEntry[1];
+ const lastY = lastEntry[1];
+
+ return [firstY, lastY];
+};
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 37b0215f6f9..28143859e4c 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -78,11 +78,11 @@ export const getDayName = date =>
* @param {date} datetime
* @returns {String}
*/
-export const formatDate = datetime => {
+export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => {
if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error(__('Invalid date'));
}
- return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
+ return dateFormat(datetime, format);
};
/**
@@ -541,7 +541,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
* The result cannot become negative.
*
* @param endDate date string that the time difference is calculated for
- * @return {number} number of milliseconds remaining until the given date
+ * @return {Number} number of milliseconds remaining until the given date
*/
export const calculateRemainingMilliseconds = endDate => {
const remainingMilliseconds = new Date(endDate).getTime() - Date.now();
@@ -552,15 +552,53 @@ export const calculateRemainingMilliseconds = endDate => {
* Subtracts a given number of days from a given date and returns the new date.
*
* @param {Date} date the date that we will substract days from
- * @param {number} daysInPast number of days that are subtracted from a given date
- * @returns {String} Date string in ISO format
+ * @param {Number} daysInPast number of days that are subtracted from a given date
+ * @returns {Date} Date in past as Date object
*/
-export const getDateInPast = (date, daysInPast) => {
- const dateClone = newDate(date);
- return new Date(
- dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000),
- ).toISOString();
+export const getDateInPast = (date, daysInPast) =>
+ new Date(newDate(date).setDate(date.getDate() - daysInPast));
+
+/*
+ * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
+ * to match the user's time zone. We want to display the date in server time for now, to
+ * be consistent with the "edit issue -> due date" UI.
+ */
+
+export const newDateAsLocaleTime = date => {
+ const suffix = 'T00:00:00';
+ return new Date(`${date}${suffix}`);
};
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
+
+/**
+ * @param {Date} d1
+ * @param {Date} d2
+ * @param {Function} formatter
+ * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date)
+ */
+export const getDatesInRange = (d1, d2, formatter = x => x) => {
+ if (!(d1 instanceof Date) || !(d2 instanceof Date)) {
+ return [];
+ }
+ let startDate = d1.getTime();
+ const endDate = d2.getTime();
+ const oneDay = 24 * 3600 * 1000;
+ const range = [d1];
+
+ while (startDate < endDate) {
+ startDate += oneDay;
+ range.push(new Date(startDate));
+ }
+
+ return range.map(formatter);
+};
+
+/**
+ * Converts the supplied number of seconds to milliseconds.
+ *
+ * @param {Number} seconds
+ * @return {Number} number of milliseconds
+ */
+export const secondsToMilliseconds = seconds => seconds * 1000;
diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js
index cd509a13193..8db08099b3f 100644
--- a/app/assets/javascripts/lib/utils/notify.js
+++ b/app/assets/javascripts/lib/utils/notify.js
@@ -1,8 +1,7 @@
-/* eslint-disable no-var, consistent-return, no-return-assign */
+/* eslint-disable consistent-return, no-return-assign */
function notificationGranted(message, opts, onclick) {
- var notification;
- notification = new Notification(message, opts);
+ const notification = new Notification(message, opts);
setTimeout(
() =>
// Hide the notification after X amount of seconds
@@ -21,8 +20,7 @@ function notifyPermissions() {
}
function notifyMe(message, body, icon, onclick) {
- var opts;
- opts = {
+ const opts = {
body,
icon,
};
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 0f2cc57b1f9..bc87232f40b 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -117,3 +117,36 @@ export const median = arr => {
const sorted = arr.sort((a, b) => a - b);
return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
};
+
+/**
+ * Computes the change from one value to the other as a percentage.
+ * @param {Number} firstY
+ * @param {Number} lastY
+ * @returns {Number}
+ */
+export const changeInPercent = (firstY, lastY) => {
+ if (firstY === lastY) {
+ return 0;
+ }
+
+ return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100);
+};
+
+/**
+ * Computes and formats the change from one value to the other as a percentage.
+ * Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and
+ * returns a given string if the result is not finite (for example, if the first value is "0").
+ * @param firstY
+ * @param lastY
+ * @param nonFiniteResult
+ * @returns {String}
+ */
+export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => {
+ const change = changeInPercent(firstY, lastY);
+
+ if (!Number.isFinite(change)) {
+ return nonFiniteResult;
+ }
+
+ return `${change >= 0 ? '+' : ''}${change}%`;
+};
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index d13fbeb5fc7..0c194d67bce 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -36,25 +36,26 @@ export const humanize = string =>
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters
- * @param {String} str
+ * Replaces whitespace and non-sluggish characters with a given separator
+ * @param {String} str - The string to slugify
+ * @param {String=} separator - The separator used to separate words (defaults to "-")
* @returns {String}
*/
-export const slugify = str => {
+export const slugify = (str, separator = '-') => {
const slug = str
.trim()
.toLowerCase()
- .replace(/[^a-zA-Z0-9_.-]+/g, '-');
+ .replace(/[^a-zA-Z0-9_.-]+/g, separator);
- return slug === '-' ? '' : slug;
+ return slug === separator ? '' : slug;
};
/**
- * Replaces whitespaces with underscore and converts to lower case
+ * Replaces whitespace and non-sluggish characters with underscores
* @param {String} str
* @returns {String}
*/
-export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_');
+export const slugifyWithUnderscore = str => slugify(str, '_');
/**
* Truncates given text
@@ -139,6 +140,14 @@ export const stripHtml = (string, replace = '') => {
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
/**
+ * Converts camelCase string to snake_case
+ *
+ * @param {*} string
+ */
+export const convertToSnakeCase = string =>
+ slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' '));
+
+/**
* Converts a sentence to lower case from the second word onwards
* e.g. Hello World => Hello world
*
diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js
deleted file mode 100644
index af3ca714400..00000000000
--- a/app/assets/javascripts/lib/utils/tick_formats.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import { createDateTimeFormat } from '../../locale';
-
-let dateTimeFormats;
-
-export const initDateFormats = () => {
- const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' });
- const monthFormat = createDateTimeFormat({ month: 'long' });
- const yearFormat = createDateTimeFormat({ year: 'numeric' });
-
- dateTimeFormats = {
- dayFormat,
- monthFormat,
- yearFormat,
- };
-};
-
-initDateFormats();
-
-/**
- Formats a localized date in way that it can be used for d3.js axis.tickFormat().
-
- That is, it displays
- - 4-digit for first of January
- - full month name for first of every month
- - day and abbreviated month otherwise
-
- see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat
- */
-export const dateTickFormat = date => {
- if (date.getDate() !== 1) {
- return dateTimeFormats.dayFormat.format(date);
- }
-
- if (date.getMonth() > 0) {
- return dateTimeFormats.monthFormat.format(date);
- }
-
- return dateTimeFormats.yearFormat.format(date);
-};
diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js
index b6b96fe7bd5..dd868bb9f4c 100644
--- a/app/assets/javascripts/line_highlighter.js
+++ b/app/assets/javascripts/line_highlighter.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */
+/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */
import $ from 'jquery';
@@ -82,13 +82,13 @@ LineHighlighter.prototype.highlightHash = function(newHash) {
};
LineHighlighter.prototype.clickHandler = function(event) {
- var current, lineNumber, range;
+ let range;
event.preventDefault();
this.clearHighlight();
- lineNumber = $(event.target)
+ const lineNumber = $(event.target)
.closest('a')
.data('lineNumber');
- current = this.hashToRange(this._hash);
+ const current = this.hashToRange(this._hash);
if (!(current[0] && event.shiftKey)) {
// If there's no current selection, or there is but Shift wasn't held,
// treat this like a single-line selection.
@@ -121,12 +121,11 @@ LineHighlighter.prototype.clearHighlight = function() {
//
// Returns an Array
LineHighlighter.prototype.hashToRange = function(hash) {
- var first, last, matches;
// ?L(\d+)(?:-(\d+))?$/)
- matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
+ const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/);
if (matches && matches.length) {
- first = parseInt(matches[1], 10);
- last = matches[2] ? parseInt(matches[2], 10) : null;
+ const first = parseInt(matches[1], 10);
+ const last = matches[2] ? parseInt(matches[2], 10) : null;
return [first, last];
} else {
return [null, null];
@@ -160,7 +159,7 @@ LineHighlighter.prototype.highlightRange = function(range) {
// Set the URL hash string
LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) {
- var hash;
+ let hash;
if (lastLineNumber) {
hash = `#L${firstLineNumber}-${lastLineNumber}`;
} else {
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index c19a845eb69..465c9a362ba 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,7 +37,6 @@ import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
import { initUserTracking } from './tracking';
import { __ } from './locale';
-import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout';
import 'ee_else_ce/main_ee';
@@ -97,7 +96,6 @@ function deferredInitialisation() {
initUsagePingConsent();
initUserPopovers();
initUserTracking();
- initPrivacyPolicyUpdateCallout();
if (document.querySelector('.search')) initSearchAutocomplete();
@@ -162,24 +160,6 @@ function deferredInitialisation() {
});
loadAwardsHandler();
-
- /**
- * Toggle Canary Badge
- *
- * For GitLab.com only, when the user is using canary
- * we render a Next badge and hide the option to switch
- * to canay
- */
- if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') {
- const canaryBadge = document.querySelector('.js-canary-badge');
- const canaryLink = document.querySelector('.js-canary-link');
- if (canaryBadge) {
- canaryBadge.classList.remove('hidden');
- }
- if (canaryLink) {
- canaryLink.classList.add('hidden');
- }
- }
}
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js
index 29a0e5a904a..f93dbcd4c47 100644
--- a/app/assets/javascripts/manual_ordering.js
+++ b/app/assets/javascripts/manual_ordering.js
@@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
createFlash(s__("ManualOrdering|Couldn't save the order of the issues"));
});
-const initManualOrdering = () => {
+const initManualOrdering = (draggableSelector = 'li.issue') => {
const issueList = document.querySelector('.manual-ordering');
if (!issueList || !(gon.current_user_id > 0)) {
@@ -34,14 +34,14 @@ const initManualOrdering = () => {
group: {
name: 'issues',
},
- draggable: 'li.issue',
+ draggable: draggableSelector,
onStart: () => {
sortableStart();
},
onUpdate: event => {
const el = event.item;
- const url = el.getAttribute('url');
+ const url = el.getAttribute('url') || el.dataset.url;
const prev = el.previousElementSibling;
const next = el.nextElementSibling;
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 7223b5c0d43..3a7ade5ad94 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */
+/* eslint-disable func-names, no-underscore-dangle, consistent-return */
import $ from 'jquery';
import { __ } from '~/locale';
@@ -17,14 +17,7 @@ function MergeRequest(opts) {
this.opts = opts != null ? opts : {};
this.submitNoteForm = this.submitNoteForm.bind(this);
this.$el = $('.merge-request');
- this.$('.show-all-commits').on(
- 'click',
- (function(_this) {
- return function() {
- return _this.showAllCommits();
- };
- })(this),
- );
+ this.$('.show-all-commits').on('click', () => this.showAllCommits());
this.initTabs();
this.initMRBtnListeners();
@@ -71,12 +64,10 @@ MergeRequest.prototype.showAllCommits = function() {
};
MergeRequest.prototype.initMRBtnListeners = function() {
- var _this;
- _this = this;
+ const _this = this;
return $('a.btn-close, a.btn-reopen').on('click', function(e) {
- var $this, shouldSubmit;
- $this = $(this);
- shouldSubmit = $this.hasClass('btn-comment');
+ const $this = $(this);
+ const shouldSubmit = $this.hasClass('btn-comment');
if (shouldSubmit && $this.data('submitted')) {
return;
}
@@ -95,8 +86,7 @@ MergeRequest.prototype.initMRBtnListeners = function() {
};
MergeRequest.prototype.submitNoteForm = function(form, $button) {
- var noteText;
- noteText = form.find('textarea.js-note-text').val();
+ const noteText = form.find('textarea.js-note-text').val();
if (noteText.trim().length > 0) {
form.submit();
$button.data('submitted', true);
@@ -106,7 +96,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) {
MergeRequest.prototype.initCommitMessageListeners = function() {
$(document).on('click', 'a.js-with-description-link', e => {
- var textarea = $('textarea.js-commit-message');
+ const textarea = $('textarea.js-commit-message');
e.preventDefault();
textarea.val(textarea.data('messageWithDescription'));
@@ -115,7 +105,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() {
});
$(document).on('click', 'a.js-without-description-link', e => {
- var textarea = $('textarea.js-commit-message');
+ const textarea = $('textarea.js-commit-message');
e.preventDefault();
textarea.val(textarea.data('messageWithoutDescription'));
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
new file mode 100644
index 00000000000..8eeac737a11
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -0,0 +1,227 @@
+<script>
+import { flatten, isNumber } from 'underscore';
+import { GlLineChart, 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 { graphDataValidatorForAnomalyValues } from '../../utils';
+import MonitorTimeSeriesChart from './time_series.vue';
+
+/**
+ * Series indexes
+ */
+const METRIC = 0;
+const UPPER = 1;
+const LOWER = 2;
+
+/**
+ * Boundary area appearance
+ */
+const AREA_COLOR = colorValues.anomalyAreaColor;
+const AREA_OPACITY = areaOpacityValues.default;
+const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`;
+
+/**
+ * The anomaly component highlights when a metric shows
+ * some anomalous behavior.
+ *
+ * It shows both a metric line and a boundary band in a
+ * time series chart, the boundary band shows the normal
+ * range of values the metric should take.
+ *
+ * This component accepts 3 queries, which contain the
+ * "metric", "upper" limit and "lower" limit.
+ *
+ * The upper and lower series are "stacked areas" visually
+ * to create the boundary band, and if any "metric" value
+ * is outside this band, it is highlighted to warn users.
+ *
+ * The boundary band stack must be painted above the 0 line
+ * so the area is shown correctly. If any of the values of
+ * the data are negative, the chart data is shifted to be
+ * above 0 line.
+ *
+ * The data passed to the time series is will always be
+ * positive, but reformatted to show the original values of
+ * data.
+ *
+ */
+export default {
+ components: {
+ GlLineChart,
+ GlChartSeriesLabel,
+ MonitorTimeSeriesChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: graphDataValidatorForAnomalyValues,
+ },
+ },
+ computed: {
+ series() {
+ return this.graphData.queries.map(query => {
+ const values = query.result[0] ? query.result[0].values : [];
+ return {
+ label: query.label,
+ data: values.filter(([, value]) => !Number.isNaN(value)),
+ };
+ });
+ },
+ /**
+ * If any of the values of the data is negative, the
+ * chart data is shifted to the lowest value
+ *
+ * This offset is the lowest value.
+ */
+ yOffset() {
+ const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y)));
+ const min = values.length ? Math.floor(Math.min(...values)) : 0;
+ return min < 0 ? -min : 0;
+ },
+ metricData() {
+ const originalMetricQuery = this.graphData.queries[0];
+
+ const metricQuery = { ...originalMetricQuery };
+ metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [
+ x,
+ y + this.yOffset,
+ ]);
+ return {
+ ...this.graphData,
+ type: 'line-chart',
+ queries: [metricQuery],
+ };
+ },
+ metricSeriesConfig() {
+ return {
+ type: 'line',
+ symbol: 'circle',
+ symbolSize: (val, params) => {
+ if (this.isDatapointAnomaly(params.dataIndex)) {
+ return symbolSizes.anomaly;
+ }
+ // 0 causes echarts to throw an error, use small number instead
+ // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423
+ return 0.001;
+ },
+ showSymbol: true,
+ itemStyle: {
+ color: params => {
+ if (this.isDatapointAnomaly(params.dataIndex)) {
+ return colorValues.anomalySymbol;
+ }
+ return colorValues.primaryColor;
+ },
+ },
+ };
+ },
+ chartOptions() {
+ const [, upperSeries, lowerSeries] = this.series;
+ const calcOffsetY = (data, offsetCallback) =>
+ data.map((value, dataIndex) => {
+ const [x, y] = value;
+ return [x, y + offsetCallback(dataIndex)];
+ });
+
+ const yAxisWithOffset = {
+ name: this.yAxisLabel,
+ axisLabel: {
+ formatter: num => roundOffFloat(num - this.yOffset, 3).toString(),
+ },
+ };
+
+ /**
+ * Boundary is rendered by 2 series: An invisible
+ * series (opacity: 0) stacked on a visible one.
+ *
+ * Order is important, lower boundary is stacked
+ * *below* the upper boundary.
+ */
+ const boundarySeries = [];
+
+ if (upperSeries.data.length && lowerSeries.data.length) {
+ // Lower boundary, plus the offset if negative values
+ boundarySeries.push(
+ this.makeBoundarySeries({
+ name: this.formatLegendLabel(lowerSeries),
+ data: calcOffsetY(lowerSeries.data, () => this.yOffset),
+ }),
+ );
+ // Upper boundary, minus the lower boundary
+ boundarySeries.push(
+ this.makeBoundarySeries({
+ name: this.formatLegendLabel(upperSeries),
+ data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)),
+ areaStyle: {
+ color: AREA_COLOR,
+ opacity: AREA_OPACITY,
+ },
+ }),
+ );
+ }
+ return { yAxis: yAxisWithOffset, series: boundarySeries };
+ },
+ },
+ methods: {
+ formatLegendLabel(query) {
+ return query.label;
+ },
+ yValue(seriesIndex, dataIndex) {
+ const d = this.series[seriesIndex].data[dataIndex];
+ return d && d[1];
+ },
+ yValueFormatted(seriesIndex, dataIndex) {
+ const y = this.yValue(seriesIndex, dataIndex);
+ return isNumber(y) ? y.toFixed(3) : '';
+ },
+ isDatapointAnomaly(dataIndex) {
+ const yVal = this.yValue(METRIC, dataIndex);
+ const yUpper = this.yValue(UPPER, dataIndex);
+ const yLower = this.yValue(LOWER, dataIndex);
+ return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower);
+ },
+ makeBoundarySeries(series) {
+ const stackKey = 'anomaly-boundary-series-stack';
+ return {
+ type: 'line',
+ stack: stackKey,
+ lineStyle: {
+ width: 0,
+ color: AREA_COLOR_RGBA, // legend color
+ },
+ color: AREA_COLOR_RGBA, // tooltip color
+ symbol: 'none',
+ ...series,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <monitor-time-series-chart
+ v-bind="$attrs"
+ :graph-data="metricData"
+ :option="chartOptions"
+ :series-config="metricSeriesConfig"
+ >
+ <slot></slot>
+ <template v-slot:tooltipContent="slotProps">
+ <div
+ v-for="(content, seriesIndex) in slotProps.tooltip.content"
+ :key="seriesIndex"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="content.color">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ yValueFormatted(seriesIndex, content.dataIndex) }}
+ </div>
+ </div>
+ </template>
+ </monitor-time-series-chart>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
new file mode 100644
index 00000000000..b8158247e49
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlHeatmap } from '@gitlab/ui/dist/charts';
+import dateformat from 'dateformat';
+import PrometheusHeader from '../shared/prometheus_header.vue';
+import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
+import { graphDataValidatorForValues } from '../../utils';
+
+export default {
+ components: {
+ GlHeatmap,
+ ResizableChartContainer,
+ PrometheusHeader,
+ },
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: graphDataValidatorForValues.bind(null, false),
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ chartData() {
+ return this.queries.result.reduce(
+ (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])],
+ [],
+ );
+ },
+ xAxisName() {
+ return this.graphData.x_label || '';
+ },
+ yAxisName() {
+ return this.graphData.y_label || '';
+ },
+ xAxisLabels() {
+ return this.queries.result.map(res => Object.values(res.metric)[0]);
+ },
+ yAxisLabels() {
+ return this.result.values.map(val => {
+ const [yLabel] = val;
+
+ return dateformat(new Date(yLabel), 'HH:MM:ss');
+ });
+ },
+ result() {
+ return this.queries.result[0];
+ },
+ queries() {
+ return this.graphData.queries[0];
+ },
+ },
+};
+</script>
+<template>
+ <div class="prometheus-graph col-12 col-lg-6">
+ <prometheus-header :graph-title="graphData.title" />
+ <resizable-chart-container>
+ <gl-heatmap
+ ref="heatmapChart"
+ v-bind="$attrs"
+ :data-series="chartData"
+ :x-axis-name="xAxisName"
+ :y-axis-name="yAxisName"
+ :x-axis-labels="xAxisLabels"
+ :y-axis-labels="yAxisLabels"
+ :width="containerWidth"
+ />
+ </resizable-chart-container>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 78fe575717a..6a88c8a5ee3 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,17 +1,23 @@
<script>
import { s__, __ } from '~/locale';
-import { GlLink, GlButton, GlTooltip } from '@gitlab/ui';
+import _ from 'underscore';
+import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
-import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
+import { roundOffFloat } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants';
+import {
+ chartHeight,
+ graphTypes,
+ lineTypes,
+ lineWidths,
+ symbolSizes,
+ dateFormats,
+} from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
-let debouncedResize;
-
export default {
components: {
GlAreaChart,
@@ -22,6 +28,9 @@ export default {
GlLink,
Icon,
},
+ directives: {
+ GlResizeObserverDirective,
+ },
inheritAttrs: false,
props: {
graphData: {
@@ -29,9 +38,15 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
- containerWidth: {
- type: Number,
- required: true,
+ option: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ seriesConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
},
deploymentData: {
type: Array,
@@ -99,29 +114,35 @@ export default {
const lineWidth =
appearance && appearance.line && appearance.line.width
? appearance.line.width
- : undefined;
+ : lineWidths.default;
const areaStyle = {
opacity:
appearance && appearance.area && typeof appearance.area.opacity === 'number'
? appearance.area.opacity
: undefined,
};
-
const series = makeDataSeries(query.result, {
name: this.formatLegendLabel(query),
lineStyle: {
type: lineType,
width: lineWidth,
+ color: this.primaryColor,
},
showSymbol: false,
areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
+ ...this.seriesConfig,
});
return acc.concat(series);
}, []);
},
+ chartOptionSeries() {
+ return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []);
+ },
chartOptions() {
+ const option = _.omit(this.option, 'series');
return {
+ series: this.chartOptionSeries,
xAxis: {
name: __('Time'),
type: 'time',
@@ -138,8 +159,8 @@ export default {
formatter: num => roundOffFloat(num, 3).toString(),
},
},
- series: this.scatterSeries,
dataZoom: [this.dataZoomConfig],
+ ...option,
};
},
dataZoomConfig() {
@@ -147,6 +168,14 @@ export default {
return handleIcon ? { handleIcon } : {};
},
+ /**
+ * This method returns the earliest time value in all series of a chart.
+ * Takes a chart data with data to populate a timeseries.
+ * data should be an array of data points [t, y] where t is a ISO formatted date,
+ * and is sorted by t (time).
+ * @returns {(String|null)} earliest x value from all series, or null when the
+ * chart series data is empty.
+ */
earliestDatapoint() {
return this.chartData.reduce((acc, series) => {
const { data } = series;
@@ -206,21 +235,13 @@ export default {
return `${this.graphData.y_label}`;
},
},
- watch: {
- containerWidth: 'onResize',
- },
mounted() {
const graphTitleEl = this.$refs.graphTitle;
if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) {
this.showTitleTooltip = true;
}
},
- beforeDestroy() {
- window.removeEventListener('resize', debouncedResize);
- },
created() {
- debouncedResize = debounceByAnimationFrame(this.onResize);
- window.addEventListener('resize', debouncedResize);
this.setSvg('rocket');
this.setSvg('scroll-handle');
},
@@ -241,10 +262,11 @@ export default {
this.tooltip.sha = deploy.sha.substring(0, 8);
this.tooltip.commitUrl = deploy.commitUrl;
} else {
- const { seriesName, color } = dataPoint;
+ const { seriesName, color, dataIndex } = dataPoint;
const value = yVal.toFixed(3);
this.tooltip.content.push({
name: seriesName,
+ dataIndex,
value,
color,
});
@@ -276,7 +298,7 @@ export default {
</script>
<template>
- <div class="prometheus-graph">
+ <div v-gl-resize-observer-directive="onResize" class="prometheus-graph">
<div class="prometheus-graph-header">
<h5
ref="graphTitle"
@@ -317,23 +339,27 @@ export default {
</template>
<template v-else>
<template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
- </div>
+ <slot name="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </slot>
</template>
<template slot="tooltipContent">
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="prepend-left-32">
- {{ content.value }}
+ <slot name="tooltipContent" :tooltip="tooltip">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
</div>
- </div>
+ </slot>
</template>
</template>
</component>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index b4ea415bb51..26e2c2568c1 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -11,7 +11,7 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
-import { __, s__ } from '~/locale';
+import { s__ } from '~/locale';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
@@ -22,12 +22,9 @@ import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
-import { sidebarAnimationDuration } from '../constants';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
-let sidebarMutationObserver;
-
export default {
components: {
VueDraggable,
@@ -167,10 +164,10 @@ export default {
data() {
return {
state: 'gettingStarted',
- elWidth: 0,
formIsValid: null,
selectedTimeWindow: {},
isRearrangingPanels: false,
+ hasValidDates: true,
};
},
computed: {
@@ -178,7 +175,7 @@ export default {
return this.customMetricsAvailable && this.customMetricsPath.length;
},
...mapState('monitoringDashboard', [
- 'groups',
+ 'dashboard',
'emptyState',
'showEmptyState',
'environments',
@@ -189,10 +186,15 @@ export default {
'additionalPanelTypesEnabled',
]),
firstDashboard() {
- return this.allDashboards[0] || {};
+ return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0
+ ? this.allDashboards[0]
+ : {};
+ },
+ selectedDashboard() {
+ return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
},
selectedDashboardText() {
- return this.currentDashboard || this.firstDashboard.display_name;
+ return this.selectedDashboard.display_name;
},
showRearrangePanelsBtn() {
return !this.showEmptyState && this.rearrangePanelsAvailable;
@@ -200,8 +202,13 @@ export default {
addingMetricsAvailable() {
return IS_EE && this.canAddMetrics && !this.showEmptyState;
},
- alertWidgetAvailable() {
- return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint;
+ hasHeaderButtons() {
+ return (
+ this.addingMetricsAvailable ||
+ this.showRearrangePanelsBtn ||
+ this.selectedDashboard.can_edit ||
+ this.externalDashboardUrl.length
+ );
},
},
created() {
@@ -214,11 +221,6 @@ export default {
projectPath: this.projectPath,
});
},
- beforeDestroy() {
- if (sidebarMutationObserver) {
- sidebarMutationObserver.disconnect();
- }
- },
mounted() {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
@@ -235,17 +237,12 @@ export default {
this.selectedTimeWindow = range;
if (!isValidDate(start) || !isValidDate(end)) {
+ this.hasValidDates = false;
this.showInvalidDateError();
} else {
+ this.hasValidDates = true;
this.fetchData(range);
}
-
- sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
- sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
- attributes: true,
- childList: false,
- subtree: false,
- });
}
},
methods: {
@@ -253,43 +250,25 @@ export default {
'fetchData',
'setGettingStartedEmptyState',
'setEndpoints',
- 'setDashboardEnabled',
+ 'setPanelGroupMetrics',
]),
chartsWithData(charts) {
- if (!this.useDashboardEndpoint) {
- return charts;
- }
return charts.filter(chart =>
chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
- csvText(graphData) {
- const chartData = graphData.queries[0].result[0].values;
- const yLabel = graphData.y_label;
- const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
- return chartData.reduce((csv, data) => {
- const row = data.join(',');
- return `${csv}${row}\r\n`;
- }, header);
- },
- downloadCsv(graphData) {
- const data = new Blob([this.csvText(graphData)], { type: 'text/plain' });
- return window.URL.createObjectURL(data);
- },
- // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed
- // Issue number: https://gitlab.com/gitlab-org/gitlab-foss/issues/63845
- getGraphAlerts(queries) {
- if (!this.allAlerts) return {};
- const metricIdsForChart = queries.map(q => q.metricId);
- return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
- },
- getGraphAlertValues(queries) {
- return Object.values(this.getGraphAlerts(queries));
- },
- showToast() {
- this.$toast.show(__('Link copied'));
- },
- // TODO: END
+ updateMetrics(key, metrics) {
+ this.setPanelGroupMetrics({
+ metrics,
+ key,
+ });
+ },
+ removeMetric(key, metrics, graphIndex) {
+ this.setPanelGroupMetrics({
+ metrics: metrics.filter((v, i) => i !== graphIndex),
+ key,
+ });
+ },
removeGraph(metrics, graphIndex) {
// At present graphs will not be removed, they should removed using the vuex store
// See https://gitlab.com/gitlab-org/gitlab/issues/27835
@@ -306,11 +285,6 @@ export default {
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
},
- onSidebarMutation() {
- setTimeout(() => {
- this.elWidth = this.$el.clientWidth;
- }, sidebarAnimationDuration);
- },
toggleRearrangingPanels() {
this.isRearrangingPanels = !this.isRearrangingPanels;
},
@@ -389,7 +363,7 @@ export default {
</gl-form-group>
<gl-form-group
- v-if="!showEmptyState"
+ v-if="hasValidDates"
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
@@ -403,7 +377,7 @@ export default {
</template>
<gl-form-group
- v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length"
+ v-if="hasHeaderButtons"
label-for="prometheus-graphs-dropdown-buttons"
class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end"
>
@@ -451,6 +425,14 @@ export default {
</gl-modal>
<gl-button
+ v-if="selectedDashboard.can_edit"
+ class="mt-1 js-edit-link"
+ :href="selectedDashboard.project_blob_path"
+ >
+ {{ __('Edit dashboard') }}
+ </gl-button>
+
+ <gl-button
v-if="externalDashboardUrl.length"
class="mt-1 js-external-dashboard-link"
variant="primary"
@@ -468,116 +450,46 @@ export default {
<div v-if="!showEmptyState">
<graph-group
- v-for="(groupData, index) in groups"
+ v-for="(groupData, index) in dashboard.panel_groups"
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
:collapse-group="groupHasData(groupData)"
>
- <template v-if="additionalPanelTypesEnabled">
- <vue-draggable
- :list="groupData.metrics"
- group="metrics-dashboard"
- :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
- :disabled="!isRearrangingPanels"
+ <vue-draggable
+ :value="groupData.metrics"
+ group="metrics-dashboard"
+ :component-data="{ attrs: { class: 'row mx-0 w-100' } }"
+ :disabled="!isRearrangingPanels"
+ @input="updateMetrics(groupData.key, $event)"
+ >
+ <div
+ v-for="(graphData, graphIndex) in groupData.metrics"
+ :key="`panel-type-${graphIndex}`"
+ class="col-12 col-lg-6 px-2 mb-2 draggable"
+ :class="{ 'draggable-enabled': isRearrangingPanels }"
>
- <div
- v-for="(graphData, graphIndex) in groupData.metrics"
- :key="`panel-type-${graphIndex}`"
- class="col-12 col-lg-6 px-2 mb-2 draggable"
- :class="{ 'draggable-enabled': isRearrangingPanels }"
- >
- <div class="position-relative draggable-panel js-draggable-panel">
- <div
- v-if="isRearrangingPanels"
- class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
- @click="removeGraph(groupData.metrics, graphIndex)"
- >
- <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
- ><icon name="close"
- /></a>
- </div>
-
- <panel-type
- :clipboard-text="
- generateLink(groupData.group, graphData.title, graphData.y_label)
- "
- :graph-data="graphData"
- :dashboard-width="elWidth"
- :alerts-endpoint="alertsEndpoint"
- :prometheus-alerts-available="prometheusAlertsAvailable"
- :index="`${index}-${graphIndex}`"
- />
+ <div class="position-relative draggable-panel js-draggable-panel">
+ <div
+ v-if="isRearrangingPanels"
+ class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
+ @click="removeGraph(groupData.metrics, graphIndex)"
+ >
+ <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
+ ><icon name="close"
+ /></a>
</div>
- </div>
- </vue-draggable>
- </template>
- <template v-else>
- <monitor-time-series-chart
- v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
- :key="graphIndex"
- class="col-12 col-lg-6 pb-3"
- :graph-data="graphData"
- :deployment-data="deploymentData"
- :thresholds="getGraphAlertValues(graphData.queries)"
- :container-width="elWidth"
- :project-path="projectPath"
- group-id="monitor-time-series-chart"
- >
- <div
- class="d-flex align-items-center"
- :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'"
- >
- <alert-widget
- v-if="alertWidgetAvailable && graphData"
- :modal-id="`alert-modal-${index}-${graphIndex}`"
+
+ <panel-type
+ :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)"
+ :graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
- :relevant-queries="graphData.queries"
- :alerts-to-manage="getGraphAlerts(graphData.queries)"
- @setAlerts="setAlerts"
+ :prometheus-alerts-available="prometheusAlertsAvailable"
+ :index="`${index}-${graphIndex}`"
/>
- <gl-dropdown
- v-gl-tooltip
- class="ml-2 mr-3"
- toggle-class="btn btn-transparent border-0"
- :right="true"
- :no-caret="true"
- :title="__('More actions')"
- >
- <template slot="button-content">
- <icon name="ellipsis_v" class="text-secondary" />
- </template>
- <gl-dropdown-item
- v-track-event="downloadCSVOptions(graphData.title)"
- :href="downloadCsv(graphData)"
- download="chart_metrics.csv"
- >
- {{ __('Download CSV') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-track-event="
- generateLinkToChartOptions(
- generateLink(groupData.group, graphData.title, graphData.y_label),
- )
- "
- class="js-chart-link"
- :data-clipboard-text="
- generateLink(groupData.group, graphData.title, graphData.y_label)
- "
- @click="showToast"
- >
- {{ __('Generate link to chart') }}
- </gl-dropdown-item>
- <gl-dropdown-item
- v-if="alertWidgetAvailable"
- v-gl-modal="`alert-modal-${index}-${graphIndex}`"
- >
- {{ __('Alerts') }}
- </gl-dropdown-item>
- </gl-dropdown>
</div>
- </monitor-time-series-chart>
- </template>
+ </div>
+ </vue-draggable>
</graph-group>
</div>
<empty-state
diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
index 4616a767295..8749019c5cd 100644
--- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
+++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue
@@ -55,17 +55,13 @@ export default {
};
},
},
+ watch: {
+ selectedTimeWindow() {
+ this.verifyTimeRange();
+ },
+ },
mounted() {
- const range = getTimeWindow(this.selectedTimeWindow);
- if (range) {
- this.selectedTimeWindowText = this.timeWindows[range];
- } else {
- this.customTime = {
- from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
- to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
- };
- this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
- }
+ this.verifyTimeRange();
},
methods: {
activeTimeWindow(key) {
@@ -87,6 +83,18 @@ export default {
closeDropdown() {
this.$refs.dropdown.hide();
},
+ verifyTimeRange() {
+ const range = getTimeWindow(this.selectedTimeWindow);
+ if (range) {
+ this.selectedTimeWindowText = this.timeWindows[range];
+ } else {
+ this.customTime = {
+ from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
+ to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
+ };
+ this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
+ }
+ },
},
};
</script>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index 7857aaa6ecc..f75839c7c6b 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -35,9 +35,9 @@ export default {
};
},
computed: {
- ...mapState('monitoringDashboard', ['groups', 'metricsWithData']),
+ ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']),
charts() {
- const groupWithMetrics = this.groups.find(group =>
+ const groupWithMetrics = this.dashboard.panel_groups.find(group =>
group.metrics.find(chart => this.chartHasData(chart)),
) || { metrics: [] };
@@ -78,9 +78,6 @@ export default {
}, sidebarAnimationDuration);
},
setInitialState() {
- this.setFeatureFlags({
- prometheusEndpointEnabled: true,
- });
this.setEndpoints({
dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl),
});
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index ee3a2bae79b..3cb6ccb64b1 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -45,7 +45,7 @@ export default {
<div v-if="showPanels" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
- <a role="button" @click="collapse">
+ <a role="button" class="js-graph-group-toggle" @click="collapse">
<icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" />
</a>
</div>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 1a14d06f4c8..cafb4b0b479 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -11,7 +11,9 @@ import {
} from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
+import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
+import MonitorHeatmapChart from './charts/heatmap.vue';
import MonitorEmptyChart from './charts/empty_chart.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
@@ -19,7 +21,7 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default {
components: {
MonitorSingleStatChart,
- MonitorTimeSeriesChart,
+ MonitorHeatmapChart,
MonitorEmptyChart,
Icon,
GlDropdown,
@@ -40,10 +42,6 @@ export default {
type: Object,
required: true,
},
- dashboardWidth: {
- type: Number,
- required: true,
- },
index: {
type: String,
required: false,
@@ -71,6 +69,12 @@ export default {
const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data);
},
+ monitorChartComponent() {
+ if (this.isPanelType('anomaly-chart')) {
+ return MonitorAnomalyChart;
+ }
+ return MonitorTimeSeriesChart;
+ },
},
methods: {
getGraphAlerts(queries) {
@@ -97,14 +101,19 @@ export default {
v-if="isPanelType('single-stat') && graphDataHasMetrics"
:graph-data="graphData"
/>
- <monitor-time-series-chart
+ <monitor-heatmap-chart
+ v-else-if="isPanelType('heatmap') && graphDataHasMetrics"
+ :graph-data="graphData"
+ :container-width="dashboardWidth"
+ />
+ <component
+ :is="monitorChartComponent"
v-else-if="graphDataHasMetrics"
:graph-data="graphData"
:deployment-data="deploymentData"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.queries)"
- :container-width="dashboardWidth"
- group-id="monitor-area-chart"
+ group-id="panel-type-chart"
>
<div class="d-flex align-items-center">
<alert-widget
@@ -146,6 +155,6 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</div>
- </monitor-time-series-chart>
+ </component>
<monitor-empty-chart v-else :graph-title="graphData.title" />
</template>
diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue
new file mode 100644
index 00000000000..153c8f389db
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue
@@ -0,0 +1,15 @@
+<script>
+export default {
+ props: {
+ graphTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="prometheus-graph-header">
+ <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5>
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 2836fe4fc26..1a1fcdd0e66 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -14,13 +14,28 @@ export const graphTypes = {
};
export const symbolSizes = {
+ anomaly: 8,
default: 14,
};
+export const areaOpacityValues = {
+ default: 0.2,
+};
+
+export const colorValues = {
+ primaryColor: '#1f78d1', // $blue-500 (see variables.scss)
+ anomalySymbol: '#db3b21',
+ anomalyAreaColor: '#1f78d1',
+};
+
export const lineTypes = {
default: 'solid',
};
+export const lineWidths = {
+ default: 2,
+};
+
export const timeWindows = {
thirtyMinutes: __('30 minutes'),
threeHours: __('3 hours'),
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 6aa1fb5e9c6..a14145d480b 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -11,13 +11,6 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
- if (gon.features) {
- store.dispatch('monitoringDashboard/setFeatureFlags', {
- prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint,
- additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes,
- });
- }
-
const [currentDashboard] = getParameterValues('dashboard');
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 2cf34ddb45b..6a8e3cc82f5 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -7,7 +7,7 @@ import { s__, __ } from '../../locale';
const MAX_REQUESTS = 3;
-function backOffRequest(makeRequestCallback) {
+export function backOffRequest(makeRequestCallback) {
let requestCounter = 0;
return backOff((next, stop) => {
makeRequestCallback()
@@ -35,14 +35,6 @@ export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, endpoints);
};
-export const setFeatureFlags = (
- { commit },
- { prometheusEndpointEnabled, additionalPanelTypesEnabled },
-) => {
- commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled);
- commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled);
-};
-
export const setShowErrorBanner = ({ commit }, enabled) => {
commit(types.SET_SHOW_ERROR_BANNER, enabled);
};
@@ -79,29 +71,7 @@ export const fetchData = ({ dispatch }, params) => {
dispatch('fetchEnvironmentsData');
};
-export const fetchMetricsData = ({ state, dispatch }, params) => {
- if (state.useDashboardEndpoint) {
- return dispatch('fetchDashboard', params);
- }
-
- dispatch('requestMetricsData');
-
- return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.data || !response.success) {
- dispatch('receiveMetricsDataFailure', null);
- createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
- }
- dispatch('receiveMetricsDataSuccess', response.data);
- })
- .catch(error => {
- dispatch('receiveMetricsDataFailure', error);
- if (state.setShowErrorBanner) {
- createFlash(s__('Metrics|There was an error while retrieving metrics'));
- }
- });
-};
+export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params);
export const fetchDashboard = ({ state, dispatch }, params) => {
dispatch('requestMetricsDashboard');
@@ -111,11 +81,13 @@ export const fetchDashboard = ({ state, dispatch }, params) => {
params.dashboard = state.currentDashboard;
}
- return axios
- .get(state.dashboardEndpoint, { params })
+ return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
.then(response => {
- dispatch('receiveMetricsDashboardSuccess', { response, params });
+ dispatch('receiveMetricsDashboardSuccess', {
+ response,
+ params,
+ });
})
.catch(error => {
dispatch('receiveMetricsDashboardFailure', error);
@@ -166,7 +138,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => {
commit(types.REQUEST_METRICS_DATA);
const promises = [];
- state.groups.forEach(group => {
+ state.dashboard.panel_groups.forEach(group => {
group.panels.forEach(panel => {
panel.metrics.forEach(metric => {
promises.push(dispatch('fetchPrometheusMetric', { metric, params }));
@@ -221,5 +193,15 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => {
});
};
+/**
+ * Set a new array of metrics to a panel group
+ * @param {*} data An object containing
+ * - `key` with a unique panel key
+ * - `metrics` with the metrics array
+ */
+export const setPanelGroupMetrics = ({ commit }, data) => {
+ commit(types.SET_PANEL_GROUP_METRICS, data);
+};
+
// 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 9c546427c6e..fa15a2ba800 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -9,10 +9,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC
export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
-export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
-export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED';
export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER';
+export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 320b33d3d69..696af5aed75 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
+import { slugify } from '~/lib/utils/text_utility';
import * as types from './mutation_types';
-import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils';
+import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils';
const normalizePanel = panel => panel.metrics.map(normalizeMetric);
@@ -10,10 +11,12 @@ export default {
state.showEmptyState = true;
},
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
- state.groups = groupData.map(group => {
+ state.dashboard.panel_groups = groupData.map((group, i) => {
+ const key = `${slugify(group.group || 'default')}-${i}`;
let { metrics = [], panels = [] } = group;
// each panel has metric information that needs to be normalized
+
panels = panels.map(panel => ({
...panel,
metrics: normalizePanel(panel),
@@ -22,24 +25,21 @@ export default {
// for backwards compatibility, and to limit Vue template changes:
// for each group alias panels to metrics
// for each panel alias metrics to queries
- if (state.useDashboardEndpoint) {
- metrics = panels.map(panel => ({
- ...panel,
- queries: panel.metrics,
- }));
- }
+ metrics = panels.map(panel => ({
+ ...panel,
+ queries: panel.metrics,
+ }));
return {
...group,
panels,
- metrics: normalizeMetrics(sortMetrics(metrics)),
+ key,
+ metrics: normalizeMetrics(metrics),
};
});
- if (!state.groups.length) {
+ if (!state.dashboard.panel_groups.length) {
state.emptyState = 'noData';
- } else {
- state.showEmptyState = false;
}
},
[types.RECEIVE_METRICS_DATA_FAILURE](state, error) {
@@ -65,7 +65,7 @@ export default {
state.showEmptyState = false;
- state.groups.forEach(group => {
+ state.dashboard.panel_groups.forEach(group => {
group.metrics.forEach(metric => {
metric.queries.forEach(query => {
if (query.metric_id === metricId) {
@@ -86,9 +86,6 @@ export default {
state.currentDashboard = endpoints.currentDashboard;
state.projectPath = endpoints.projectPath;
},
- [types.SET_DASHBOARD_ENABLED](state, enabled) {
- state.useDashboardEndpoint = enabled;
- },
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
},
@@ -97,12 +94,13 @@ export default {
state.emptyState = 'noData';
},
[types.SET_ALL_DASHBOARDS](state, dashboards) {
- state.allDashboards = dashboards;
- },
- [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) {
- state.additionalPanelTypesEnabled = enabled;
+ state.allDashboards = dashboards || [];
},
[types.SET_SHOW_ERROR_BANNER](state, enabled) {
state.showErrorBanner = enabled;
},
+ [types.SET_PANEL_GROUP_METRICS](state, payload) {
+ const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key);
+ panelGroup.metrics = payload.metrics;
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index e894e988f6a..87e94311176 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -7,12 +7,12 @@ export default () => ({
environmentsEndpoint: null,
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
- useDashboardEndpoint: false,
- additionalPanelTypesEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
showErrorBanner: true,
- groups: [],
+ dashboard: {
+ panel_groups: [],
+ },
deploymentData: [],
environments: [],
metricsWithData: [],
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index a19829f0c65..8a396b15a31 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -82,12 +82,6 @@ export const normalizeMetric = (metric = {}) =>
'id',
);
-export const sortMetrics = metrics =>
- _.chain(metrics)
- .sortBy('title')
- .sortBy('weight')
- .value();
-
export const normalizeQueryResult = timeSeries => {
let normalizedResult = {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 00f188c1d5a..2ae1647011d 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,7 +1,6 @@
import dateformat from 'dateformat';
import { secondsIn, dateTimePickerRegex, dateFormats } from './constants';
-
-const secondsToMilliseconds = seconds => seconds * 1000;
+import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
export const getTimeDiff = timeWindow => {
const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds
@@ -131,4 +130,20 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
+/**
+ * This function validates the graph data contains exactly 3 queries plus
+ * value validations from graphDataValidatorForValues.
+ * @param {Object} isValues
+ * @param {Object} graphData the graph data response from a prometheus request
+ * @returns {boolean} true if the data is valid
+ */
+export const graphDataValidatorForAnomalyValues = graphData => {
+ const anomalySeriesCount = 3; // metric, upper, lower
+ return (
+ graphData.queries &&
+ graphData.queries.length === anomalySeriesCount &&
+ graphDataValidatorForValues(false, graphData)
+ );
+};
+
export default {};
diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js
index a0ba2193d90..c301c304409 100644
--- a/app/assets/javascripts/network/branch_graph.js
+++ b/app/assets/javascripts/network/branch_graph.js
@@ -1,12 +1,12 @@
-/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */
+/* eslint-disable func-names, consistent-return, camelcase */
import $ from 'jquery';
import { __ } from '../locale';
import axios from '../lib/utils/axios_utils';
import Raphael from './raphael';
-export default (function() {
- function BranchGraph(element1, options1) {
+export default class BranchGraph {
+ constructor(element1, options1) {
this.element = element1;
this.options = options1;
this.scrollTop = this.scrollTop.bind(this);
@@ -28,7 +28,7 @@ export default (function() {
this.load();
}
- BranchGraph.prototype.load = function() {
+ load() {
axios
.get(this.options.url)
.then(({ data }) => {
@@ -37,21 +37,23 @@ export default (function() {
this.buildGraph();
})
.catch(() => __('Error fetching network graph.'));
- };
+ }
- BranchGraph.prototype.prepareData = function(days, commits) {
- var c, ch, cw, j, len, ref;
+ prepareData(days, commits) {
+ let c = 0;
+ let j = 0;
+ let len = 0;
this.days = days;
this.commits = commits;
this.collectParents();
this.graphHeight = $(this.element).height();
this.graphWidth = $(this.element).width();
- ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150);
- cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300);
+ const ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150);
+ const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300);
this.r = Raphael(this.element.get(0), cw, ch);
this.top = this.r.set();
this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320);
- ref = this.commits;
+ const ref = this.commits;
for (j = 0, len = ref.length; j < len; j += 1) {
c = ref[j];
if (c.id in this.parents) {
@@ -61,37 +63,34 @@ export default (function() {
this.markCommit(c);
}
return this.collectColors();
- };
+ }
- BranchGraph.prototype.collectParents = function() {
- var c, j, len, p, ref, results;
- ref = this.commits;
- results = [];
+ collectParents() {
+ let j = 0;
+ let l = 0;
+ let len = 0;
+ let len1 = 0;
+ const ref = this.commits;
+ const results = [];
for (j = 0, len = ref.length; j < len; j += 1) {
- c = ref[j];
+ const c = ref[j];
this.mtime = Math.max(this.mtime, c.time);
this.mspace = Math.max(this.mspace, c.space);
- results.push(
- function() {
- var l, len1, ref1, results1;
- ref1 = c.parents;
- results1 = [];
- for (l = 0, len1 = ref1.length; l < len1; l += 1) {
- p = ref1[l];
- this.parents[p[0]] = true;
- results1.push((this.mspace = Math.max(this.mspace, p[1])));
- }
- return results1;
- }.call(this),
- );
+ const ref1 = c.parents;
+ const results1 = [];
+ for (l = 0, len1 = ref1.length; l < len1; l += 1) {
+ const p = ref1[l];
+ this.parents[p[0]] = true;
+ results1.push((this.mspace = Math.max(this.mspace, p[1])));
+ }
+ results.push(results1);
}
return results;
- };
+ }
- BranchGraph.prototype.collectColors = function() {
- var k, results;
- k = 0;
- results = [];
+ collectColors() {
+ let k = 0;
+ const results = [];
while (k < this.mspace) {
this.colors.push(Raphael.getColor(0.8));
// Skipping a few colors in the spectrum to get more contrast between colors
@@ -100,23 +99,24 @@ export default (function() {
results.push((k += 1));
}
return results;
- };
+ }
- BranchGraph.prototype.buildGraph = function() {
- var cuday, cumonth, day, len, mm, ref;
+ buildGraph() {
+ let mm = 0;
+ let len = 0;
+ let cuday = 0;
+ let cumonth = '';
const { r } = this;
- cuday = 0;
- cumonth = '';
r.rect(0, 0, 40, this.barHeight).attr({
fill: '#222',
});
r.rect(40, 0, 30, this.barHeight).attr({
fill: '#444',
});
- ref = this.days;
+ const ref = this.days;
for (mm = 0, len = ref.length; mm < len; mm += 1) {
- day = ref[mm];
+ const day = ref[mm];
if (cuday !== day[0] || cumonth !== day[1]) {
// Dates
r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({
@@ -138,29 +138,28 @@ export default (function() {
}
this.renderPartialGraph();
return this.bindEvents();
- };
+ }
- BranchGraph.prototype.renderPartialGraph = function() {
- var commit, end, i, isGraphEdge, start, x, y;
- start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10;
+ renderPartialGraph() {
+ const isGraphEdge = true;
+ let i = 0;
+ let start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10;
if (start < 0) {
- isGraphEdge = true;
start = 0;
}
- end = start + 40;
+ let end = start + 40;
if (this.commits.length < end) {
- isGraphEdge = true;
end = this.commits.length;
}
if (this.prev_start === -1 || Math.abs(this.prev_start - start) > 10 || isGraphEdge) {
i = start;
this.prev_start = start;
while (i < end) {
- commit = this.commits[i];
+ const commit = this.commits[i];
i += 1;
if (commit.hasDrawn !== true) {
- x = this.offsetX + this.unitSpace * (this.mspace - commit.space);
- y = this.offsetY + this.unitTime * commit.time;
+ const x = this.offsetX + this.unitSpace * (this.mspace - commit.space);
+ const y = this.offsetY + this.unitTime * commit.time;
this.drawDot(x, y, commit);
this.drawLines(x, y, commit);
this.appendLabel(x, y, commit);
@@ -170,70 +169,62 @@ export default (function() {
}
return this.top.toFront();
}
- };
+ }
- BranchGraph.prototype.bindEvents = function() {
+ bindEvents() {
const { element } = this;
- return $(element).scroll(
- (function(_this) {
- return function() {
- return _this.renderPartialGraph();
- };
- })(this),
- );
- };
+ return $(element).scroll(() => this.renderPartialGraph());
+ }
- BranchGraph.prototype.scrollDown = function() {
+ scrollDown() {
this.element.scrollTop(this.element.scrollTop() + 50);
return this.renderPartialGraph();
- };
+ }
- BranchGraph.prototype.scrollUp = function() {
+ scrollUp() {
this.element.scrollTop(this.element.scrollTop() - 50);
return this.renderPartialGraph();
- };
+ }
- BranchGraph.prototype.scrollLeft = function() {
+ scrollLeft() {
this.element.scrollLeft(this.element.scrollLeft() - 50);
return this.renderPartialGraph();
- };
+ }
- BranchGraph.prototype.scrollRight = function() {
+ scrollRight() {
this.element.scrollLeft(this.element.scrollLeft() + 50);
return this.renderPartialGraph();
- };
+ }
- BranchGraph.prototype.scrollBottom = function() {
+ scrollBottom() {
return this.element.scrollTop(this.element.find('svg').height());
- };
+ }
- BranchGraph.prototype.scrollTop = function() {
+ scrollTop() {
return this.element.scrollTop(0);
- };
-
- BranchGraph.prototype.appendLabel = function(x, y, commit) {
- var label, rect, shortrefs, text, textbox;
+ }
+ appendLabel(x, y, commit) {
if (!commit.refs) {
return;
}
const { r } = this;
- shortrefs = commit.refs;
+ let shortrefs = commit.refs;
// Truncate if longer than 15 chars
if (shortrefs.length > 17) {
shortrefs = `${shortrefs.substr(0, 15)}…`;
}
- text = r.text(x + 4, y, shortrefs).attr({
+ const text = r.text(x + 4, y, shortrefs).attr({
'text-anchor': 'start',
font: '10px Monaco, monospace',
fill: '#FFF',
title: commit.refs,
});
- textbox = text.getBBox();
+ const textbox = text.getBBox();
// Create rectangle based on the size of the textbox
- rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({
+ const rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({
fill: '#000',
'fill-opacity': 0.5,
stroke: 'none',
@@ -244,13 +235,13 @@ export default (function() {
'fill-opacity': 0.5,
stroke: 'none',
});
- label = r.set(rect, text);
+ const label = r.set(rect, text);
label.transform(['t', -rect.getBBox().width - 15, 0]);
// Set text to front
return text.toFront();
- };
+ }
- BranchGraph.prototype.appendAnchor = function(x, y, commit) {
+ appendAnchor(x, y, commit) {
const { r, top, options } = this;
const anchor = r
.circle(x, y, 10)
@@ -270,9 +261,9 @@ export default (function() {
},
);
return top.push(anchor);
- };
+ }
- BranchGraph.prototype.drawDot = function(x, y, commit) {
+ drawDot(x, y, commit) {
const { r } = this;
r.circle(x, y, 3).attr({
fill: this.colors[commit.space],
@@ -293,20 +284,24 @@ export default (function() {
'text-anchor': 'start',
font: '14px Monaco, monospace',
});
- };
+ }
- BranchGraph.prototype.drawLines = function(x, y, commit) {
- var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route;
+ drawLines(x, y, commit) {
+ let i = 0;
+ let len = 0;
+ let arrow = '';
+ let offset = [];
+ let color = [];
const { r } = this;
const ref = commit.parents;
const results = [];
for (i = 0, len = ref.length; i < len; i += 1) {
- parent = ref[i];
- parentCommit = this.preparedCommits[parent[0]];
- parentY = this.offsetY + this.unitTime * parentCommit.time;
- parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space);
- parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]);
+ const parent = ref[i];
+ const parentCommit = this.preparedCommits[parent[0]];
+ const parentY = this.offsetY + this.unitTime * parentCommit.time;
+ const parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space);
+ const parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]);
// Set line color
if (parentCommit.space <= commit.space) {
color = this.colors[commit.space];
@@ -325,7 +320,7 @@ export default (function() {
arrow = 'l-5,0,2,4,3,-4,-4,2';
}
// Start point
- route = ['M', x + offset[0], y + offset[1]];
+ const route = ['M', x + offset[0], y + offset[1]];
// Add arrow if not first parent
if (i > 0) {
route.push(arrow);
@@ -344,9 +339,9 @@ export default (function() {
);
}
return results;
- };
+ }
- BranchGraph.prototype.markCommit = function(commit) {
+ markCommit(commit) {
if (commit.id === this.options.commit_id) {
const { r } = this;
const x = this.offsetX + this.unitSpace * (this.mspace - commit.space);
@@ -359,7 +354,5 @@ export default (function() {
// Displayed in the center
return this.element.scrollTop(y - this.graphHeight / 2);
}
- };
-
- return BranchGraph;
-})();
+ }
+}
diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js
index 9f9db21d65b..918c6e408a2 100644
--- a/app/assets/javascripts/new_branch_form.js
+++ b/app/assets/javascripts/new_branch_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */
+/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/i18n/no-non-i18n-strings */
import $ from 'jquery';
import RefSelectDropdown from './ref_select_dropdown';
@@ -26,23 +26,22 @@ export default class NewBranchForm {
}
setupRestrictions() {
- var endsWith, invalid, single, startsWith;
- startsWith = {
+ const startsWith = {
pattern: /^(\/|\.)/g,
prefix: "can't start with",
conjunction: 'or',
};
- endsWith = {
+ const endsWith = {
pattern: /(\/|\.|\.lock)$/g,
prefix: "can't end in",
conjunction: 'or',
};
- invalid = {
+ const invalid = {
pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g,
prefix: "can't contain",
conjunction: ', ',
};
- single = {
+ const single = {
pattern: /^@+$/g,
prefix: "can't be",
conjunction: 'or',
@@ -51,19 +50,17 @@ export default class NewBranchForm {
}
validate() {
- var errorMessage, errors, formatter, unique, validator;
const { indexOf } = [];
this.branchNameError.empty();
- unique = function(values, value) {
+ const unique = function(values, value) {
if (indexOf.call(values, value) === -1) {
values.push(value);
}
return values;
};
- formatter = function(values, restriction) {
- var formatted;
- formatted = values.map(value => {
+ const formatter = function(values, restriction) {
+ const formatted = values.map(value => {
switch (false) {
case !/\s/.test(value):
return 'spaces';
@@ -75,20 +72,17 @@ export default class NewBranchForm {
});
return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`;
};
- validator = (function(_this) {
- return function(errors, restriction) {
- var matched;
- matched = _this.name.val().match(restriction.pattern);
- if (matched) {
- return errors.concat(formatter(matched.reduce(unique, []), restriction));
- } else {
- return errors;
- }
- };
- })(this);
- errors = this.restrictions.reduce(validator, []);
+ const validator = (errors, restriction) => {
+ const matched = this.name.val().match(restriction.pattern);
+ if (matched) {
+ return errors.concat(formatter(matched.reduce(unique, []), restriction));
+ } else {
+ return errors;
+ }
+ };
+ const errors = this.restrictions.reduce(validator, []);
if (errors.length > 0) {
- errorMessage = $('<span/>').text(errors.join(', '));
+ const errorMessage = $('<span/>').text(errors.join(', '));
return this.branchNameError.append(errorMessage);
}
}
diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js
index b142f212eb0..037be8467cb 100644
--- a/app/assets/javascripts/new_commit_form.js
+++ b/app/assets/javascripts/new_commit_form.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-var, no-return-assign */
+/* eslint-disable no-return-assign */
export default class NewCommitForm {
constructor(form) {
this.form = form;
@@ -11,8 +11,7 @@ export default class NewCommitForm {
this.renderDestination();
}
renderDestination() {
- var different;
- different = this.branchName.val() !== this.originalBranch.val();
+ const different = this.branchName.val() !== this.originalBranch.val();
if (different) {
this.createMergeRequestContainer.show();
if (!this.wasDifferent) {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 3715a91d599..defa278c089 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1,8 +1,8 @@
-/* eslint-disable no-restricted-properties, func-names, no-var, camelcase,
+/* eslint-disable no-restricted-properties, no-var, camelcase,
no-unused-expressions, one-var, default-case,
-consistent-return, no-alert, no-return-assign,
-no-param-reassign, no-else-return, vars-on-top,
-no-shadow, no-useless-escape, class-methods-use-this */
+consistent-return, no-alert, no-param-reassign, no-else-return,
+vars-on-top, no-shadow, no-useless-escape,
+class-methods-use-this */
/* global ResolveService */
@@ -281,14 +281,7 @@ export default class Notes {
if (Notes.interval) {
clearInterval(Notes.interval);
}
- return (Notes.interval = setInterval(
- (function(_this) {
- return function() {
- return _this.refresh();
- };
- })(this),
- this.pollingInterval,
- ));
+ Notes.interval = setInterval(() => this.refresh(), this.pollingInterval);
}
refresh() {
@@ -847,57 +840,52 @@ export default class Notes {
var noteElId, $note;
$note = $(e.currentTarget).closest('.note');
noteElId = $note.attr('id');
- $(`.note[id="${noteElId}"]`).each(
- (function() {
- // A same note appears in the "Discussion" and in the "Changes" tab, we have
- // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
- // where $('#noteId') would return only one.
- return function(i, el) {
- var $note, $notes;
- $note = $(el);
- $notes = $note.closest('.discussion-notes');
- const discussionId = $('.notes', $notes).data('discussionId');
-
- if (typeof gl.diffNotesCompileComponents !== 'undefined') {
- if (gl.diffNoteApps[noteElId]) {
- gl.diffNoteApps[noteElId].$destroy();
- }
- }
-
- $note.remove();
+ $(`.note[id="${noteElId}"]`).each((i, el) => {
+ // A same note appears in the "Discussion" and in the "Changes" tab, we have
+ // to remove all. Using $('.note[id='noteId']') ensure we get all the notes,
+ // where $('#noteId') would return only one.
+ const $note = $(el);
+ const $notes = $note.closest('.discussion-notes');
+ const discussionId = $('.notes', $notes).data('discussionId');
+
+ if (typeof gl.diffNotesCompileComponents !== 'undefined') {
+ if (gl.diffNoteApps[noteElId]) {
+ gl.diffNoteApps[noteElId].$destroy();
+ }
+ }
- // check if this is the last note for this line
- if ($notes.find('.note').length === 0) {
- var notesTr = $notes.closest('tr');
+ $note.remove();
- // "Discussions" tab
- $notes.closest('.timeline-entry').remove();
+ // check if this is the last note for this line
+ if ($notes.find('.note').length === 0) {
+ const notesTr = $notes.closest('tr');
- $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
+ // "Discussions" tab
+ $notes.closest('.timeline-entry').remove();
- // The notes tr can contain multiple lists of notes, like on the parallel diff
- // notesTr does not exist for image diffs
- if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) {
- const $diffFile = $notes.closest('.diff-file');
- if ($diffFile.length > 0) {
- const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', {
- detail: {
- // badgeNumber's start with 1 and index starts with 0
- badgeNumber: $notes.index() + 1,
- },
- });
+ $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue');
- $diffFile[0].dispatchEvent(removeBadgeEvent);
- }
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ // notesTr does not exist for image diffs
+ if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) {
+ const $diffFile = $notes.closest('.diff-file');
+ if ($diffFile.length > 0) {
+ const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', {
+ detail: {
+ // badgeNumber's start with 1 and index starts with 0
+ badgeNumber: $notes.index() + 1,
+ },
+ });
- $notes.remove();
- } else if (notesTr.length > 0) {
- notesTr.remove();
- }
+ $diffFile[0].dispatchEvent(removeBadgeEvent);
}
- };
- })(this),
- );
+
+ $notes.remove();
+ } else if (notesTr.length > 0) {
+ notesTr.remove();
+ }
+ }
+ });
Notes.checkMergeRequestStatus();
return this.updateNotesCount(-1);
diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue
new file mode 100644
index 00000000000..4c9075912ee
--- /dev/null
+++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue
@@ -0,0 +1,133 @@
+<script>
+import { mapActions } from 'vuex';
+import _ from 'underscore';
+
+import { s__, __, sprintf } from '~/locale';
+import { truncateSha } from '~/lib/utils/text_utility';
+
+import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
+import noteEditedText from './note_edited_text.vue';
+import noteHeader from './note_header.vue';
+
+export default {
+ name: 'DiffDiscussionHeader',
+ components: {
+ userAvatarLink,
+ noteEditedText,
+ noteHeader,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ notes() {
+ return this.discussion.notes;
+ },
+ firstNote() {
+ return this.notes[0];
+ },
+ lastNote() {
+ return this.notes[this.notes.length - 1];
+ },
+ author() {
+ return this.firstNote.author;
+ },
+ resolvedText() {
+ return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
+ },
+ lastUpdatedBy() {
+ return this.notes.length > 1 ? this.lastNote.author : null;
+ },
+ lastUpdatedAt() {
+ return this.notes.length > 1 ? this.lastNote.created_at : null;
+ },
+ headerText() {
+ const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
+ const linkEnd = '</a>';
+
+ const { commit_id: commitId } = this.discussion;
+ let commitDisplay = commitId;
+
+ if (commitId) {
+ commitDisplay = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
+ }
+
+ const {
+ for_commit: isForCommit,
+ diff_discussion: isDiffDiscussion,
+ active: isActive,
+ } = this.discussion;
+
+ let text = s__('MergeRequests|started a thread');
+ if (isForCommit) {
+ text = s__(
+ 'MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}',
+ );
+ } else if (isDiffDiscussion && commitId) {
+ text = isActive
+ ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}')
+ : s__(
+ 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}',
+ );
+ } else if (isDiffDiscussion) {
+ text = isActive
+ ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
+ : s__(
+ 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
+ );
+ }
+
+ return sprintf(text, { commitDisplay, linkStart, linkEnd }, false);
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDiscussion']),
+ toggleDiscussionHandler() {
+ this.toggleDiscussion({ discussionId: this.discussion.id });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-header note-wrapper">
+ <div v-once class="timeline-icon align-self-start flex-shrink-0">
+ <user-avatar-link
+ v-if="author"
+ :link-href="author.path"
+ :img-src="author.avatar_url"
+ :img-alt="author.name"
+ :img-size="40"
+ />
+ </div>
+ <div class="timeline-content w-100">
+ <note-header
+ :author="author"
+ :created-at="firstNote.created_at"
+ :note-id="firstNote.id"
+ :include-toggle="true"
+ :expanded="discussion.expanded"
+ @toggleHandler="toggleDiscussionHandler"
+ >
+ <span v-html="headerText"></span>
+ </note-header>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ <note-edited-text
+ v-else-if="lastUpdatedAt"
+ :edited-at="lastUpdatedAt"
+ :edited-by="lastUpdatedBy"
+ :action-text="__('Last updated')"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 3158e086f6c..e4f09492d9c 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -101,6 +101,7 @@ export default {
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</a>
</template>
+ <slot name="extra-controls"></slot>
<i
class="fa fa-spinner fa-spin editing-spinner"
:aria-label="__('Comment is being updated')"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index cb1975a8962..47ec740b63a 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -1,18 +1,15 @@
<script>
-import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
-import { truncateSha } from '~/lib/utils/text_utility';
-import { s__, __, sprintf } from '~/locale';
+import { s__, __ } from '~/locale';
import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import noteHeader from './note_header.vue';
+import diffDiscussionHeader from './diff_discussion_header.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
-import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
import noteable from '../mixins/noteable';
@@ -27,9 +24,8 @@ export default {
components: {
icon,
userAvatarLink,
- noteHeader,
+ diffDiscussionHeader,
noteSignedOutWidget,
- noteEditedText,
noteForm,
DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
TimelineEntryItem,
@@ -92,9 +88,6 @@ export default {
currentUser() {
return this.getUserData;
},
- author() {
- return this.firstNote.author;
- },
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
},
@@ -104,27 +97,6 @@ export default {
firstNote() {
return this.discussion.notes.slice(0, 1)[0];
},
- lastUpdatedBy() {
- const { notes } = this.discussion;
-
- if (notes.length > 1) {
- return notes[notes.length - 1].author;
- }
-
- return null;
- },
- lastUpdatedAt() {
- const { notes } = this.discussion;
-
- if (notes.length > 1) {
- return notes[notes.length - 1].created_at;
- }
-
- return null;
- },
- resolvedText() {
- return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
- },
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
},
@@ -150,40 +122,6 @@ export default {
shouldHideDiscussionBody() {
return this.shouldRenderDiffs && !this.isExpanded;
},
- actionText() {
- const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
- const linkEnd = '</a>';
-
- let { commit_id: commitId } = this.discussion;
- if (commitId) {
- commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
- }
-
- const {
- for_commit: isForCommit,
- diff_discussion: isDiffDiscussion,
- active: isActive,
- } = this.discussion;
-
- let text = s__('MergeRequests|started a thread');
- if (isForCommit) {
- text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}');
- } else if (isDiffDiscussion && commitId) {
- text = isActive
- ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}')
- : s__(
- 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
- );
- } else if (isDiffDiscussion) {
- text = isActive
- ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
- : s__(
- 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
- );
- }
-
- return sprintf(text, { commitId, linkStart, linkEnd }, false);
- },
diffLine() {
if (this.line) {
return this.line;
@@ -208,16 +146,11 @@ export default {
methods: {
...mapActions([
'saveNote',
- 'toggleDiscussion',
'removePlaceholderNotes',
'toggleResolveNote',
'expandDiscussion',
'removeConvertedDiscussion',
]),
- truncateSha,
- toggleDiscussionHandler() {
- this.toggleDiscussion({ discussionId: this.discussion.id });
- },
showReplyForm() {
this.isReplying = true;
},
@@ -311,43 +244,7 @@ export default {
class="discussion js-discussion-container"
data-qa-selector="discussion_content"
>
- <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper">
- <div v-once class="timeline-icon align-self-start flex-shrink-0">
- <user-avatar-link
- v-if="author"
- :link-href="author.path"
- :img-src="author.avatar_url"
- :img-alt="author.name"
- :img-size="40"
- />
- </div>
- <div class="timeline-content w-100">
- <note-header
- :author="author"
- :created-at="firstNote.created_at"
- :note-id="firstNote.id"
- :include-toggle="true"
- :expanded="discussion.expanded"
- @toggleHandler="toggleDiscussionHandler"
- >
- <span v-html="actionText"></span>
- </note-header>
- <note-edited-text
- v-if="discussion.resolved"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline"
- />
- <note-edited-text
- v-else-if="lastUpdatedAt"
- :edited-at="lastUpdatedAt"
- :edited-by="lastUpdatedBy"
- action-text="Last updated"
- class-name="discussion-headline-light js-discussion-headline"
- />
- </div>
- </div>
+ <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" />
<div v-if="!shouldHideDiscussionBody" class="discussion-body">
<component
:is="wrapperComponent"
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index c6c97489e5e..9d1de4ef8a0 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -122,6 +122,8 @@ export default {
this.toggleAward({ awardName, noteId });
});
}
+
+ window.addEventListener('hashchange', this.handleHashChanged);
},
updated() {
this.$nextTick(() => {
@@ -131,6 +133,7 @@ export default {
},
beforeDestroy() {
this.stopPolling();
+ window.removeEventListener('hashchange', this.handleHashChanged);
},
methods: {
...mapActions([
@@ -138,7 +141,6 @@ export default {
'fetchDiscussions',
'poll',
'toggleAward',
- 'scrollToNoteIfNeeded',
'setNotesData',
'setNoteableData',
'setUserData',
@@ -151,6 +153,13 @@ export default {
'convertToDiscussion',
'stopPolling',
]),
+ handleHashChanged() {
+ const noteId = this.checkLocationHash();
+
+ if (noteId) {
+ this.setTargetNoteHash(getLocationHash());
+ }
+ },
fetchNotes() {
if (this.isFetching) return null;
@@ -194,6 +203,8 @@ export default {
this.expandDiscussion({ discussionId: discussion.id });
}
}
+
+ return noteId;
},
startReplying(discussionId) {
return this.convertToDiscussion(discussionId)
diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js
new file mode 100644
index 00000000000..12d80f3faa2
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/description_version_history.js
@@ -0,0 +1,12 @@
+// Placeholder for GitLab FOSS
+// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js
+export default {
+ computed: {
+ canSeeDescriptionVersion() {},
+ shouldShowDescriptionVersion() {},
+ descriptionVersionToggleIcon() {},
+ },
+ methods: {
+ toggleDescriptionVersion() {},
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 004035ea1d4..82c291379ec 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -12,6 +12,7 @@ import service from '../services/notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
+import { mergeUrlParams } from '../../lib/utils/url_utility';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import { __ } from '~/locale';
import Api from '~/api';
@@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) =>
export const removeConvertedDiscussion = ({ commit }, noteId) =>
commit(types.REMOVE_CONVERTED_DISCUSSION, noteId);
+export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => {
+ let requestUrl = endpoint;
+
+ if (startingVersion) {
+ requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl);
+ }
+
+ return axios
+ .get(requestUrl)
+ .then(res => res.data)
+ .catch(() => {
+ Flash(__('Something went wrong while fetching description changes. Please try again.'));
+ });
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js
index bee6d4f0329..3cdcc7a05b8 100644
--- a/app/assets/javascripts/notes/stores/collapse_utils.js
+++ b/app/assets/javascripts/notes/stores/collapse_utils.js
@@ -1,34 +1,9 @@
-import { n__, s__, sprintf } from '~/locale';
import { DESCRIPTION_TYPE } from '../constants';
/**
- * Changes the description from a note, returns 'changed the description n number of times'
- */
-export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => {
- const descriptionNote = Object.assign({}, note);
-
- descriptionNote.note_html = sprintf(
- s__(`MergeRequest|
- %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`),
- {
- paragraphStart: '<p dir="auto">',
- paragraphEnd: '</p>',
- descriptionChangedTimes,
- timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes),
- },
- false,
- );
-
- descriptionNote.times_updated = descriptionChangedTimes;
-
- return descriptionNote;
-};
-
-/**
* Checks the time difference between two notes from their 'created_at' dates
* returns an integer
*/
-
export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => {
const descriptionNoteBegin = new Date(noteBeggining.created_at);
const descriptionNoteEnd = new Date(noteEnd.created_at);
@@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC
export const collapseSystemNotes = notes => {
let lastDescriptionSystemNote = null;
let lastDescriptionSystemNoteIndex = -1;
- let descriptionChangedTimes = 1;
return notes.slice(0).reduce((acc, currentNote) => {
const note = currentNote.notes[0];
@@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => {
} else if (lastDescriptionSystemNote) {
const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note);
- // are they less than 10 minutes apart?
- if (timeDifferenceMinutes > 10) {
- // reset counter
- descriptionChangedTimes = 1;
+ // are they less than 10 minutes apart from the same user?
+ if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) {
// update the previous system note
lastDescriptionSystemNote = note;
lastDescriptionSystemNoteIndex = acc.length;
} else {
- // increase counter
- descriptionChangedTimes += 1;
+ // set the first version to fetch grouped system note versions
+ note.start_description_version_id = lastDescriptionSystemNote.description_version_id;
// delete the previous one
acc.splice(lastDescriptionSystemNoteIndex, 1);
- // replace the text of the current system note with the collapsed note.
- currentNote.notes.splice(
- 0,
- 1,
- changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes),
- );
-
// update the previous system note index
lastDescriptionSystemNoteIndex = acc.length;
}
}
}
+
acc.push(currentNote);
return acc;
}, []);
diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js
index d76b1f174fc..d97e24d9e0b 100644
--- a/app/assets/javascripts/pages/admin/abuse_reports/index.js
+++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js
@@ -1,3 +1,8 @@
+/* eslint-disable no-new */
import AbuseReports from './abuse_reports';
+import UsersSelect from '~/users_select';
-document.addEventListener('DOMContentLoaded', () => new AbuseReports());
+document.addEventListener('DOMContentLoaded', () => {
+ new AbuseReports();
+ new UsersSelect();
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js
index 43992938d07..4d04c37caa7 100644
--- a/app/assets/javascripts/pages/admin/clusters/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/index.js
@@ -1,21 +1,5 @@
-import PersistentUserCallout from '~/persistent_user_callout';
-import initGkeDropdowns from '~/create_cluster/gke_cluster';
-
-function initGcpSignupCallout() {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
-}
+import initCreateCluster from '~/create_cluster/init_create_cluster';
document.addEventListener('DOMContentLoaded', () => {
- const { page } = document.body.dataset;
- const newClusterViews = [
- 'admin:clusters:new',
- 'admin:clusters:create_gcp',
- 'admin:clusters:create_user',
- ];
-
- if (newClusterViews.indexOf(page) > -1) {
- initGcpSignupCallout();
- initGkeDropdowns();
- }
+ initCreateCluster(document, gon);
});
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js
index a33d242908b..4d04c37caa7 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/index.js
@@ -1,21 +1,5 @@
-import PersistentUserCallout from '~/persistent_user_callout';
-import initGkeDropdowns from '~/create_cluster/gke_cluster';
-
-function initGcpSignupCallout() {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
-}
+import initCreateCluster from '~/create_cluster/init_create_cluster';
document.addEventListener('DOMContentLoaded', () => {
- const { page } = document.body.dataset;
- const newClusterViews = [
- 'groups:clusters:new',
- 'groups:clusters:create_gcp',
- 'groups:clusters:create_user',
- ];
-
- if (newClusterViews.indexOf(page) > -1) {
- initGcpSignupCallout();
- initGkeDropdowns();
- }
+ initCreateCluster(document, gon);
});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index dcdee77a8ab..090e1a2bc6d 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,3 +1,4 @@
+import initIssuablesList from '~/issuables_list';
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
@@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
+ initIssuablesList();
+
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
new file mode 100644
index 00000000000..1d68ccd724d
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+const rootUrl = gon.relative_url_root;
+
+export default function fetchGroupPathAvailability(groupPath) {
+ return axios.get(`${rootUrl}/users/${groupPath}/suggests`);
+}
diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js
new file mode 100644
index 00000000000..2021ad117e8
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js
@@ -0,0 +1,91 @@
+import InputValidator from '~/validators/input_validator';
+
+import _ from 'underscore';
+import fetchGroupPathAvailability from './fetch_group_path_availability';
+import flash from '~/flash';
+import { __ } from '~/locale';
+
+const debounceTimeoutDuration = 1000;
+const invalidInputClass = 'gl-field-error-outline';
+const successInputClass = 'gl-field-success-outline';
+const successMessageSelector = '.validation-success';
+const pendingMessageSelector = '.validation-pending';
+const unavailableMessageSelector = '.validation-error';
+const suggestionsMessageSelector = '.gl-path-suggestions';
+
+export default class GroupPathValidator extends InputValidator {
+ constructor(opts = {}) {
+ super();
+
+ const container = opts.container || '';
+ const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`);
+
+ this.debounceValidateInput = _.debounce(inputDomElement => {
+ GroupPathValidator.validateGroupPathInput(inputDomElement);
+ }, debounceTimeoutDuration);
+
+ validateElements.forEach(element =>
+ element.addEventListener('input', this.eventHandler.bind(this)),
+ );
+ }
+
+ eventHandler(event) {
+ const inputDomElement = event.target;
+
+ GroupPathValidator.resetInputState(inputDomElement);
+ this.debounceValidateInput(inputDomElement);
+ }
+
+ static validateGroupPathInput(inputDomElement) {
+ const groupPath = inputDomElement.value;
+
+ if (inputDomElement.checkValidity() && groupPath.length > 0) {
+ GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
+
+ fetchGroupPathAvailability(groupPath)
+ .then(({ data }) => data)
+ .then(data => {
+ GroupPathValidator.setInputState(inputDomElement, !data.exists);
+ GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector, false);
+ GroupPathValidator.setMessageVisibility(
+ inputDomElement,
+ data.exists ? unavailableMessageSelector : successMessageSelector,
+ );
+
+ if (data.exists) {
+ GroupPathValidator.showSuggestions(inputDomElement, data.suggests);
+ }
+ })
+ .catch(() => flash(__('An error occurred while validating group path')));
+ }
+ }
+
+ static showSuggestions(inputDomElement, suggestions) {
+ const messageElement = inputDomElement.parentElement.parentElement.querySelector(
+ suggestionsMessageSelector,
+ );
+ const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none';
+ messageElement.textContent = textSuggestions;
+ }
+
+ static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) {
+ const messageElement = inputDomElement.parentElement.parentElement.querySelector(
+ messageSelector,
+ );
+ messageElement.classList.toggle('hide', !isVisible);
+ }
+
+ static setInputState(inputDomElement, success = true) {
+ inputDomElement.classList.toggle(successInputClass, success);
+ inputDomElement.classList.toggle(invalidInputClass, !success);
+ }
+
+ static resetInputState(inputDomElement) {
+ GroupPathValidator.setMessageVisibility(inputDomElement, successMessageSelector, false);
+ GroupPathValidator.setMessageVisibility(inputDomElement, unavailableMessageSelector, false);
+
+ if (inputDomElement.checkValidity()) {
+ inputDomElement.classList.remove(successInputClass, invalidInputClass);
+ }
+ }
+}
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index 57b53eb9e5d..0710fefe70c 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -1,8 +1,14 @@
+import $ from 'jquery';
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
import initAvatarPicker from '~/avatar_picker';
+import GroupPathValidator from './group_path_validator';
document.addEventListener('DOMContentLoaded', () => {
+ const parentId = $('#group_parent_id');
+ if (!parentId.val()) {
+ new GroupPathValidator(); // eslint-disable-line no-new
+ }
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
initAvatarPicker();
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 84e5bb3c46e..aee67899ca2 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -3,6 +3,7 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta
import BlobViewer from '~/blob/viewer/index';
import initBlob from '~/pages/projects/init_blob';
import GpgBadges from '~/gpg_badges';
+import '~/sourcegraph/load';
document.addEventListener('DOMContentLoaded', () => {
new BlobViewer(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js
deleted file mode 100644
index 14d5ab21555..00000000000
--- a/app/assets/javascripts/pages/projects/clusters/new/index.js
+++ /dev/null
@@ -1,13 +0,0 @@
-document.addEventListener('DOMContentLoaded', () => {
- if (gon.features.createEksClusters) {
- import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster')
- .then(({ default: initCreateEKSCluster }) => {
- const el = document.querySelector('.js-create-eks-cluster-form-container');
-
- if (el) {
- initCreateEKSCluster(el);
- }
- })
- .catch(() => {});
- }
-});
diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js
index f091c01fc98..397f9faf6fe 100644
--- a/app/assets/javascripts/pages/projects/clusters/show/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/show/index.js
@@ -1,5 +1,5 @@
import ClustersBundle from '~/clusters/clusters_bundle';
-import initGkeNamespace from '~/projects/gke_cluster_namespace';
+import initGkeNamespace from '~/create_cluster/gke_cluster_namespace';
document.addEventListener('DOMContentLoaded', () => {
new ClustersBundle(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js
index 5aa4734244e..0eb6f231839 100644
--- a/app/assets/javascripts/pages/projects/commit/show/index.js
+++ b/app/assets/javascripts/pages/projects/commit/show/index.js
@@ -9,6 +9,7 @@ import initNotes from '~/init_notes';
import initChangesDropdown from '~/init_changes_dropdown';
import initDiffNotes from '~/diff_notes/diff_notes_bundle';
import { fetchCommitMergeRequests } from '~/commit_merge_requests';
+import '~/sourcegraph/load';
document.addEventListener('DOMContentLoaded', () => {
const hasPerfBar = document.querySelector('.with-performance-bar');
diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js
new file mode 100644
index 00000000000..25d1c744e1b
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js
@@ -0,0 +1,5 @@
+import ErrorTrackingDetails from '~/error_tracking/details';
+
+document.addEventListener('DOMContentLoaded', () => {
+ ErrorTrackingDetails();
+});
diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js
deleted file mode 100644
index 5a8fe137e9a..00000000000
--- a/app/assets/javascripts/pages/projects/error_tracking/index.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import ErrorTracking from '~/error_tracking';
-
-document.addEventListener('DOMContentLoaded', () => {
- ErrorTracking();
-});
diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js
new file mode 100644
index 00000000000..ead81cd5d2d
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js
@@ -0,0 +1,5 @@
+import ErrorTrackingList from '~/error_tracking/list';
+
+document.addEventListener('DOMContentLoaded', () => {
+ ErrorTrackingList();
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js
index f79c386b59e..09d9c78c446 100644
--- a/app/assets/javascripts/pages/projects/graphs/show/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/show/index.js
@@ -1,25 +1,3 @@
-import $ from 'jquery';
-import flash from '~/flash';
-import { __ } from '~/locale';
-import axios from '~/lib/utils/axios_utils';
-import ContributorsStatGraph from './stat_graph_contributors';
+import initContributorsGraphs from '~/contributors';
-document.addEventListener('DOMContentLoaded', () => {
- const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath;
-
- axios
- .get(url)
- .then(({ data }) => {
- const graph = new ContributorsStatGraph();
- graph.init(data);
-
- $('#brush_change').change(() => {
- graph.change_date_header();
- graph.redraw_authors();
- });
-
- $('.stat-graph').fadeIn();
- $('.loading-graph').hide();
- })
- .catch(() => flash(__('Error fetching contributors data.')));
-});
+document.addEventListener('DOMContentLoaded', initContributorsGraphs);
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
deleted file mode 100644
index 5b873e6b909..00000000000
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */
-
-import $ from 'jquery';
-import _ from 'underscore';
-import { n__, s__, createDateTimeFormat, sprintf } from '~/locale';
-import {
- ContributorsGraph,
- ContributorsAuthorGraph,
- ContributorsMasterGraph,
-} from './stat_graph_contributors_graph';
-import ContributorsStatGraphUtil from './stat_graph_contributors_util';
-
-export default (function() {
- function ContributorsStatGraph() {
- this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' });
- }
-
- ContributorsStatGraph.prototype.init = function(log) {
- var author_commits, total_commits;
- this.parsed_log = ContributorsStatGraphUtil.parse_log(log);
- this.set_current_field('commits');
- total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field);
- this.add_master_graph(total_commits);
- this.add_authors_graph(author_commits);
- return this.change_date_header();
- };
-
- ContributorsStatGraph.prototype.add_master_graph = function(total_data) {
- this.master_graph = new ContributorsMasterGraph(total_data);
- return this.master_graph.draw();
- };
-
- ContributorsStatGraph.prototype.add_authors_graph = function(author_data) {
- var limited_author_data;
- this.authors = [];
- limited_author_data = author_data.slice(0, 100);
- return _.each(
- limited_author_data,
- (function(_this) {
- return function(d) {
- var author_graph, author_header;
- author_header = _this.create_author_header(d);
- $('.contributors-list').append(author_header);
-
- author_graph = new ContributorsAuthorGraph(d.dates);
- _this.authors[d.author_name] = author_graph;
- return author_graph.draw();
- };
- })(this),
- );
- };
-
- ContributorsStatGraph.prototype.format_author_commit_info = function(author) {
- var commits;
- commits = $('<span/>', {
- class: 'graph-author-commits-count',
- });
- commits.text(n__('%d commit', '%d commits', author.commits));
- return $('<span/>').append(commits);
- };
-
- ContributorsStatGraph.prototype.create_author_header = function(author) {
- var author_commit_info, author_commit_info_span, author_email, author_name, list_item;
- list_item = $('<li/>', {
- class: 'person',
- style: 'display: block;',
- });
- author_name = $(`<h4>${author.author_name}</h4>`);
- author_email = $(`<p class="graph-author-email">${author.author_email}</p>`);
- author_commit_info_span = $('<span/>', {
- class: 'commits',
- });
- author_commit_info = this.format_author_commit_info(author);
- author_commit_info_span.html(author_commit_info);
- list_item.append(author_name);
- list_item.append(author_email);
- list_item.append(author_commit_info_span);
- return list_item;
- };
-
- ContributorsStatGraph.prototype.redraw_master = function() {
- var total_data;
- total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field);
- this.master_graph.set_data(total_data);
- return this.master_graph.redraw();
- };
-
- ContributorsStatGraph.prototype.redraw_authors = function() {
- $('ol').html('');
-
- const { x_domain } = ContributorsGraph.prototype;
- const author_commits = ContributorsStatGraphUtil.get_author_data(
- this.parsed_log,
- this.field,
- x_domain,
- );
-
- return _.each(
- author_commits,
- (function(_this) {
- return function(d) {
- _this.redraw_author_commit_info(d);
- if (_this.authors[d.author_name] != null) {
- $(_this.authors[d.author_name].list_item).appendTo('ol');
- _this.authors[d.author_name].set_data(d.dates);
- return _this.authors[d.author_name].redraw();
- }
- return '';
- };
- })(this),
- );
- };
-
- ContributorsStatGraph.prototype.set_current_field = function(field) {
- return (this.field = field);
- };
-
- ContributorsStatGraph.prototype.change_date_header = function() {
- const { x_domain } = ContributorsGraph.prototype;
- const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), {
- startDate: this.dateFormat.format(new Date(x_domain[0])),
- endDate: this.dateFormat.format(new Date(x_domain[1])),
- });
- return $('#date_header').text(formattedDateRange);
- };
-
- ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) {
- var author_commit_info, author_list_item, $author;
- $author = this.authors[author.author_name];
- if ($author != null) {
- author_list_item = $(this.authors[author.author_name].list_item);
- author_commit_info = this.format_author_commit_info(author);
- return author_list_item.find('span').html(author_commit_info);
- }
- return '';
- };
-
- return ContributorsStatGraph;
-})();
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
deleted file mode 100644
index 86794800f87..00000000000
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js
+++ /dev/null
@@ -1,379 +0,0 @@
-/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */
-
-import $ from 'jquery';
-import _ from 'underscore';
-import { extent, max } from 'd3-array';
-import { select, event as d3Event } from 'd3-selection';
-import { scaleTime, scaleLinear } from 'd3-scale';
-import { axisLeft, axisBottom } from 'd3-axis';
-import { area } from 'd3-shape';
-import { brushX } from 'd3-brush';
-import { timeParse } from 'd3-time-format';
-import { dateTickFormat } from '~/lib/utils/tick_formats';
-
-const d3 = {
- extent,
- max,
- select,
- scaleTime,
- scaleLinear,
- axisLeft,
- axisBottom,
- area,
- brushX,
- timeParse,
-};
-
-const hasProp = {}.hasOwnProperty;
-const extend = function(child, parent) {
- for (const key in parent) {
- if (hasProp.call(parent, key)) child[key] = parent[key];
- }
- function ctor() {
- this.constructor = child;
- }
- ctor.prototype = parent.prototype;
- child.prototype = new ctor();
- child.__super__ = parent.prototype;
- return child;
-};
-
-export const ContributorsGraph = (function() {
- function ContributorsGraph() {}
-
- ContributorsGraph.prototype.MARGIN = {
- top: 20,
- right: 10,
- bottom: 30,
- left: 40,
- };
-
- ContributorsGraph.prototype.x_domain = null;
-
- ContributorsGraph.prototype.y_domain = null;
-
- ContributorsGraph.prototype.dates = [];
-
- ContributorsGraph.prototype.determine_width = function(baseWidth, $parentElement) {
- const parentPaddingWidth =
- parseFloat($parentElement.css('padding-left')) +
- parseFloat($parentElement.css('padding-right'));
- const marginWidth = this.MARGIN.left + this.MARGIN.right;
- return baseWidth - parentPaddingWidth - marginWidth;
- };
-
- ContributorsGraph.set_x_domain = function(data) {
- return (ContributorsGraph.prototype.x_domain = data);
- };
-
- ContributorsGraph.set_y_domain = function(data) {
- return (ContributorsGraph.prototype.y_domain = [
- 0,
- d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
- ]);
- };
-
- ContributorsGraph.init_x_domain = function(data) {
- return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date));
- };
-
- ContributorsGraph.init_y_domain = function(data) {
- return (ContributorsGraph.prototype.y_domain = [
- 0,
- d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)),
- ]);
- };
-
- ContributorsGraph.init_domain = function(data) {
- ContributorsGraph.init_x_domain(data);
- return ContributorsGraph.init_y_domain(data);
- };
-
- ContributorsGraph.set_dates = function(data) {
- return (ContributorsGraph.prototype.dates = data);
- };
-
- ContributorsGraph.prototype.set_x_domain = function() {
- return this.x.domain(this.x_domain);
- };
-
- ContributorsGraph.prototype.set_y_domain = function() {
- return this.y.domain(this.y_domain);
- };
-
- ContributorsGraph.prototype.set_domain = function() {
- this.set_x_domain();
- return this.set_y_domain();
- };
-
- ContributorsGraph.prototype.create_scale = function(width, height) {
- this.x = d3
- .scaleTime()
- .range([0, width])
- .clamp(true);
- return (this.y = d3
- .scaleLinear()
- .range([height, 0])
- .nice());
- };
-
- ContributorsGraph.prototype.draw_x_axis = function() {
- return this.svg
- .append('g')
- .attr('class', 'x axis')
- .attr('transform', `translate(0, ${this.height})`)
- .call(this.x_axis);
- };
-
- ContributorsGraph.prototype.draw_y_axis = function() {
- return this.svg
- .append('g')
- .attr('class', 'y axis')
- .call(this.y_axis);
- };
-
- ContributorsGraph.prototype.set_data = function(data) {
- return (this.data = data);
- };
-
- return ContributorsGraph;
-})();
-
-export const ContributorsMasterGraph = (function(superClass) {
- extend(ContributorsMasterGraph, superClass);
-
- function ContributorsMasterGraph(data1) {
- const $parentElement = $('#contributors-master');
-
- this.data = data1;
- this.update_content = this.update_content.bind(this);
- this.width = this.determine_width($('.js-graphs-show').width(), $parentElement);
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.brush = null;
- this.x_max_domain = null;
- }
-
- ContributorsMasterGraph.prototype.process_dates = function(data) {
- const dates = this.get_dates(data);
- this.parse_dates(data);
- return ContributorsGraph.set_dates(dates);
- };
-
- ContributorsMasterGraph.prototype.get_dates = function(data) {
- return _.pluck(data, 'date');
- };
-
- ContributorsMasterGraph.prototype.parse_dates = function(data) {
- const parseDate = d3.timeParse('%Y-%m-%d');
- return data.forEach(d => (d.date = parseDate(d.date)));
- };
-
- ContributorsMasterGraph.prototype.create_scale = function() {
- return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsMasterGraph.prototype.create_axes = function() {
- this.x_axis = d3
- .axisBottom()
- .scale(this.x)
- .tickFormat(dateTickFormat);
- return (this.y_axis = d3
- .axisLeft()
- .scale(this.y)
- .ticks(5));
- };
-
- ContributorsMasterGraph.prototype.create_svg = function() {
- this.svg = d3
- .select('#contributors-master')
- .append('svg')
- .attr('width', this.width + this.MARGIN.left + this.MARGIN.right)
- .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
- .attr('class', 'tint-box')
- .append('g')
- .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
- return this.svg;
- };
-
- ContributorsMasterGraph.prototype.create_area = function(x, y) {
- return (this.area = d3
- .area()
- .x(d => x(d.date))
- .y0(this.height)
- .y1(d => {
- d.commits = d.commits || d.additions || d.deletions;
- return y(d.commits);
- }));
- };
-
- ContributorsMasterGraph.prototype.create_brush = function() {
- return (this.brush = d3
- .brushX(this.x)
- .extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]])
- .on('end', this.update_content));
- };
-
- ContributorsMasterGraph.prototype.draw_path = function(data) {
- return this.svg
- .append('path')
- .datum(data)
- .attr('class', 'area')
- .attr('d', this.area);
- };
-
- ContributorsMasterGraph.prototype.add_brush = function() {
- return this.svg
- .append('g')
- .attr('class', 'selection')
- .call(this.brush)
- .selectAll('rect')
- .attr('height', this.height);
- };
-
- ContributorsMasterGraph.prototype.update_content = function() {
- // d3Event.selection replaces the function brush.empty() calls
- if (d3Event.selection != null) {
- ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert));
- } else {
- ContributorsGraph.set_x_domain(this.x_max_domain);
- }
- return $('#brush_change').trigger('change');
- };
-
- ContributorsMasterGraph.prototype.draw = function() {
- this.process_dates(this.data);
- this.create_scale();
- this.create_axes();
- ContributorsGraph.init_domain(this.data);
- this.x_max_domain = this.x_domain;
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.create_brush();
- this.draw_path(this.data);
- this.draw_x_axis();
- this.draw_y_axis();
- return this.add_brush();
- };
-
- ContributorsMasterGraph.prototype.redraw = function() {
- this.process_dates(this.data);
- ContributorsGraph.set_y_domain(this.data);
- this.set_y_domain();
- this.svg.select('path').datum(this.data);
- this.svg.select('path').attr('d', this.area);
- return this.svg.select('.y.axis').call(this.y_axis);
- };
-
- return ContributorsMasterGraph;
-})(ContributorsGraph);
-
-export const ContributorsAuthorGraph = (function(superClass) {
- extend(ContributorsAuthorGraph, superClass);
-
- function ContributorsAuthorGraph(data1) {
- const $parentElements = $('.person');
-
- this.data = data1;
- // Don't split graph size in half for mobile devices.
- if ($(window).width() < 790) {
- this.width = this.determine_width($('.js-graphs-show').width(), $parentElements);
- } else {
- this.width = this.determine_width($('.js-graphs-show').width() / 2, $parentElements);
- }
- this.height = 200;
- this.x = null;
- this.y = null;
- this.x_axis = null;
- this.y_axis = null;
- this.area = null;
- this.svg = null;
- this.list_item = null;
- }
-
- ContributorsAuthorGraph.prototype.create_scale = function() {
- return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height);
- };
-
- ContributorsAuthorGraph.prototype.create_axes = function() {
- this.x_axis = d3
- .axisBottom()
- .scale(this.x)
- .ticks(8)
- .tickFormat(dateTickFormat);
- return (this.y_axis = d3
- .axisLeft()
- .scale(this.y)
- .ticks(5));
- };
-
- ContributorsAuthorGraph.prototype.create_area = function(x, y) {
- return (this.area = d3
- .area()
- .x(d => {
- const parseDate = d3.timeParse('%Y-%m-%d');
- return x(parseDate(d));
- })
- .y0(this.height)
- .y1(
- (function(_this) {
- return function(d) {
- if (_this.data[d] != null) {
- return y(_this.data[d]);
- } else {
- return y(0);
- }
- };
- })(this),
- ));
- };
-
- ContributorsAuthorGraph.prototype.create_svg = function() {
- const persons = document.querySelectorAll('.person');
- this.list_item = persons[persons.length - 1];
- this.svg = d3
- .select(this.list_item)
- .append('svg')
- .attr('width', this.width + this.MARGIN.left + this.MARGIN.right)
- .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom)
- .attr('class', 'spark')
- .append('g')
- .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`);
- return this.svg;
- };
-
- ContributorsAuthorGraph.prototype.draw_path = function(data) {
- return this.svg
- .append('path')
- .datum(data)
- .attr('class', 'area-contributor')
- .attr('d', this.area);
- };
-
- ContributorsAuthorGraph.prototype.draw = function() {
- this.create_scale();
- this.create_axes();
- this.set_domain();
- this.create_area(this.x, this.y);
- this.create_svg();
- this.draw_path(this.dates);
- this.draw_x_axis();
- return this.draw_y_axis();
- };
-
- ContributorsAuthorGraph.prototype.redraw = function() {
- this.set_domain();
- this.svg.select('path').datum(this.dates);
- this.svg.select('path').attr('d', this.area);
- this.svg.select('.x.axis').call(this.x_axis);
- return this.svg.select('.y.axis').call(this.y_axis);
- };
-
- return ContributorsAuthorGraph;
-})(ContributorsGraph);
diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
deleted file mode 100644
index a89a13fe37a..00000000000
--- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js
+++ /dev/null
@@ -1,143 +0,0 @@
-/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */
-import _ from 'underscore';
-
-export default {
- parse_log(log) {
- var by_author, by_email, data, entry, i, len, total, normalized_email;
- total = {};
- by_author = {};
- by_email = {};
- for (i = 0, len = log.length; i < len; i += 1) {
- entry = log[i];
- if (total[entry.date] == null) {
- this.add_date(entry.date, total);
- }
- normalized_email = entry.author_email.toLowerCase();
- data = by_author[entry.author_name] || by_email[normalized_email];
- if (data == null) {
- data = this.add_author(entry, by_author, by_email);
- }
- if (!data[entry.date]) {
- this.add_date(entry.date, data);
- }
- this.store_data(entry, total[entry.date], data[entry.date]);
- }
- total = _.toArray(total);
- by_author = _.toArray(by_author);
- return {
- total,
- by_author,
- };
- },
- add_date(date, collection) {
- collection[date] = {};
- return (collection[date].date = date);
- },
- add_author(author, by_author, by_email) {
- var data, normalized_email;
- data = {};
- data.author_name = author.author_name;
- data.author_email = author.author_email;
- normalized_email = author.author_email.toLowerCase();
- by_author[author.author_name] = data;
- by_email[normalized_email] = data;
- return data;
- },
- store_data(entry, total, by_author) {
- this.store_commits(total, by_author);
- this.store_additions(entry, total, by_author);
- return this.store_deletions(entry, total, by_author);
- },
- store_commits(total, by_author) {
- this.add(total, 'commits', 1);
- return this.add(by_author, 'commits', 1);
- },
- add(collection, field, value) {
- if (collection[field] == null) {
- collection[field] = 0;
- }
- return (collection[field] += value);
- },
- store_additions(entry, total, by_author) {
- if (entry.additions == null) {
- entry.additions = 0;
- }
- this.add(total, 'additions', entry.additions);
- return this.add(by_author, 'additions', entry.additions);
- },
- store_deletions(entry, total, by_author) {
- if (entry.deletions == null) {
- entry.deletions = 0;
- }
- this.add(total, 'deletions', entry.deletions);
- return this.add(by_author, 'deletions', entry.deletions);
- },
- get_total_data(parsed_log, field) {
- var log, total_data;
- log = parsed_log.total;
- total_data = this.pick_field(log, field);
- return _.sortBy(total_data, d => d.date);
- },
- pick_field(log, field) {
- var total_data;
- total_data = [];
- _.each(log, d => total_data.push(_.pick(d, [field, 'date'])));
- return total_data;
- },
- get_author_data(parsed_log, field, date_range) {
- var author_data, log;
- if (date_range == null) {
- date_range = null;
- }
- log = parsed_log.by_author;
- author_data = [];
- _.each(
- log,
- (function(_this) {
- return function(log_entry) {
- var parsed_log_entry;
- parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range);
- if (!_.isEmpty(parsed_log_entry.dates)) {
- return author_data.push(parsed_log_entry);
- }
- };
- })(this),
- );
- return _.sortBy(author_data, d => d[field]).reverse();
- },
- parse_log_entry(log_entry, field, date_range) {
- var parsed_entry;
- parsed_entry = {};
-
- parsed_entry.author_name = log_entry.author_name;
- parsed_entry.author_email = log_entry.author_email;
- parsed_entry.dates = {};
-
- parsed_entry.commits = 0;
- parsed_entry.additions = 0;
- parsed_entry.deletions = 0;
-
- _.each(
- _.omit(log_entry, 'author_name', 'author_email'),
- (function(_this) {
- return function(value) {
- if (_this.in_range(value.date, date_range)) {
- parsed_entry.dates[value.date] = value[field];
- parsed_entry.commits += value.commits;
- parsed_entry.additions += value.additions;
- return (parsed_entry.deletions += value.deletions);
- }
- };
- })(this),
- );
- return parsed_entry;
- },
- in_range(date, date_range) {
- var ref;
- if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) {
- return true;
- } else {
- return false;
- }
- },
-};
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 196798a9076..190d0806c28 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,24 +1,9 @@
-import initGkeDropdowns from '~/create_cluster/gke_cluster';
-import initGkeNamespace from '~/projects/gke_cluster_namespace';
-import PersistentUserCallout from '../../persistent_user_callout';
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
+import initCreateCluster from '~/create_cluster/init_create_cluster';
document.addEventListener('DOMContentLoaded', () => {
- const { page } = document.body.dataset;
- const newClusterViews = [
- 'projects:clusters:new',
- 'projects:clusters:create_gcp',
- 'projects:clusters:create_user',
- ];
-
- if (newClusterViews.indexOf(page) > -1) {
- const callout = document.querySelector('.gcp-signup-offer');
- PersistentUserCallout.factory(callout);
-
- initGkeDropdowns();
- initGkeNamespace();
- }
+ initCreateCluster(document, gon);
new Project(); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
index fa1de1f13cb..16034313af2 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js
@@ -5,6 +5,7 @@ import { handleLocationHash } from '~/lib/utils/common_utils';
import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
+import initSourcegraph from '~/sourcegraph';
import initWidget from '../../../vue_merge_request_widget';
export default function() {
@@ -19,4 +20,5 @@ export default function() {
handleLocationHash();
howToMerge();
initWidget();
+ initSourcegraph();
}
diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js
index 43417fa9702..5f2014f1631 100644
--- a/app/assets/javascripts/pages/projects/network/network.js
+++ b/app/assets/javascripts/pages/projects/network/network.js
@@ -1,22 +1,19 @@
-/* eslint-disable func-names, no-var */
-
import $ from 'jquery';
import BranchGraph from '../../../network/branch_graph';
-export default (function() {
- function Network(opts) {
- var vph;
- $('#filter_ref').click(function() {
- return $(this)
- .closest('form')
- .submit();
- });
- this.branch_graph = new BranchGraph($('.network-graph'), opts);
- vph = $(window).height() - 250;
- $('.network-graph').css({
- height: `${vph}px`,
- });
+const vph = $(window).height() - 250;
+
+export default class Network {
+ constructor(opts) {
+ this.opts = opts;
+ this.filter_ref = $('#filter_ref');
+ this.network_graph = $('.network-graph');
+ this.filter_ref.click(() => this.submit());
+ this.branch_graph = new BranchGraph(this.network_graph, this.opts);
+ this.network_graph.css({ height: `${vph}px` });
}
- return Network;
-})();
+ submit() {
+ return this.filter_ref.closest('form').submit();
+ }
+}
diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js
index cef8e92610c..ae5368179b1 100644
--- a/app/assets/javascripts/pages/projects/pages_domains/form.js
+++ b/app/assets/javascripts/pages/projects/pages_domains/form.js
@@ -1,17 +1,23 @@
import setupToggleButtons from '~/toggle_buttons';
+function updateVisibility(selector, isVisible) {
+ Array.from(document.querySelectorAll(selector)).forEach(el => {
+ if (isVisible) {
+ el.classList.remove('d-none');
+ } else {
+ el.classList.add('d-none');
+ }
+ });
+}
+
export default () => {
const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container');
if (toggleContainer) {
const onToggleButtonClicked = isAutoSslEnabled => {
- Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => {
- if (isAutoSslEnabled) {
- el.classList.add('d-none');
- } else {
- el.classList.remove('d-none');
- }
- });
+ updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled);
+
+ updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled);
Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => {
if (isAutoSslEnabled) {
diff --git a/app/assets/javascripts/pages/projects/pipelines/test_report/index.js b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js
new file mode 100644
index 00000000000..7e69983c2ed
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js
@@ -0,0 +1,2 @@
+// /test_report is an alias for show
+import '../show/index';
diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js
index 435e8705803..01acfca158f 100644
--- a/app/assets/javascripts/pages/projects/project.js
+++ b/app/assets/javascripts/pages/projects/project.js
@@ -40,11 +40,6 @@ export default class Project {
$label.text(activeText);
});
- $('#modal-geo-info').data({
- cloneUrlSecondary: $this.attr('href'),
- cloneUrlPrimary: $this.data('primaryUrl') || '',
- });
-
if (mobileCloneField) {
mobileCloneField.dataset.clipboardText = url;
} else {
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 98e19705976..a32c188909c 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -1,9 +1,11 @@
import mountErrorTrackingForm from '~/error_tracking_settings';
import mountOperationSettings from '~/operation_settings';
+import mountGrafanaIntegration from '~/grafana_integration';
import initSettingsPanels from '~/settings_panels';
document.addEventListener('DOMContentLoaded', () => {
mountErrorTrackingForm();
mountOperationSettings();
+ mountGrafanaIntegration();
initSettingsPanels();
});
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 89cac42abae..4802cc2ad25 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,7 +1,6 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
-import { __ } from '~/locale';
+import { s__ } from '~/locale';
import projectFeatureSetting from './project_feature_setting.vue';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
@@ -13,7 +12,7 @@ import {
} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
-const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone');
+const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone');
export default {
components: {
@@ -207,7 +206,10 @@ export default {
<template>
<div>
<div class="project-visibility-setting">
- <project-setting-row :help-path="visibilityHelpPath" label="Project visibility">
+ <project-setting-row
+ :help-path="visibilityHelpPath"
+ :label="s__('ProjectSettings|Project visibility')"
+ >
<div class="project-feature-controls">
<div class="select-wrapper">
<select
@@ -220,17 +222,17 @@ export default {
<option
:value="visibilityOptions.PRIVATE"
:disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
- >{{ __('Private') }}</option
+ >{{ s__('ProjectSettings|Private') }}</option
>
<option
:value="visibilityOptions.INTERNAL"
:disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
- >{{ __('Internal') }}</option
+ >{{ s__('ProjectSettings|Internal') }}</option
>
<option
:value="visibilityOptions.PUBLIC"
:disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
- >{{ __('Public') }}</option
+ >{{ s__('ProjectSettings|Public') }}</option
>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
@@ -243,14 +245,15 @@ export default {
type="hidden"
name="project[request_access_enabled]"
/>
- <input v-model="requestAccessEnabled" type="checkbox" /> Allow users to request access
+ <input v-model="requestAccessEnabled" type="checkbox" />
+ {{ s__('ProjectSettings|Allow users to request access') }}
</label>
</project-setting-row>
</div>
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
<project-setting-row
- label="Issues"
- help-text="Lightweight issue tracking system for this project"
+ :label="s__('ProjectSettings|Issues')"
+ :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')"
>
<project-feature-setting
v-model="issuesAccessLevel"
@@ -258,7 +261,10 @@ export default {
name="project[project_feature_attributes][issues_access_level]"
/>
</project-setting-row>
- <project-setting-row label="Repository" help-text="View and edit files in this project">
+ <project-setting-row
+ :label="s__('ProjectSettings|Repository')"
+ :help-text="s__('ProjectSettings|View and edit files in this project')"
+ >
<project-feature-setting
v-model="repositoryAccessLevel"
:options="featureAccessLevelOptions"
@@ -267,8 +273,8 @@ export default {
</project-setting-row>
<div class="project-feature-setting-group">
<project-setting-row
- label="Merge requests"
- help-text="Submit changes to be merged upstream"
+ :label="s__('ProjectSettings|Merge requests')"
+ :help-text="s__('ProjectSettings|Submit changes to be merged upstream')"
>
<project-feature-setting
v-model="mergeRequestsAccessLevel"
@@ -277,7 +283,10 @@ export default {
name="project[project_feature_attributes][merge_requests_access_level]"
/>
</project-setting-row>
- <project-setting-row label="Pipelines" help-text="Build, test, and deploy your changes">
+ <project-setting-row
+ :label="s__('ProjectSettings|Pipelines')"
+ :help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
+ >
<project-feature-setting
v-model="buildsAccessLevel"
:options="repoFeatureAccessLevelOptions"
@@ -288,11 +297,17 @@ export default {
<project-setting-row
v-if="registryAvailable"
:help-path="registryHelpPath"
- label="Container registry"
- help-text="Every project can have its own space to store its Docker images"
+ :label="s__('ProjectSettings|Container registry')"
+ :help-text="
+ s__('ProjectSettings|Every project can have its own space to store its Docker images')
+ "
>
<div v-if="showContainerRegistryPublicNote" class="text-muted">
- {{ __('Note: the container registry is always visible when a project is public') }}
+ {{
+ s__(
+ 'ProjectSettings|Note: the container registry is always visible when a project is public',
+ )
+ }}
</div>
<project-feature-toggle
v-model="containerRegistryEnabled"
@@ -303,8 +318,10 @@ export default {
<project-setting-row
v-if="lfsAvailable"
:help-path="lfsHelpPath"
- label="Git Large File Storage"
- help-text="Manages large files such as audio, video, and graphics files"
+ :label="s__('ProjectSettings|Git Large File Storage')"
+ :help-text="
+ s__('ProjectSettings|Manages large files such as audio, video, and graphics files')
+ "
>
<project-feature-toggle
v-model="lfsEnabled"
@@ -315,8 +332,10 @@ export default {
<project-setting-row
v-if="packagesAvailable"
:help-path="packagesHelpPath"
- label="Packages"
- help-text="Every project can have its own space to store its packages"
+ :label="s__('ProjectSettings|Packages')"
+ :help-text="
+ s__('ProjectSettings|Every project can have its own space to store its packages')
+ "
>
<project-feature-toggle
v-model="packagesEnabled"
@@ -325,7 +344,10 @@ export default {
/>
</project-setting-row>
</div>
- <project-setting-row label="Wiki" help-text="Pages for project documentation">
+ <project-setting-row
+ :label="s__('ProjectSettings|Wiki')"
+ :help-text="s__('ProjectSettings|Pages for project documentation')"
+ >
<project-feature-setting
v-model="wikiAccessLevel"
:options="featureAccessLevelOptions"
@@ -333,8 +355,8 @@ export default {
/>
</project-setting-row>
<project-setting-row
- label="Snippets"
- help-text="Share code pastes with others out of Git repository"
+ :label="s__('ProjectSettings|Snippets')"
+ :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')"
>
<project-feature-setting
v-model="snippetsAccessLevel"
@@ -345,8 +367,10 @@ export default {
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
:help-path="pagesHelpPath"
- label="Pages access control"
- help-text="Access control for the project's static website"
+ :label="s__('ProjectSettings|Pages')"
+ :help-text="
+ s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab')
+ "
>
<project-feature-setting
v-model="pagesAccessLevel"
@@ -358,10 +382,13 @@ export default {
<project-setting-row v-if="canDisableEmails" class="mb-3">
<label class="js-emails-disabled">
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
- <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }}
+ <input v-model="emailsDisabled" type="checkbox" />
+ {{ s__('ProjectSettings|Disable email notifications') }}
</label>
<span class="form-text text-muted">{{
- __('This setting will override user notification preferences for all project members.')
+ s__(
+ 'ProjectSettings|This setting will override user notification preferences for all project members.',
+ )
}}</span>
</project-setting-row>
</div>
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 6aa41d0825b..370f3c6e7a2 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -48,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => {
leaveByUrl('project');
if (document.getElementById('js-tree-list')) {
- import('~/repository')
+ import('ee_else_ce/repository')
.then(m => m.default())
.catch(e => {
throw e;
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 7b90a3a4f6e..16d71379e31 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => {
GpgBadges.fetch();
if (document.getElementById('js-tree-list')) {
- import('~/repository')
+ import('ee_else_ce/repository')
.then(m => m.default())
.catch(e => {
throw e;
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index 55bc93a2b13..66ee2d9303f 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -5,6 +5,7 @@ import NoEmojiValidator from '../../../emoji/no_emoji_validator';
import SigninTabsMemoizer from './signin_tabs_memoizer';
import OAuthRememberMe from './oauth_remember_me';
import preserveUrlFragment from './preserve_url_fragment';
+import Tracking from '~/tracking';
document.addEventListener('DOMContentLoaded', () => {
new UsernameValidator(); // eslint-disable-line no-new
@@ -20,3 +21,16 @@ document.addEventListener('DOMContentLoaded', () => {
// redirected to sign-in after attempting to access a protected URL that included a fragment.
preserveUrlFragment(window.location.hash);
});
+
+export default function trackData() {
+ if (gon.tracking_data) {
+ const tab = document.querySelector(".new-session-tabs a[href='#register-pane']");
+ const { category, action, ...data } = gon.tracking_data;
+
+ tab.addEventListener('click', () => {
+ Tracking.event(category, action, data);
+ });
+ }
+}
+
+trackData();
diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue
new file mode 100644
index 00000000000..54bca8a1b67
--- /dev/null
+++ b/app/assets/javascripts/performance_bar/components/add_request.vue
@@ -0,0 +1,48 @@
+import { __ } from '~/locale';
+
+<script>
+export default {
+ data() {
+ return {
+ inputEnabled: false,
+ urlOrRequestId: '',
+ };
+ },
+ methods: {
+ toggleInput() {
+ this.inputEnabled = !this.inputEnabled;
+ },
+ addRequest() {
+ this.$emit('add-request', this.urlOrRequestId);
+ this.clearForm();
+ },
+ clearForm() {
+ this.urlOrRequestId = '';
+ this.toggleInput();
+ },
+ },
+};
+</script>
+<template>
+ <div id="peek-view-add-request" class="view">
+ <form class="form-inline" @submit.prevent>
+ <button
+ class="btn-blank btn-link bold"
+ type="button"
+ :title="__(`Add request manually`)"
+ @click="toggleInput"
+ >
+ +
+ </button>
+ <input
+ v-if="inputEnabled"
+ v-model="urlOrRequestId"
+ type="text"
+ :placeholder="__(`URL or request ID`)"
+ class="form-control form-control-sm d-inline-block ml-1"
+ @keyup.enter="addRequest"
+ @keyup.esc="clearForm"
+ />
+ </form>
+ </div>
+</template>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 3b07eba02b7..8ce653bf1fb 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -1,12 +1,14 @@
<script>
import { glEmojiTag } from '~/emoji';
+import AddRequest from './add_request.vue';
import DetailedMetric from './detailed_metric.vue';
import RequestSelector from './request_selector.vue';
import { s__ } from '~/locale';
export default {
components: {
+ AddRequest,
DetailedMetric,
RequestSelector,
},
@@ -118,6 +120,7 @@ export default {
>
<a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a>
</div>
+ <add-request v-on="$listeners" />
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js
index 1ae9487f391..735c9d804ee 100644
--- a/app/assets/javascripts/performance_bar/index.js
+++ b/app/assets/javascripts/performance_bar/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import axios from '~/lib/utils/axios_utils';
+
import PerformanceBarService from './services/performance_bar_service';
import PerformanceBarStore from './stores/performance_bar_store';
@@ -32,6 +34,15 @@ export default ({ container }) =>
PerformanceBarService.removeInterceptor(this.interceptor);
},
methods: {
+ addRequestManually(urlOrRequestId) {
+ if (urlOrRequestId.startsWith('https://') || urlOrRequestId.startsWith('http://')) {
+ // We don't need to do anything with the response, we just
+ // want to trace the request.
+ axios.get(urlOrRequestId);
+ } else {
+ this.loadRequestDetails(urlOrRequestId, urlOrRequestId);
+ }
+ },
loadRequestDetails(requestId, requestUrl) {
if (!this.store.canTrackRequest(requestUrl)) {
return;
@@ -58,6 +69,9 @@ export default ({ container }) =>
peekUrl: this.peekUrl,
profileUrl: this.profileUrl,
},
+ on: {
+ 'add-request': this.addRequestManually,
+ },
});
},
});
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 3c85bb61ce8..fd59a580961 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -88,7 +88,7 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
+ class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper d-flex align-items-center justify-content-center"
@click="onClickAction"
>
<gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index 5275de3bc8b..afb8439511f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -265,7 +265,11 @@ export default {
<div class="table-section section-10 commit-link">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div>
<div class="table-mobile-content">
- <ci-badge :status="pipelineStatus" :show-text="!isChildView" />
+ <ci-badge
+ :status="pipelineStatus"
+ :show-text="!isChildView"
+ data-qa-selector="pipeline_commit_status"
+ />
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
new file mode 100644
index 00000000000..388b300b39d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue
@@ -0,0 +1,81 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import TestSuiteTable from './test_suite_table.vue';
+import TestSummary from './test_summary.vue';
+import TestSummaryTable from './test_summary_table.vue';
+import store from '~/pipelines/stores/test_reports';
+
+export default {
+ name: 'TestReports',
+ components: {
+ GlLoadingIcon,
+ TestSuiteTable,
+ TestSummary,
+ TestSummaryTable,
+ },
+ store,
+ computed: {
+ ...mapState(['isLoading', 'selectedSuite', 'testReports']),
+ showSuite() {
+ return this.selectedSuite.total_count > 0;
+ },
+ showTests() {
+ return this.testReports.total_count > 0;
+ },
+ },
+ methods: {
+ ...mapActions(['setSelectedSuite', 'removeSelectedSuite']),
+ summaryBackClick() {
+ this.removeSelectedSuite();
+ },
+ summaryTableRowClick(suite) {
+ this.setSelectedSuite(suite);
+ },
+ beforeEnterTransition() {
+ document.documentElement.style.overflowX = 'hidden';
+ },
+ afterLeaveTransition() {
+ document.documentElement.style.overflowX = '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isLoading">
+ <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" />
+ </div>
+
+ <div
+ v-else-if="!isLoading && showTests"
+ ref="container"
+ class="tests-detail position-relative js-tests-detail"
+ >
+ <transition
+ name="slide"
+ @before-enter="beforeEnterTransition"
+ @after-leave="afterLeaveTransition"
+ >
+ <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element">
+ <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" />
+
+ <test-suite-table />
+ </div>
+
+ <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element">
+ <test-summary :report="testReports" />
+
+ <test-summary-table @row-click="summaryTableRowClick" />
+ </div>
+ </transition>
+ </div>
+
+ <div v-else>
+ <div class="row prepend-top-default">
+ <div class="col-12">
+ <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
new file mode 100644
index 00000000000..28b2c706320
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -0,0 +1,108 @@
+<script>
+import { mapGetters } from 'vuex';
+import Icon from '~/vue_shared/components/icon.vue';
+import store from '~/pipelines/stores/test_reports';
+import { __ } from '~/locale';
+
+export default {
+ name: 'TestsSuiteTable',
+ components: {
+ Icon,
+ },
+ store,
+ props: {
+ heading: {
+ type: String,
+ required: false,
+ default: __('Tests'),
+ },
+ },
+ computed: {
+ ...mapGetters(['getSuiteTests']),
+ hasSuites() {
+ return this.getSuiteTests.length > 0;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="row prepend-top-default">
+ <div class="col-12">
+ <h4>{{ heading }}</h4>
+ </div>
+ </div>
+
+ <div v-if="hasSuites" class="test-reports-table js-test-cases-table">
+ <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray">
+ <div role="rowheader" class="table-section section-20">
+ {{ __('Class') }}
+ </div>
+ <div role="rowheader" class="table-section section-20">
+ {{ __('Name') }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-center">
+ {{ __('Status') }}
+ </div>
+ <div role="rowheader" class="table-section flex-grow-1">
+ {{ __('Trace'), }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-right">
+ {{ __('Duration') }}
+ </div>
+ </div>
+
+ <div
+ v-for="(testCase, index) in getSuiteTests"
+ :key="index"
+ class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row"
+ >
+ <div class="table-section section-20 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div>
+ <div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div>
+ </div>
+
+ <div class="table-section section-20 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div>
+ <div class="table-mobile-content">{{ testCase.name }}</div>
+ </div>
+
+ <div class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div>
+ <div class="table-mobile-content text-center">
+ <div
+ class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
+ :class="`ci-status-icon-${testCase.status}`"
+ >
+ <icon :size="24" :name="testCase.icon" />
+ </div>
+ </div>
+ </div>
+
+ <div class="table-section flex-grow-1">
+ <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div>
+ <div class="table-mobile-content">
+ <pre
+ v-if="testCase.system_output"
+ class="build-trace build-trace-rounded text-left"
+ ><code class="bash p-0">{{testCase.system_output}}</code></pre>
+ </div>
+ </div>
+
+ <div class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">
+ {{ __('Duration') }}
+ </div>
+ <div class="table-mobile-content text-right">
+ {{ testCase.formattedTime }}
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div v-else>
+ <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
new file mode 100644
index 00000000000..dce8b020d6f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -0,0 +1,116 @@
+<script>
+import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'TestSummary',
+ components: {
+ GlButton,
+ GlLink,
+ GlProgressBar,
+ Icon,
+ },
+ props: {
+ report: {
+ type: Object,
+ required: true,
+ },
+ showBack: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ heading() {
+ return this.report.name || __('Summary');
+ },
+ successPercentage() {
+ return Math.round((this.report.success_count / this.report.total_count) * 100) || 0;
+ },
+ formattedDuration() {
+ return formatTime(secondsToMilliseconds(this.report.total_time));
+ },
+ progressBarVariant() {
+ if (this.successPercentage < 33) {
+ return 'danger';
+ }
+
+ if (this.successPercentage >= 33 && this.successPercentage < 66) {
+ return 'warning';
+ }
+
+ if (this.successPercentage >= 66 && this.successPercentage < 90) {
+ return 'primary';
+ }
+
+ return 'success';
+ },
+ },
+ methods: {
+ onBackClick() {
+ this.$emit('on-back-click');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="row">
+ <div class="col-12 d-flex prepend-top-8 align-items-center">
+ <gl-button
+ v-if="showBack"
+ size="sm"
+ class="append-right-default js-back-button"
+ @click="onBackClick"
+ >
+ <icon name="angle-left" />
+ </gl-button>
+
+ <h4>{{ heading }}</h4>
+ </div>
+ </div>
+
+ <div class="row mt-2">
+ <div class="col-4 col-md">
+ <span class="js-total-tests">{{
+ sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count })
+ }}</span>
+ </div>
+
+ <div class="col-4 col-md text-center text-md-center">
+ <span class="js-failed-tests">{{
+ sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count })
+ }}</span>
+ </div>
+
+ <div class="col-4 col-md text-right text-md-center">
+ <span class="js-errored-tests">{{
+ sprintf(s__('TestReports|%{count} errors'), { count: report.error_count })
+ }}</span>
+ </div>
+
+ <div class="col-6 mt-3 col-md mt-md-0 text-md-center">
+ <span class="js-success-rate">{{
+ sprintf(s__('TestReports|%{rate}%{sign} success rate'), {
+ rate: successPercentage,
+ sign: '%',
+ })
+ }}</span>
+ </div>
+
+ <div class="col-6 mt-3 col-md mt-md-0 text-right">
+ <span class="js-duration">{{ formattedDuration }}</span>
+ </div>
+ </div>
+
+ <div class="row mt-3">
+ <div class="col-12">
+ <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" />
+ </div>
+ </div>
+ </div>
+</template>
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
new file mode 100644
index 00000000000..96177512e35
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -0,0 +1,129 @@
+<script>
+import { mapGetters } from 'vuex';
+import { s__ } from '~/locale';
+import store from '~/pipelines/stores/test_reports';
+
+export default {
+ name: 'TestsSummaryTable',
+ store,
+ props: {
+ heading: {
+ type: String,
+ required: false,
+ default: s__('TestReports|Test suites'),
+ },
+ },
+ computed: {
+ ...mapGetters(['getTestSuites']),
+ hasSuites() {
+ return this.getTestSuites.length > 0;
+ },
+ },
+ methods: {
+ tableRowClick(suite) {
+ this.$emit('row-click', suite);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="row prepend-top-default">
+ <div class="col-12">
+ <h4>{{ heading }}</h4>
+ </div>
+ </div>
+
+ <div v-if="hasSuites" class="test-reports-table js-test-suites-table">
+ <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold">
+ <div role="rowheader" class="table-section section-25 pl-3">
+ {{ __('Suite') }}
+ </div>
+ <div role="rowheader" class="table-section section-25">
+ {{ __('Duration') }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-center">
+ {{ __('Failed') }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-center">
+ {{ __('Errors'), }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-center">
+ {{ __('Skipped'), }}
+ </div>
+ <div role="rowheader" class="table-section section-10 text-center">
+ {{ __('Passed'), }}
+ </div>
+ <div role="rowheader" class="table-section section-10 pr-3 text-right">
+ {{ __('Total') }}
+ </div>
+ </div>
+
+ <div
+ 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"
+ @click="tableRowClick(testSuite)"
+ >
+ <div class="table-section section-25">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Suite') }}
+ </div>
+ <div class="table-mobile-content underline cgray pl-3">
+ {{ testSuite.name }}
+ </div>
+ </div>
+
+ <div class="table-section section-25">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Duration') }}
+ </div>
+ <div class="table-mobile-content text-md-left">
+ {{ testSuite.formattedTime }}
+ </div>
+ </div>
+
+ <div class="table-section section-10 text-center">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Failed') }}
+ </div>
+ <div class="table-mobile-content">{{ testSuite.failed_count }}</div>
+ </div>
+
+ <div class="table-section section-10 text-center">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Errors') }}
+ </div>
+ <div class="table-mobile-content">{{ testSuite.error_count }}</div>
+ </div>
+
+ <div class="table-section section-10 text-center">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Skipped') }}
+ </div>
+ <div class="table-mobile-content">{{ testSuite.skipped_count }}</div>
+ </div>
+
+ <div class="table-section section-10 text-center">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Passed') }}
+ </div>
+ <div class="table-mobile-content">{{ testSuite.success_count }}</div>
+ </div>
+
+ <div class="table-section section-10 text-right pr-md-3">
+ <div role="rowheader" class="table-mobile-header font-weight-bold">
+ {{ __('Total') }}
+ </div>
+ <div class="table-mobile-content">{{ testSuite.total_count }}</div>
+ </div>
+ </div>
+ </div>
+
+ <div v-else>
+ <p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index d27829db50c..c9655d18a04 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -1,3 +1,9 @@
export const CANCEL_REQUEST = 'CANCEL_REQUEST';
export const PIPELINES_TABLE = 'PIPELINES_TABLE';
export const LAYOUT_CHANGE_DELAY = 300;
+
+export const TestStatus = {
+ FAILED: 'failed',
+ SKIPPED: 'skipped',
+ SUCCESS: 'success',
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index b6f8716d37d..d8dbc3c2454 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
+import TestReports from './components/test_reports/test_reports.vue';
+import testReportsStore from './stores/test_reports';
Vue.use(Translate);
@@ -17,7 +19,7 @@ export default () => {
mediator.fetchPipeline();
- // eslint-disable-next-line
+ // eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-graph-vue',
components: {
@@ -47,7 +49,7 @@ export default () => {
},
});
- // eslint-disable-next-line
+ // eslint-disable-next-line no-new
new Vue({
el: '#js-pipeline-header-vue',
components: {
@@ -81,4 +83,23 @@ export default () => {
});
},
});
+
+ const testReportsEnabled =
+ window.gon && window.gon.features && window.gon.features.junitPipelineView;
+
+ if (testReportsEnabled) {
+ testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint);
+ testReportsStore.dispatch('fetchReports');
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-pipeline-tests-detail',
+ components: {
+ TestReports,
+ },
+ render(createElement) {
+ return createElement('test-reports');
+ },
+ });
+ }
};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
new file mode 100644
index 00000000000..71d875c1a83
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js
@@ -0,0 +1,30 @@
+import axios from '~/lib/utils/axios_utils';
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+
+export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data);
+
+export const fetchReports = ({ state, commit, dispatch }) => {
+ dispatch('toggleLoading');
+
+ return axios
+ .get(state.endpoint)
+ .then(response => {
+ const { data } = response;
+ commit(types.SET_REPORTS, data);
+ })
+ .catch(() => {
+ createFlash(s__('TestReports|There was an error fetching the test reports.'));
+ })
+ .finally(() => {
+ dispatch('toggleLoading');
+ });
+};
+
+export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data);
+export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {});
+export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
new file mode 100644
index 00000000000..788c1d32987
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js
@@ -0,0 +1,23 @@
+import { addIconStatus, formattedTime, sortTestCases } from './utils';
+
+export const getTestSuites = state => {
+ const { test_suites: testSuites = [] } = state.testReports;
+
+ return testSuites.map(suite => ({
+ ...suite,
+ formattedTime: formattedTime(suite.total_time),
+ }));
+};
+
+export const getSuiteTests = state => {
+ const { selectedSuite } = state;
+
+ if (selectedSuite.test_cases) {
+ return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus);
+ }
+
+ return [];
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js
new file mode 100644
index 00000000000..318dff5bcb2
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js
@@ -0,0 +1,15 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state,
+});
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
new file mode 100644
index 00000000000..832e45cf7a1
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_ENDPOINT = 'SET_ENDPOINT';
+export const SET_REPORTS = 'SET_REPORTS';
+export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE';
+export const TOGGLE_LOADING = 'TOGGLE_LOADING';
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
new file mode 100644
index 00000000000..349e6ec0469
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENDPOINT](state, endpoint) {
+ Object.assign(state, { endpoint });
+ },
+
+ [types.SET_REPORTS](state, testReports) {
+ Object.assign(state, { testReports });
+ },
+
+ [types.SET_SELECTED_SUITE](state, selectedSuite) {
+ Object.assign(state, { selectedSuite });
+ },
+
+ [types.TOGGLE_LOADING](state) {
+ Object.assign(state, { isLoading: !state.isLoading });
+ },
+};
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js
new file mode 100644
index 00000000000..80a0c2a46a0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js
@@ -0,0 +1,6 @@
+export default () => ({
+ endpoint: '',
+ testReports: {},
+ selectedSuite: {},
+ isLoading: false,
+});
diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
new file mode 100644
index 00000000000..95466587d6b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js
@@ -0,0 +1,36 @@
+import { TestStatus } from '~/pipelines/constants';
+import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility';
+
+function iconForTestStatus(status) {
+ switch (status) {
+ case 'success':
+ return 'status_success_borderless';
+ case 'failed':
+ return 'status_failed_borderless';
+ default:
+ return 'status_skipped_borderless';
+ }
+}
+
+export const formattedTime = timeInSeconds => formatTime(secondsToMilliseconds(timeInSeconds));
+
+export const addIconStatus = testCase => ({
+ ...testCase,
+ icon: iconForTestStatus(testCase.status),
+ formattedTime: formattedTime(testCase.execution_time),
+});
+
+export const sortTestCases = (a, b) => {
+ if (a.status === b.status) {
+ return 0;
+ }
+
+ switch (b.status) {
+ case TestStatus.SUCCESS:
+ return -1;
+ case TestStatus.FAILED:
+ return 1;
+ default:
+ return 0;
+ }
+};
diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js
deleted file mode 100644
index 97f41deb30f..00000000000
--- a/app/assets/javascripts/privacy_policy_update_callout.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import PersistentUserCallout from '~/persistent_user_callout';
-
-function initPrivacyPolicyUpdateCallout() {
- const callout = document.querySelector('.js-privacy-policy-update');
- PersistentUserCallout.factory(callout);
-}
-
-export default initPrivacyPolicyUpdateCallout;
diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js
index 44bc2d9f5f8..880e1a88975 100644
--- a/app/assets/javascripts/profile/gl_crop.js
+++ b/app/assets/javascripts/profile/gl_crop.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-useless-escape, no-var, no-underscore-dangle, func-names, no-return-assign, one-var, consistent-return, class-methods-use-this */
+/* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
import 'cropper';
@@ -59,8 +59,7 @@ import _ from 'underscore';
}
bindEvents() {
- var _this;
- _this = this;
+ const _this = this;
this.fileInput.on('change', function(e) {
_this.onFileInputChange(e, this);
this.value = null;
@@ -70,8 +69,7 @@ import _ from 'underscore';
this.modalCrop.on('hidden.bs.modal', this.onModalHide);
this.uploadImageBtn.on('click', this.onUploadImageBtnClick);
this.cropActionsBtn.on('click', function() {
- var btn;
- btn = this;
+ const btn = this;
return _this.onActionBtnClick(btn);
});
return (this.croppedImageBlob = null);
@@ -82,8 +80,7 @@ import _ from 'underscore';
}
onModalShow() {
- var _this;
- _this = this;
+ const _this = this;
return this.modalCropImg.cropper({
viewMode: 1,
center: false,
@@ -128,8 +125,7 @@ import _ from 'underscore';
}
onActionBtnClick(btn) {
- var data;
- data = $(btn).data();
+ const data = $(btn).data();
if (this.modalCropImg.data('cropper') && data.method) {
return this.modalCropImg.cropper(data.method, data.option);
}
@@ -140,9 +136,8 @@ import _ from 'underscore';
}
readFile(input) {
- var _this, reader;
- _this = this;
- reader = new FileReader();
+ const _this = this;
+ const reader = new FileReader();
reader.onload = () => {
_this.modalCropImg.attr('src', reader.result);
return _this.modalCrop.modal('show');
@@ -151,9 +146,10 @@ import _ from 'underscore';
}
dataURLtoBlob(dataURL) {
- var array, binary, i, len;
- binary = atob(dataURL.split(',')[1]);
- array = [];
+ let i = 0;
+ let len = 0;
+ const binary = atob(dataURL.split(',')[1]);
+ const array = [];
for (i = 0, len = binary.length; i < len; i += 1) {
array.push(binary.charCodeAt(i));
@@ -164,9 +160,8 @@ import _ from 'underscore';
}
setPreview() {
- var filename;
+ const filename = this.fileInput.val().replace(FILENAMEREGEX, '');
this.previewImage.attr('src', this.dataURL);
- filename = this.fileInput.val().replace(FILENAMEREGEX, '');
return this.filename.text(filename);
}
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index 2c375b39c1f..031c54d2336 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -1,16 +1,20 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */
+/* eslint-disable func-names, consistent-return, no-return-assign */
import $ from 'jquery';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import { __ } from '~/locale';
+import sanitize from 'sanitize-html';
// highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> )
const highlighter = function(element, text, matches) {
- var j, lastIndex, len, matchIndex, matchedChars, unmatched;
- lastIndex = 0;
- matchedChars = [];
+ let j = 0;
+ let len = 0;
+ let lastIndex = 0;
+ let matchedChars = [];
+ let matchIndex = matches[j];
+ let unmatched = text.substring(lastIndex, matchIndex);
for (j = 0, len = matches.length; j < len; j += 1) {
matchIndex = matches[j];
unmatched = text.substring(lastIndex, matchIndex);
@@ -54,10 +58,10 @@ export default class ProjectFindFile {
'keyup',
(function(_this) {
return function(event) {
- var oldValue, ref, target, value;
- target = $(event.target);
- value = target.val();
- oldValue = (ref = target.data('oldValue')) != null ? ref : '';
+ const target = $(event.target);
+ const value = target.val();
+ const ref = target.data('oldValue');
+ const oldValue = ref != null ? ref : '';
if (value !== oldValue) {
target.data('oldValue', value);
_this.findFile();
@@ -73,9 +77,8 @@ export default class ProjectFindFile {
}
findFile() {
- var result, searchText;
- searchText = this.inputElement.val();
- result =
+ const searchText = sanitize(this.inputElement.val());
+ const result =
searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths;
return this.renderList(result, searchText);
// find file
@@ -100,20 +103,21 @@ export default class ProjectFindFile {
// render result
renderList(filePaths, searchText) {
- var blobItemUrl, filePath, html, i, len, matches, results;
+ let i = 0;
+ let len = 0;
+ let matches = [];
+ const results = [];
this.element.find('.tree-table > tbody').empty();
- results = [];
-
for (i = 0, len = filePaths.length; i < len; i += 1) {
- filePath = filePaths[i];
+ const filePath = filePaths[i];
if (i === 20) {
break;
}
if (searchText) {
matches = fuzzaldrinPlus.match(filePath, searchText);
}
- blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`;
- html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
+ const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`;
+ const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl);
results.push(this.element.find('.tree-table > tbody').append(html));
}
@@ -124,8 +128,7 @@ export default class ProjectFindFile {
// make tbody row html
static makeHtml(filePath, matches, blobItemUrl) {
- var $tr;
- $tr = $(
+ const $tr = $(
"<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>",
);
if (matches) {
@@ -140,9 +143,9 @@ export default class ProjectFindFile {
}
selectRow(type) {
- var next, rows, selectedRow;
- rows = this.element.find('.files-slider tr.tree-item');
- selectedRow = this.element.find('.files-slider tr.tree-item.selected');
+ const rows = this.element.find('.files-slider tr.tree-item');
+ let selectedRow = this.element.find('.files-slider tr.tree-item.selected');
+ let next = selectedRow.prev();
if (rows && rows.length > 0) {
if (selectedRow && selectedRow.length > 0) {
if (type === 'UP') {
@@ -174,7 +177,7 @@ export default class ProjectFindFile {
}
goToBlob() {
- var $link = this.element.find('.tree-item.selected .tree-item-file-name a');
+ const $link = this.element.find('.tree-item.selected .tree-item-file-name a');
if ($link.length) {
$link.get(0).click();
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index 0fbb7e5ca42..66ce1ab5659 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, one-var, no-else-return */
+/* eslint-disable func-names, no-else-return */
import $ from 'jquery';
import Api from './api';
@@ -7,9 +7,11 @@ import { s__ } from './locale';
const projectSelect = () => {
$('.ajax-project-select').each(function(i, select) {
- var placeholder;
+ let placeholder;
const simpleFilter = $(select).data('simpleFilter') || false;
+ const isInstantiated = $(select).data('select2');
this.groupId = $(select).data('groupId');
+ this.userId = $(select).data('userId');
this.includeGroups = $(select).data('includeGroups');
this.allProjects = $(select).data('allProjects') || false;
this.orderBy = $(select).data('orderBy') || 'id';
@@ -28,55 +30,62 @@ const projectSelect = () => {
$(select).select2({
placeholder,
minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
- var data;
- data = {
- results: projects,
- };
- return query.callback(data);
+ query: query => {
+ let projectsCallback;
+ const finalCallback = function(projects) {
+ const data = {
+ results: projects,
};
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
- var data;
- data = groups.concat(projects);
- return finalCallback(data);
- };
- return Api.groups(query.term, {}, groupsCallback);
- };
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(
- _this.groupId,
- query.term,
- {
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- with_shared: _this.withShared,
- include_subgroups: _this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- } else {
- return Api.projects(
- query.term,
- {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- membership: !_this.allProjects,
- },
- projectsCallback,
- );
- }
+ return query.callback(data);
};
- })(this),
+ if (this.includeGroups) {
+ projectsCallback = function(projects) {
+ const groupsCallback = function(groups) {
+ const data = groups.concat(projects);
+ return finalCallback(data);
+ };
+ return Api.groups(query.term, {}, groupsCallback);
+ };
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (this.groupId) {
+ return Api.groupProjects(
+ this.groupId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else if (this.userId) {
+ return Api.userProjects(
+ this.userId,
+ query.term,
+ {
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ with_shared: this.withShared,
+ include_subgroups: this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else {
+ return Api.projects(
+ query.term,
+ {
+ order_by: this.orderBy,
+ with_issues_enabled: this.withIssuesEnabled,
+ with_merge_requests_enabled: this.withMergeRequestsEnabled,
+ membership: !this.allProjects,
+ },
+ projectsCallback,
+ );
+ }
+ },
id(project) {
if (simpleFilter) return project.id;
return JSON.stringify({
@@ -96,7 +105,7 @@ const projectSelect = () => {
dropdownCssClass: 'ajax-project-dropdown',
});
- if (simpleFilter) return select;
+ if (isInstantiated || simpleFilter) return select;
return new ProjectSelectComboButton(select);
});
};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 9066844f687..2429da9c061 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -182,6 +182,10 @@ const bindEvents = () => {
text: s__('ProjectTemplates|Netlify/Hexo'),
icon: '.template-option .icon-netlify',
},
+ serverless_framework: {
+ text: s__('ProjectTemplates|Serverless Framework/JS'),
+ icon: '.template-option .icon-serverless_framework',
+ },
};
const selectedTemplate = templates[value];
diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue
index 95f8270b5d0..5a6f9370564 100644
--- a/app/assets/javascripts/registry/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/components/collapsible_container.vue
@@ -8,12 +8,13 @@ import {
GlModalDirective,
GlEmptyState,
} from '@gitlab/ui';
-import createFlash from '../../flash';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import Icon from '../../vue_shared/components/icon.vue';
+import createFlash from '~/flash';
+import Tracking from '~/tracking';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
import TableRegistry from './table_registry.vue';
-import { errorMessages, errorMessagesTypes } from '../constants';
-import { __ } from '../../locale';
+import { DELETE_REPO_ERROR_MESSAGE } from '../constants';
+import { __ } from '~/locale';
export default {
name: 'CollapsibeContainerRegisty',
@@ -30,6 +31,7 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
+ mixins: [Tracking.mixin({})],
props: {
repo: {
type: Object,
@@ -40,6 +42,10 @@ export default {
return {
isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
+ tracking: {
+ category: document.body.dataset.page,
+ label: 'registry_repository_delete',
+ },
};
},
computed: {
@@ -61,15 +67,13 @@ export default {
}
},
handleDeleteRepository() {
+ this.track('confirm_delete', {});
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
this.fetchRepos();
})
- .catch(() => this.showError(errorMessagesTypes.DELETE_REPO));
- },
- showError(message) {
- createFlash(errorMessages[message]);
+ .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE));
},
},
};
@@ -97,10 +101,9 @@ export default {
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
- data-track-event="click_button"
- data-track-label="registry_repository_delete"
class="js-remove-repo btn-inverted"
variant="danger"
+ @click="track('click_button', {})"
>
<icon name="remove" />
</gl-button>
@@ -124,7 +127,13 @@ export default {
class="mx-auto my-0"
/>
</div>
- <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository">
+ <gl-modal
+ ref="deleteModal"
+ :modal-id="modalId"
+ ok-variant="danger"
+ @ok="handleDeleteRepository"
+ @cancel="track('cancel_delete', {})"
+ >
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
v-html="
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 8470fbc2b59..caa5fd4ff4e 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -1,20 +1,15 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import {
- GlButton,
- GlFormCheckbox,
- GlTooltipDirective,
- GlModal,
- GlModalDirective,
-} from '@gitlab/ui';
-import { n__, s__, sprintf } from '../../locale';
-import createFlash from '../../flash';
-import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
-import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue';
-import Icon from '../../vue_shared/components/icon.vue';
-import timeagoMixin from '../../vue_shared/mixins/timeago';
-import { errorMessages, errorMessagesTypes } from '../constants';
-import { numberToHumanSize } from '../../lib/utils/number_utils';
+import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import { n__, s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants';
export default {
components: {
@@ -27,7 +22,6 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
- GlModal: GlModalDirective,
},
mixins: [timeagoMixin],
props: {
@@ -65,12 +59,21 @@ export default {
this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length,
);
},
- },
- mounted() {
- this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents);
+ isMultiDelete() {
+ return this.itemsToBeDeleted.length > 1;
+ },
+ tracking() {
+ return {
+ property: this.repo.name,
+ label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
},
methods: {
...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']),
+ track(action) {
+ Tracking.event(document.body.dataset.page, action, this.tracking);
+ },
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = sprintf(
@@ -92,17 +95,11 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
- removeModalEvents() {
- this.$refs.deleteModal.$refs.modal.$off('ok');
- },
deleteSingleItem(index) {
this.setModalDescription(index);
this.itemsToBeDeleted = [index];
-
- this.$refs.deleteModal.$refs.modal.$once('ok', () => {
- this.removeModalEvents();
- this.handleSingleDelete(this.repo.list[index]);
- });
+ this.track('click_button');
+ this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = [...this.selectedItems];
@@ -111,17 +108,14 @@ export default {
} else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
-
- this.$refs.deleteModal.$refs.modal.$once('ok', () => {
- this.removeModalEvents();
- this.handleMultipleDelete();
- });
+ this.track('click_button');
+ this.$refs.deleteModal.show();
},
handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.deleteItem(itemToDelete)
.then(() => this.fetchList({ repo: this.repo }))
- .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
+ .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
@@ -134,19 +128,16 @@ export default {
items: itemsToBeDeleted.map(x => this.repo.list[x].tag),
})
.then(() => this.fetchList({ repo: this.repo }))
- .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY));
+ .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE));
} else {
- this.showError(errorMessagesTypes.DELETE_REGISTRY);
+ createFlash(DELETE_REGISTRY_ERROR_MESSAGE);
}
},
onPageChange(pageNumber) {
this.fetchList({ repo: this.repo, page: pageNumber }).catch(() =>
- this.showError(errorMessagesTypes.FETCH_REGISTRY),
+ createFlash(FETCH_REGISTRY_ERROR_MESSAGE),
);
},
- showError(message) {
- createFlash(errorMessages[message]);
- },
onSelectAllChange() {
if (this.selectAllChecked) {
this.deselectAll();
@@ -179,6 +170,15 @@ export default {
canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled;
},
+ onDeletionConfirmed() {
+ this.track('confirm_delete');
+ if (this.isMultiDelete) {
+ this.handleMultipleDelete();
+ } else {
+ const index = this.itemsToBeDeleted[0];
+ this.handleSingleDelete(this.repo.list[index]);
+ }
+ },
},
};
</script>
@@ -202,12 +202,10 @@ export default {
<th>
<gl-button
v-if="canDeleteRepo"
+ ref="bulkDeleteButton"
v-gl-tooltip
- v-gl-modal="modalId"
:disabled="!selectedItems || selectedItems.length === 0"
- class="js-delete-registry float-right"
- data-track-event="click_button"
- data-track-label="bulk_registry_tag_delete"
+ class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
@@ -259,11 +257,8 @@ export default {
<td class="content action-buttons">
<gl-button
v-if="canDeleteRow(item)"
- v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
- data-track-event="click_button"
- data-track-label="registry_tag_delete"
variant="danger"
class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(index)"
@@ -282,7 +277,13 @@ export default {
class="js-registry-pagination"
/>
- <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger">
+ <gl-modal
+ ref="deleteModal"
+ :modal-id="modalId"
+ ok-variant="danger"
+ @ok="onDeletionConfirmed"
+ @cancel="track('cancel_delete')"
+ >
<template v-slot:modal-title>{{ modalAction }}</template>
<template v-slot:modal-ok>{{ modalAction }}</template>
<p v-html="modalDescription"></p>
diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js
index 712b0fade3d..db798fb88ac 100644
--- a/app/assets/javascripts/registry/constants.js
+++ b/app/assets/javascripts/registry/constants.js
@@ -1,15 +1,8 @@
import { __ } from '../locale';
-export const errorMessagesTypes = {
- FETCH_REGISTRY: 'FETCH_REGISTRY',
- FETCH_REPOS: 'FETCH_REPOS',
- DELETE_REPO: 'DELETE_REPO',
- DELETE_REGISTRY: 'DELETE_REGISTRY',
-};
-
-export const errorMessages = {
- [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'),
- [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'),
- [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'),
- [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'),
-};
+export const FETCH_REGISTRY_ERROR_MESSAGE = __(
+ 'Something went wrong while fetching the registry list.',
+);
+export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.');
+export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.');
+export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.');
diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js
index 2121f518a7a..6afba618486 100644
--- a/app/assets/javascripts/registry/stores/actions.js
+++ b/app/assets/javascripts/registry/stores/actions.js
@@ -1,7 +1,7 @@
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import * as types from './mutation_types';
-import { errorMessages, errorMessagesTypes } from '../constants';
+import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants';
export const fetchRepos = ({ commit, state }) => {
commit(types.TOGGLE_MAIN_LOADING);
@@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => {
})
.catch(() => {
commit(types.TOGGLE_MAIN_LOADING);
- createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]);
+ createFlash(FETCH_REPOS_ERROR_MESSAGE);
});
};
@@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => {
})
.catch(() => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
- createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]);
+ createFlash(FETCH_REGISTRY_ERROR_MESSAGE);
});
};
diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js
index ea5925247d1..419de848883 100644
--- a/app/assets/javascripts/registry/stores/mutations.js
+++ b/app/assets/javascripts/registry/stores/mutations.js
@@ -1,33 +1,31 @@
import * as types from './mutation_types';
-import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils';
+import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
export default {
[types.SET_MAIN_ENDPOINT](state, endpoint) {
- Object.assign(state, { endpoint });
+ state.endpoint = endpoint;
},
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
- Object.assign(state, { isDeleteDisabled });
+ state.isDeleteDisabled = isDeleteDisabled;
},
[types.SET_REPOS_LIST](state, list) {
- Object.assign(state, {
- repos: list.map(el => ({
- canDelete: Boolean(el.destroy_path),
- destroyPath: el.destroy_path,
- id: el.id,
- isLoading: false,
- list: [],
- location: el.location,
- name: el.path,
- tagsPath: el.tags_path,
- projectId: el.project_id,
- })),
- });
+ state.repos = list.map(el => ({
+ canDelete: Boolean(el.destroy_path),
+ destroyPath: el.destroy_path,
+ id: el.id,
+ isLoading: false,
+ list: [],
+ location: el.location,
+ name: el.path,
+ tagsPath: el.tags_path,
+ projectId: el.project_id,
+ }));
},
[types.TOGGLE_MAIN_LOADING](state) {
- Object.assign(state, { isLoading: !state.isLoading });
+ state.isLoading = !state.isLoading;
},
[types.SET_REGISTRY_LIST](state, { repo, resp, headers }) {
diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue
index 54a441de886..073cfcd7694 100644
--- a/app/assets/javascripts/releases/detail/components/app.vue
+++ b/app/assets/javascripts/releases/detail/components/app.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui';
+import _ from 'underscore';
import { __, sprintf } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import autofocusonshow from '~/vue_shared/directives/autofocusonshow';
@@ -23,6 +24,7 @@ export default {
'markdownDocsPath',
'markdownPreviewPath',
'releasesPagePath',
+ 'updateReleaseApiDocsPath',
]),
showForm() {
return !this.isFetchingRelease && !this.fetchError;
@@ -42,6 +44,20 @@ export default {
tagName() {
return this.$store.state.release.tagName;
},
+ tagNameHintText() {
+ return sprintf(
+ __(
+ 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}',
+ ),
+ {
+ linkStart: `<a href="${_.escape(
+ this.updateReleaseApiDocsPath,
+ )}" target="_blank" rel="noopener noreferrer">`,
+ linkEnd: '</a>',
+ },
+ false,
+ );
+ },
releaseTitle: {
get() {
return this.$store.state.release.name;
@@ -77,22 +93,22 @@ export default {
<div class="d-flex flex-column">
<p class="pt-3 js-subtitle-text" v-html="subtitleText"></p>
<form v-if="showForm" @submit.prevent="updateRelease()">
- <div class="row">
- <gl-form-group class="col-md-6 col-lg-5 col-xl-4">
- <label for="git-ref">{{ __('Tag name') }}</label>
- <gl-form-input
- id="git-ref"
- v-model="tagName"
- type="text"
- class="form-control"
- aria-describedby="tag-name-help"
- disabled
- />
- <div id="tag-name-help" class="form-text text-muted">
- {{ __('Choose an existing tag, or create a new one') }}
+ <gl-form-group>
+ <div class="row">
+ <div class="col-md-6 col-lg-5 col-xl-4">
+ <label for="git-ref">{{ __('Tag name') }}</label>
+ <gl-form-input
+ id="git-ref"
+ v-model="tagName"
+ type="text"
+ class="form-control"
+ aria-describedby="tag-name-help"
+ disabled
+ />
</div>
- </gl-form-group>
- </div>
+ </div>
+ <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div>
+ </gl-form-group>
<gl-form-group>
<label for="release-title">{{ __('Release title') }}</label>
<gl-form-input
diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js
index 3da971e6d90..0dab90a1ede 100644
--- a/app/assets/javascripts/releases/detail/index.js
+++ b/app/assets/javascripts/releases/detail/index.js
@@ -5,7 +5,7 @@ import createStore from './store';
export default () => {
const el = document.getElementById('js-edit-release-page');
- const store = createStore(el.dataset);
+ const store = createStore();
store.dispatch('setInitialState', el.dataset);
return new Vue({
diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js
index ff98e2bed78..7e3d975f1ae 100644
--- a/app/assets/javascripts/releases/detail/store/state.js
+++ b/app/assets/javascripts/releases/detail/store/state.js
@@ -4,6 +4,7 @@ export default () => ({
releasesPagePath: null,
markdownDocsPath: null,
markdownPreviewPath: null,
+ updateReleaseApiDocsPath: null,
release: null,
diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue
index 8d4b32e9dc0..2b6aa6aeff9 100644
--- a/app/assets/javascripts/releases/list/components/release_block.vue
+++ b/app/assets/javascripts/releases/list/components/release_block.vue
@@ -10,6 +10,7 @@ import { slugify } from '~/lib/utils/text_utility';
import { getLocationHash } from '~/lib/utils/url_utility';
import { scrollToElement } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import ReleaseBlockFooter from './release_block_footer.vue';
export default {
name: 'ReleaseBlock',
@@ -19,6 +20,7 @@ export default {
GlButton,
Icon,
UserAvatarLink,
+ ReleaseBlockFooter,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -76,9 +78,12 @@ export default {
},
shouldShowEditButton() {
return Boolean(
- this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit,
+ this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url,
);
},
+ shouldShowFooter() {
+ return this.glFeatures.releaseIssueSummary;
+ },
},
mounted() {
const hash = getLocationHash();
@@ -108,7 +113,7 @@ export default {
v-gl-tooltip
class="btn btn-default js-edit-button ml-2"
:title="__('Edit this release')"
- :href="release._links.edit"
+ :href="release._links.edit_url"
>
<icon name="pencil" />
</gl-link>
@@ -164,7 +169,7 @@ export default {
by
<user-avatar-link
class="prepend-left-4"
- :link-href="author.path"
+ :link-href="author.web_url"
:img-src="author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
@@ -216,5 +221,16 @@ export default {
<div v-html="release.description_html"></div>
</div>
</div>
+
+ <release-block-footer
+ v-if="shouldShowFooter"
+ class="card-footer"
+ :commit="release.commit"
+ :commit-path="release.commit_path"
+ :tag-name="release.tag_name"
+ :tag-path="release.tag_path"
+ :author="release.author"
+ :released-at="release.released_at"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue
new file mode 100644
index 00000000000..5659f0e530b
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue
@@ -0,0 +1,112 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import { __, sprintf } from '~/locale';
+
+export default {
+ name: 'ReleaseBlockFooter',
+ components: {
+ Icon,
+ GlLink,
+ UserAvatarLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ commit: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ commitPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tagName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ tagPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ author: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ releasedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ releasedAtTimeAgo() {
+ return this.timeFormated(this.releasedAt);
+ },
+ userImageAltDescription() {
+ return this.author && this.author.username
+ ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
+ : null;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info">
+ <icon ref="commitIcon" name="commit" class="mr-1" />
+ <div v-gl-tooltip.bottom :title="commit.title">
+ <gl-link v-if="commitPath" :href="commitPath">
+ {{ commit.short_id }}
+ </gl-link>
+ <span v-else>{{ commit.short_id }}</span>
+ </div>
+ </div>
+
+ <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info">
+ <icon name="tag" class="mr-1" />
+ <div v-gl-tooltip.bottom :title="__('Tag')">
+ <gl-link v-if="tagPath" :href="tagPath">
+ {{ tagName }}
+ </gl-link>
+ <span v-else>{{ tagName }}</span>
+ </div>
+ </div>
+
+ <div
+ v-if="releasedAt || author"
+ class="float-left d-flex align-items-center js-author-date-info"
+ >
+ <span class="text-secondary">{{ __('Created') }}&nbsp;</span>
+ <template v-if="releasedAt">
+ <span
+ v-gl-tooltip.bottom
+ :title="tooltipTitle(releasedAt)"
+ class="text-secondary flex-shrink-0"
+ >
+ {{ releasedAtTimeAgo }}&nbsp;
+ </span>
+ </template>
+
+ <div v-if="author" class="d-flex">
+ <span class="text-secondary">{{ __('by') }}&nbsp;</span>
+ <user-avatar-link
+ :link-href="author.web_url"
+ :img-src="author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="author.username"
+ tooltip-placement="bottom"
+ />
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 386653b9444..62a9338b864 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -50,6 +50,6 @@ export default {
}"
class="report-block-list-icon"
>
- <icon :name="iconName" :size="statusIconSize" />
+ <icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" />
</div>
</template>
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index f3f7d2648a8..3c8a9e6ebef 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -46,6 +46,7 @@ export default {
<li
:class="{ 'is-dismissed': issue.isDismissed }"
class="report-block-list-issue align-items-center"
+ data-qa-selector="report_item_row"
>
<issue-status-icon
v-if="showReportSectionStatusIcon"
diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue
new file mode 100644
index 00000000000..dffadade082
--- /dev/null
+++ b/app/assets/javascripts/repository/components/directory_download_links.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ currentPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ links: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ normalizedLinks() {
+ return this.links.map(link => ({
+ text: link.text,
+ path: `${link.path}?path=${this.currentPath}`,
+ }));
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="border-top pt-1 mt-1">
+ <h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5>
+ <div class="dropdown-menu-content">
+ <div class="btn-group ml-0 w-100">
+ <gl-link
+ v-for="(link, index) in normalizedLinks"
+ :key="index"
+ :href="link.path"
+ :class="{ 'btn-primary': index === 0 }"
+ class="btn btn-xs"
+ >
+ {{ link.text }}
+ </gl-link>
+ </div>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 19a2db2db25..70678b0db37 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,5 +1,6 @@
<script>
import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
+import defaultAvatarUrl from 'images/no_avatar.png';
import { sprintf, s__ } from '~/locale';
import Icon from '../../vue_shared/components/icon.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -38,7 +39,14 @@ export default {
path: this.currentPath.replace(/^\//, ''),
};
},
- update: data => data.project.repository.tree.lastCommit,
+ update: data => {
+ const pipelines = data.project.repository.tree.lastCommit.pipelines.edges;
+
+ return {
+ ...data.project.repository.tree.lastCommit,
+ pipeline: pipelines.length && pipelines[0].node,
+ };
+ },
context: {
isSingleRequest: true,
},
@@ -61,7 +69,7 @@ export default {
computed: {
statusTitle() {
return sprintf(s__('Commits|Commit: %{commitText}'), {
- commitText: this.commit.latestPipeline.detailedStatus.text,
+ commitText: this.commit.pipeline.detailedStatus.text,
});
},
isLoading() {
@@ -76,12 +84,13 @@ export default {
this.showDescription = !this.showDescription;
},
},
+ defaultAvatarUrl,
};
</script>
<template>
<div class="info-well d-none d-sm-flex project-last-commit commit p-3">
- <gl-loading-icon v-if="isLoading" size="md" class="mx-auto" />
+ <gl-loading-icon v-if="isLoading" size="md" class="m-auto" />
<template v-else>
<user-avatar-link
v-if="commit.author"
@@ -90,6 +99,9 @@ export default {
:img-size="40"
class="avatar-cell"
/>
+ <span v-else class="avatar-cell user-avatar-link">
+ <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" />
+ </span>
<div class="commit-detail flex-list">
<div class="commit-content qa-commit-content">
<gl-link :href="commit.webUrl" class="commit-row-message item-title">
@@ -102,7 +114,7 @@ export default {
class="text-expander"
@click="toggleShowDescription"
>
- <icon name="ellipsis_h" />
+ <icon name="ellipsis_h" :size="10" />
</gl-button>
<div class="committer">
<gl-link
@@ -112,12 +124,15 @@ export default {
>
{{ commit.author.name }}
</gl-link>
+ <template v-else>
+ {{ commit.authorName }}
+ </template>
{{ s__('LastCommit|authored') }}
<timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" />
</div>
<pre
v-if="commit.description"
- v-show="showDescription"
+ :class="{ 'd-block': showDescription }"
class="commit-row-description append-bottom-8"
>
{{ commit.description }}
@@ -125,19 +140,20 @@ export default {
</div>
<div class="commit-actions flex-row">
<div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div>
- <gl-link
- v-if="commit.latestPipeline"
- v-gl-tooltip
- :href="commit.latestPipeline.detailedStatus.detailsPath"
- :title="statusTitle"
- class="js-commit-pipeline"
- >
- <ci-icon
- :status="commit.latestPipeline.detailedStatus"
- :size="24"
- :aria-label="statusTitle"
- />
- </gl-link>
+ <div v-if="commit.pipeline" class="ci-status-link">
+ <gl-link
+ v-gl-tooltip.left
+ :href="commit.pipeline.detailedStatus.detailsPath"
+ :title="statusTitle"
+ class="js-commit-pipeline"
+ >
+ <ci-icon
+ :status="commit.pipeline.detailedStatus"
+ :size="24"
+ :aria-label="statusTitle"
+ />
+ </gl-link>
+ </div>
<div class="commit-sha-group d-flex">
<div class="label label-monospace monospace">
{{ showCommitId }}
@@ -153,3 +169,9 @@ export default {
</template>
</div>
</template>
+
+<style scoped>
+.commit {
+ min-height: 4.75rem;
+}
+</style>
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
new file mode 100644
index 00000000000..7f974838359
--- /dev/null
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlLink, GlLoadingIcon } from '@gitlab/ui';
+import getReadmeQuery from '../../queries/getReadme.query.graphql';
+
+export default {
+ apollo: {
+ readme: {
+ query: getReadmeQuery,
+ variables() {
+ return {
+ url: this.blob.webUrl,
+ };
+ },
+ loadingKey: 'loading',
+ },
+ },
+ components: {
+ GlLink,
+ GlLoadingIcon,
+ },
+ props: {
+ blob: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ readme: null,
+ loading: 0,
+ };
+ },
+};
+</script>
+
+<template>
+ <article class="file-holder limited-width-container readme-holder">
+ <div class="file-title">
+ <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i>
+ <gl-link :href="blob.webUrl">
+ <strong>{{ blob.name }}</strong>
+ </gl-link>
+ </div>
+ <div class="blob-viewer">
+ <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" />
+ <div v-else-if="readme" v-html="readme.html"></div>
+ </div>
+ </article>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 610c7e8d99e..8f2e9264bca 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -1,19 +1,15 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import createFlash from '~/flash';
+import { GlSkeletonLoading } from '@gitlab/ui';
import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref';
-import getFiles from '../../queries/getFiles.query.graphql';
import getProjectPath from '../../queries/getProjectPath.query.graphql';
import TableHeader from './header.vue';
import TableRow from './row.vue';
import ParentRow from './parent_row.vue';
-const PAGE_SIZE = 100;
-
export default {
components: {
- GlLoadingIcon,
+ GlSkeletonLoading,
TableHeader,
TableRow,
ParentRow,
@@ -29,86 +25,39 @@ export default {
type: String,
required: true,
},
+ entries: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
},
data() {
return {
projectPath: '',
- nextPageCursor: '',
- entries: {
- trees: [],
- submodules: [],
- blobs: [],
- },
- isLoadingFiles: false,
};
},
computed: {
tableCaption() {
+ if (this.isLoading) {
+ return sprintf(
+ __(
+ 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}',
+ ),
+ { path: this.path, ref: this.ref },
+ );
+ }
+
return sprintf(
__('Files, directories, and submodules in the path %{path} for commit reference %{ref}'),
{ path: this.path, ref: this.ref },
);
},
showParentRow() {
- return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1;
- },
- },
- watch: {
- $route: function routeChange() {
- this.entries.trees = [];
- this.entries.submodules = [];
- this.entries.blobs = [];
- this.nextPageCursor = '';
- this.fetchFiles();
- },
- },
- mounted() {
- // We need to wait for `ref` and `projectPath` to be set
- this.$nextTick(() => this.fetchFiles());
- },
- methods: {
- fetchFiles() {
- this.isLoadingFiles = true;
-
- return this.$apollo
- .query({
- query: getFiles,
- variables: {
- projectPath: this.projectPath,
- ref: this.ref,
- path: this.path || '/',
- nextPageCursor: this.nextPageCursor,
- pageSize: PAGE_SIZE,
- },
- })
- .then(({ data }) => {
- if (!data) return;
-
- const pageInfo = this.hasNextPage(data.project.repository.tree);
-
- this.isLoadingFiles = false;
- this.entries = Object.keys(this.entries).reduce(
- (acc, key) => ({
- ...acc,
- [key]: this.normalizeData(key, data.project.repository.tree[key].edges),
- }),
- {},
- );
-
- if (pageInfo && pageInfo.hasNextPage) {
- this.nextPageCursor = pageInfo.endCursor;
- this.fetchFiles();
- }
- })
- .catch(() => createFlash(__('An error occurred while fetching folder content.')));
- },
- normalizeData(key, data) {
- return this.entries[key].concat(data.map(({ node }) => node));
- },
- hasNextPage(data) {
- return []
- .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
- .find(({ hasNextPage }) => hasNextPage);
+ return !this.isLoading && ['', '/'].indexOf(this.path) === -1;
},
},
};
@@ -117,12 +66,7 @@ export default {
<template>
<div class="tree-content-holder">
<div class="table-holder bordered-box">
- <table class="table tree-table qa-file-tree" aria-live="polite">
- <caption class="sr-only">
- {{
- tableCaption
- }}
- </caption>
+ <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite">
<table-header v-once />
<tbody>
<parent-row v-show="showParentRow" :commit-ref="ref" :path="path" />
@@ -131,6 +75,7 @@ export default {
v-for="entry in val"
:id="entry.id"
:key="`${entry.flatPath}-${entry.id}`"
+ :sha="entry.sha"
:project-path="projectPath"
:current-path="path"
:name="entry.name"
@@ -141,9 +86,15 @@ export default {
:lfs-oid="entry.lfsOid"
/>
</template>
+ <template v-if="isLoading">
+ <tr v-for="i in 5" :key="i" aria-hidden="true">
+ <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
+ <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
+ <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td>
+ </tr>
+ </template>
</tbody>
</table>
- <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 171841178a3..cf0457a2abf 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,7 +1,8 @@
<script>
-import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui';
+import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import Icon from '~/vue_shared/components/icon.vue';
import { getIconName } from '../../utils/icon';
import getRefMixin from '../../mixins/get_ref';
import getCommit from '../../queries/getCommit.query.graphql';
@@ -12,6 +13,10 @@ export default {
GlLink,
GlSkeletonLoading,
TimeagoTooltip,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
apollo: {
commit: {
@@ -32,6 +37,10 @@ export default {
type: String,
required: true,
},
+ sha: {
+ type: String,
+ required: true,
+ },
projectPath: {
type: String,
required: true,
@@ -93,15 +102,20 @@ export default {
return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
},
shortSha() {
- return this.id.slice(0, 8);
+ return this.sha.slice(0, 8);
+ },
+ hasLockLabel() {
+ return this.commit && this.commit.lockLabel;
},
},
methods: {
- openRow() {
- if (this.isFolder) {
+ openRow(e) {
+ if (e.target.tagName === 'A') return;
+
+ if (this.isFolder && !e.metaKey) {
this.$router.push(this.routerLinkTo);
} else {
- visitUrl(this.url);
+ visitUrl(this.url, e.metaKey);
}
},
},
@@ -120,15 +134,28 @@ export default {
<template v-if="isSubmodule">
@ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link>
</template>
+ <icon
+ v-if="hasLockLabel"
+ v-gl-tooltip
+ :title="commit.lockLabel"
+ name="lock"
+ :size="12"
+ class="ml-2 vertical-align-middle"
+ />
</td>
<td class="d-none d-sm-table-cell tree-commit">
- <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link">
+ <gl-link
+ v-if="commit"
+ :href="commit.commitPath"
+ :title="commit.message"
+ class="str-truncated-100 tree-commit-link"
+ >
{{ commit.message }}
</gl-link>
<gl-skeleton-loading v-else :lines="1" class="h-auto" />
</td>
<td class="tree-time-ago text-right">
- <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" />
+ <timeago-tooltip v-if="commit" :time="commit.committedDate" />
<gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" />
</td>
</tr>
diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue
new file mode 100644
index 00000000000..72764f3ccc9
--- /dev/null
+++ b/app/assets/javascripts/repository/components/tree_action_link.vue
@@ -0,0 +1,28 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ text: {
+ type: String,
+ required: true,
+ },
+ cssClass: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link>
+</template>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
new file mode 100644
index 00000000000..949e653fc8f
--- /dev/null
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -0,0 +1,115 @@
+<script>
+import createFlash from '~/flash';
+import { __ } from '../../locale';
+import FileTable from './table/index.vue';
+import getRefMixin from '../mixins/get_ref';
+import getFiles from '../queries/getFiles.query.graphql';
+import getProjectPath from '../queries/getProjectPath.query.graphql';
+import FilePreview from './preview/index.vue';
+import { readmeFile } from '../utils/readme';
+
+const PAGE_SIZE = 100;
+
+export default {
+ components: {
+ FileTable,
+ FilePreview,
+ },
+ mixins: [getRefMixin],
+ apollo: {
+ projectPath: {
+ query: getProjectPath,
+ },
+ },
+ props: {
+ path: {
+ type: String,
+ required: false,
+ default: '/',
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ nextPageCursor: '',
+ entries: {
+ trees: [],
+ submodules: [],
+ blobs: [],
+ },
+ isLoadingFiles: false,
+ };
+ },
+ computed: {
+ readme() {
+ return readmeFile(this.entries.blobs);
+ },
+ },
+
+ watch: {
+ $route: function routeChange() {
+ this.entries.trees = [];
+ this.entries.submodules = [];
+ this.entries.blobs = [];
+ this.nextPageCursor = '';
+ this.fetchFiles();
+ },
+ },
+ mounted() {
+ // We need to wait for `ref` and `projectPath` to be set
+ this.$nextTick(() => this.fetchFiles());
+ },
+ methods: {
+ fetchFiles() {
+ this.isLoadingFiles = true;
+
+ return this.$apollo
+ .query({
+ query: getFiles,
+ variables: {
+ projectPath: this.projectPath,
+ ref: this.ref,
+ path: this.path || '/',
+ nextPageCursor: this.nextPageCursor,
+ pageSize: PAGE_SIZE,
+ },
+ })
+ .then(({ data }) => {
+ if (!data) return;
+
+ const pageInfo = this.hasNextPage(data.project.repository.tree);
+
+ this.isLoadingFiles = false;
+ this.entries = Object.keys(this.entries).reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.normalizeData(key, data.project.repository.tree[key].edges),
+ }),
+ {},
+ );
+
+ if (pageInfo && pageInfo.hasNextPage) {
+ this.nextPageCursor = pageInfo.endCursor;
+ this.fetchFiles();
+ }
+ })
+ .catch(() => createFlash(__('An error occurred while fetching folder content.')));
+ },
+ normalizeData(key, data) {
+ return this.entries[key].concat(data.map(({ node }) => node));
+ },
+ hasNextPage(data) {
+ return []
+ .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
+ .find(({ hasNextPage }) => hasNextPage);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" />
+ <file-preview v-if="readme" :blob="readme" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index 6cb253c8169..6936c08d852 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -1,6 +1,7 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import axios from '~/lib/utils/axios_utils';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
import { fetchLogsTree } from './log_tree';
@@ -27,6 +28,11 @@ const defaultClient = createDefaultClient(
});
});
},
+ readme(_, { url }) {
+ return axios
+ .get(url, { params: { viewer: 'rich', format: 'json' } })
+ .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' }));
+ },
},
},
{
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index f9727960040..d826f209815 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -3,13 +3,18 @@ import createRouter from './router';
import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue';
import LastCommit from './components/last_commit.vue';
+import TreeActionLink from './components/tree_action_link.vue';
+import DirectoryDownloadLinks from './components/directory_download_links.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
import { parseBoolean } from '../lib/utils/common_utils';
+import { webIDEUrl } from '../lib/utils/url_utility';
+import { __ } from '../locale';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
- const { projectPath, projectShortPath, ref, fullName } = el.dataset;
+ const { dataset } = el;
+ const { projectPath, projectShortPath, ref, fullName } = dataset;
const router = createRouter(projectPath, ref);
apolloProvider.clients.defaultClient.cache.writeData({
@@ -22,19 +27,7 @@ export default function setupVueRepositoryList() {
});
router.afterEach(({ params: { pathMatch } }) => {
- const isRoot = pathMatch === undefined || pathMatch === '/';
-
setTitle(pathMatch, ref, fullName);
-
- if (!isRoot) {
- document
- .querySelectorAll('.js-keep-hidden-on-navigation')
- .forEach(elem => elem.classList.add('hidden'));
- }
-
- document
- .querySelectorAll('.js-hide-on-navigation')
- .forEach(elem => elem.classList.toggle('hidden', !isRoot));
});
const breadcrumbEl = document.getElementById('js-repo-breadcrumb');
@@ -88,7 +81,68 @@ export default function setupVueRepositoryList() {
},
});
- return new Vue({
+ const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
+ const { historyLink } = treeHistoryLinkEl.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: treeHistoryLinkEl,
+ router,
+ render(h) {
+ return h(TreeActionLink, {
+ props: {
+ path: historyLink + (this.$route.params.pathMatch || '/'),
+ text: __('History'),
+ },
+ });
+ },
+ });
+
+ const webIdeLinkEl = document.getElementById('js-tree-web-ide-link');
+
+ if (webIdeLinkEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: webIdeLinkEl,
+ router,
+ render(h) {
+ return h(TreeActionLink, {
+ props: {
+ path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`),
+ text: __('Web IDE'),
+ cssClass: 'qa-web-ide-button',
+ },
+ });
+ },
+ });
+ }
+
+ const directoryDownloadLinks = document.getElementById('js-directory-downloads');
+
+ if (directoryDownloadLinks) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: directoryDownloadLinks,
+ router,
+ render(h) {
+ const currentPath = this.$route.params.pathMatch || '/';
+
+ if (currentPath !== '/') {
+ return h(DirectoryDownloadLinks, {
+ props: {
+ currentPath: currentPath.replace(/^\//, ''),
+ links: JSON.parse(directoryDownloadLinks.dataset.links),
+ },
+ });
+ }
+
+ return null;
+ },
+ });
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
el,
router,
apolloProvider,
@@ -96,4 +150,6 @@ export default function setupVueRepositoryList() {
return h(App);
},
});
+
+ return { router, data: dataset };
}
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
index 2c19aca2397..5bf30e625a0 100644
--- a/app/assets/javascripts/repository/log_tree.js
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -1,4 +1,5 @@
import axios from '~/lib/utils/axios_utils';
+import { normalizeData } from 'ee_else_ce/repository/utils/commit';
import getCommits from './queries/getCommits.query.graphql';
import getProjectPath from './queries/getProjectPath.query.graphql';
import getRef from './queries/getRef.query.graphql';
@@ -6,18 +7,6 @@ import getRef from './queries/getRef.query.graphql';
let fetchpromise;
let resolvers = [];
-export function normalizeData(data) {
- return data.map(d => ({
- sha: d.commit.id,
- message: d.commit.message,
- committedDate: d.commit.committed_date,
- commitPath: d.commit_path,
- fileName: d.file_name,
- type: d.type,
- __typename: 'LogTreeCommit',
- }));
-}
-
export function resolveCommit(commits, { resolve, entry }) {
const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type);
@@ -37,9 +26,12 @@ export function fetchLogsTree(client, path, offset, resolver = null) {
const { ref } = client.readQuery({ query: getRef });
fetchpromise = axios
- .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, {
- params: { format: 'json', offset },
- })
+ .get(
+ `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`,
+ {
+ params: { format: 'json', offset },
+ },
+ )
.then(({ data, headers }) => {
const headerLogsOffset = headers['more-logs-offset'];
const { commits } = client.readQuery({ query: getCommits });
diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue
index 2d92e9174ca..29786bf4ec8 100644
--- a/app/assets/javascripts/repository/pages/index.vue
+++ b/app/assets/javascripts/repository/pages/index.vue
@@ -1,18 +1,25 @@
<script>
-import FileTable from '../components/table/index.vue';
+import TreePage from './tree.vue';
+import { updateElementsVisibility } from '../utils/dom';
export default {
components: {
- FileTable,
+ TreePage,
},
- data() {
- return {
- ref: '',
- };
+ mounted() {
+ this.updateProjectElements(true);
+ },
+ beforeDestroy() {
+ this.updateProjectElements(false);
+ },
+ methods: {
+ updateProjectElements(isShow) {
+ updateElementsVisibility('.js-show-on-project-root', isShow);
+ },
},
};
</script>
<template>
- <file-table path="/" />
+ <tree-page path="/" />
</template>
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
index 3b898d1aa91..dd4d437f4dd 100644
--- a/app/assets/javascripts/repository/pages/tree.vue
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -1,9 +1,10 @@
<script>
-import FileTable from '../components/table/index.vue';
+import TreeContent from '../components/tree_content.vue';
+import { updateElementsVisibility } from '../utils/dom';
export default {
components: {
- FileTable,
+ TreeContent,
},
props: {
path: {
@@ -12,9 +13,26 @@ export default {
default: '/',
},
},
+ computed: {
+ isRoot() {
+ return this.path === '/';
+ },
+ },
+ watch: {
+ isRoot: {
+ immediate: true,
+ handler: 'updateElements',
+ },
+ },
+ methods: {
+ updateElements(isRoot) {
+ updateElementsVisibility('.js-show-on-root', isRoot);
+ updateElementsVisibility('.js-hide-on-root', !isRoot);
+ },
+ },
};
</script>
<template>
- <file-table :path="path" />
+ <tree-content :path="path" />
</template>
diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql
new file mode 100644
index 00000000000..9bb13c475c7
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql
@@ -0,0 +1,8 @@
+fragment TreeEntryCommit on LogTreeCommit {
+ sha
+ message
+ committedDate
+ commitPath
+ fileName
+ type
+}
diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql
index e2a2d831e47..e4aeaaff8fe 100644
--- a/app/assets/javascripts/repository/queries/getCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql
@@ -1,10 +1,7 @@
+#import "ee_else_ce/repository/queries/commit.fragment.graphql"
+
query getCommit($fileName: String!, $type: String!, $path: String!) {
commit(path: $path, fileName: $fileName, type: $type) @client {
- sha
- message
- committedDate
- commitPath
- fileName
- type
+ ...TreeEntryCommit
}
}
diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql
index df9e67cc440..0976b8f32d7 100644
--- a/app/assets/javascripts/repository/queries/getCommits.query.graphql
+++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql
@@ -1,10 +1,7 @@
+#import "ee_else_ce/repository/queries/commit.fragment.graphql"
+
query getCommits {
commits @client {
- sha
- message
- committedDate
- commitPath
- fileName
- type
+ ...TreeEntryCommit
}
}
diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql
index c4814f8e63a..2aaf5066b4a 100644
--- a/app/assets/javascripts/repository/queries/getFiles.query.graphql
+++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql
@@ -2,6 +2,7 @@
fragment TreeEntry on Entry {
id
+ sha
name
flatPath
type
diff --git a/app/assets/javascripts/repository/queries/getReadme.query.graphql b/app/assets/javascripts/repository/queries/getReadme.query.graphql
new file mode 100644
index 00000000000..cf056330133
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getReadme.query.graphql
@@ -0,0 +1,5 @@
+query getReadme($url: String!) {
+ readme(url: $url) @client {
+ html
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index 71c1bf12749..9be025afe39 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -5,22 +5,27 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
lastCommit {
sha
title
- message
+ description
webUrl
authoredDate
+ authorName
author {
name
avatarUrl
webUrl
}
signatureHtml
- latestPipeline {
- detailedStatus {
- detailsPath
- icon
- tooltip
- text
- group
+ pipelines(ref: $ref, first: 1) {
+ edges {
+ node {
+ detailedStatus {
+ detailsPath
+ icon
+ tooltip
+ text
+ group
+ }
+ }
}
}
}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
index 9322c81ab97..ebf0a7091ea 100644
--- a/app/assets/javascripts/repository/router.js
+++ b/app/assets/javascripts/repository/router.js
@@ -16,7 +16,7 @@ export default function createRouter(base, baseRef) {
name: 'treePath',
component: TreePage,
props: route => ({
- path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''),
+ path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'),
}),
},
{
diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js
new file mode 100644
index 00000000000..6c204b57b37
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/commit.js
@@ -0,0 +1,13 @@
+// eslint-disable-next-line import/prefer-default-export
+export function normalizeData(data, extra = () => {}) {
+ return data.map(d => ({
+ sha: d.commit.id,
+ message: d.commit.message,
+ committedDate: d.commit.committed_date,
+ commitPath: d.commit_path,
+ fileName: d.file_name,
+ type: d.type,
+ __typename: 'LogTreeCommit',
+ ...extra(d),
+ }));
+}
diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js
new file mode 100644
index 00000000000..963e6fc0bc4
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/dom.js
@@ -0,0 +1,4 @@
+// eslint-disable-next-line import/prefer-default-export
+export const updateElementsVisibility = (selector, isVisible) => {
+ document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible));
+};
diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js
new file mode 100644
index 00000000000..e43b2bdc33a
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/readme.js
@@ -0,0 +1,21 @@
+const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown'];
+const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc'];
+const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst'];
+const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS];
+const PLAIN_FILENAMES = ['readme', 'index'];
+const FILE_REGEXP = new RegExp(
+ `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`,
+ 'i',
+);
+const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i');
+const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i');
+
+// eslint-disable-next-line import/prefer-default-export
+export const readmeFile = blobs => {
+ const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1);
+
+ const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1);
+ const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1);
+
+ return previewableReadme || plainReadme;
+};
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
index 87d54c01200..ff16fbdd420 100644
--- a/app/assets/javascripts/repository/utils/title.js
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -1,10 +1,14 @@
+const DEFAULT_TITLE = '· GitLab';
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => {
- if (!pathMatch) return;
+ if (!pathMatch) {
+ document.title = `${project} ${DEFAULT_TITLE}`;
+ return;
+ }
const path = pathMatch.replace(/^\//, '');
const isEmpty = path === '';
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`;
+ document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`;
};
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 87454ee056f..fa5649679d7 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */
+/* eslint-disable func-names, consistent-return, no-else-return, no-param-reassign */
import $ from 'jquery';
import _ from 'underscore';
@@ -44,12 +44,11 @@ Sidebar.prototype.addEventListeners = function() {
};
Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
- var $allGutterToggleIcons, $this, isExpanded, tooltipLabel;
+ const $this = $(this);
+ const isExpanded = $this.find('i').hasClass('fa-angle-double-right');
+ const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
+ const $allGutterToggleIcons = $('.js-sidebar-toggle i');
e.preventDefault();
- $this = $(this);
- isExpanded = $this.find('i').hasClass('fa-angle-double-right');
- tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar');
- $allGutterToggleIcons = $('.js-sidebar-toggle i');
if (isExpanded) {
$allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left');
@@ -77,15 +76,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
};
Sidebar.prototype.toggleTodo = function(e) {
- var $this, ajaxType, url;
- $this = $(e.currentTarget);
- ajaxType = $this.data('deletePath') ? 'delete' : 'post';
-
- if ($this.data('deletePath')) {
- url = String($this.data('deletePath'));
- } else {
- url = String($this.data('createPath'));
- }
+ const $this = $(e.currentTarget);
+ const ajaxType = $this.data('deletePath') ? 'delete' : 'post';
+ const url = String($this.data('deletePath') || $this.data('createPath'));
$this.tooltip('hide');
@@ -141,13 +134,12 @@ Sidebar.prototype.todoUpdateDone = function(data) {
};
Sidebar.prototype.sidebarDropdownLoading = function() {
- var $loading, $sidebarCollapsedIcon, i, img;
- $sidebarCollapsedIcon = $(this)
+ const $sidebarCollapsedIcon = $(this)
.closest('.block')
.find('.sidebar-collapsed-icon');
- img = $sidebarCollapsedIcon.find('img');
- i = $sidebarCollapsedIcon.find('i');
- $loading = $('<i class="fa fa-spinner fa-spin"></i>');
+ const img = $sidebarCollapsedIcon.find('img');
+ const i = $sidebarCollapsedIcon.find('i');
+ const $loading = $('<i class="fa fa-spinner fa-spin"></i>');
if (img.length) {
img.before($loading);
return img.hide();
@@ -158,13 +150,12 @@ Sidebar.prototype.sidebarDropdownLoading = function() {
};
Sidebar.prototype.sidebarDropdownLoaded = function() {
- var $sidebarCollapsedIcon, i, img;
- $sidebarCollapsedIcon = $(this)
+ const $sidebarCollapsedIcon = $(this)
.closest('.block')
.find('.sidebar-collapsed-icon');
- img = $sidebarCollapsedIcon.find('img');
+ const img = $sidebarCollapsedIcon.find('img');
$sidebarCollapsedIcon.find('i.fa-spin').remove();
- i = $sidebarCollapsedIcon.find('i');
+ const i = $sidebarCollapsedIcon.find('i');
if (img.length) {
return img.show();
} else {
@@ -173,19 +164,17 @@ Sidebar.prototype.sidebarDropdownLoaded = function() {
};
Sidebar.prototype.sidebarCollapseClicked = function(e) {
- var $block, sidebar;
if ($(e.currentTarget).hasClass('dont-change-state')) {
return;
}
- sidebar = e.data;
+ const sidebar = e.data;
e.preventDefault();
- $block = $(this).closest('.block');
+ const $block = $(this).closest('.block');
return sidebar.openDropdown($block);
};
Sidebar.prototype.openDropdown = function(blockOrName) {
- var $block;
- $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
+ const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName;
if (!this.isOpen()) {
this.setCollapseAfterUpdate($block);
this.toggleSidebar('open');
@@ -204,10 +193,9 @@ Sidebar.prototype.setCollapseAfterUpdate = function($block) {
};
Sidebar.prototype.onSidebarDropdownHidden = function(e) {
- var $block, sidebar;
- sidebar = e.data;
+ const sidebar = e.data;
e.preventDefault();
- $block = $(e.target).closest('.block');
+ const $block = $(e.target).closest('.block');
return sidebar.sidebarDropdownHidden($block);
};
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index f6722ff7bca..8d888a574d8 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -1,4 +1,4 @@
-/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */
+/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */
import $ from 'jquery';
import { escape, throttle } from 'underscore';
@@ -29,14 +29,14 @@ const KEYCODE = {
};
function setSearchOptions() {
- var $projectOptionsDataEl = $('.js-search-project-options');
- var $groupOptionsDataEl = $('.js-search-group-options');
- var $dashboardOptionsDataEl = $('.js-search-dashboard-options');
+ const $projectOptionsDataEl = $('.js-search-project-options');
+ const $groupOptionsDataEl = $('.js-search-group-options');
+ const $dashboardOptionsDataEl = $('.js-search-dashboard-options');
if ($projectOptionsDataEl.length) {
gl.projectOptions = gl.projectOptions || {};
- var projectPath = $projectOptionsDataEl.data('projectPath');
+ const projectPath = $projectOptionsDataEl.data('projectPath');
gl.projectOptions[projectPath] = {
name: $projectOptionsDataEl.data('name'),
@@ -49,7 +49,7 @@ function setSearchOptions() {
if ($groupOptionsDataEl.length) {
gl.groupOptions = gl.groupOptions || {};
- var groupPath = $groupOptionsDataEl.data('groupPath');
+ const groupPath = $groupOptionsDataEl.data('groupPath');
gl.groupOptions[groupPath] = {
name: $groupOptionsDataEl.data('name'),
@@ -95,10 +95,9 @@ export class SearchAutocomplete {
this.createAutocomplete();
}
- this.searchInput.addClass('disabled');
- this.saveTextLength();
this.bindEvents();
this.dropdownToggle.dropdown();
+ this.searchInput.addClass('js-autocomplete-disabled');
}
// Finds an element inside wrapper element
@@ -107,7 +106,7 @@ export class SearchAutocomplete {
this.onClearInputClick = this.onClearInputClick.bind(this);
this.onSearchInputFocus = this.onSearchInputFocus.bind(this);
this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this);
- this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this);
+ this.onSearchInputChange = this.onSearchInputChange.bind(this);
this.setScrollFade = this.setScrollFade.bind(this);
}
getElement(selector) {
@@ -118,10 +117,6 @@ export class SearchAutocomplete {
return (this.originalState = this.serializeState());
}
- saveTextLength() {
- return (this.lastTextLength = this.searchInput.val().length);
- }
-
createAutocomplete() {
return this.searchInput.glDropdown({
filterInputBlur: false,
@@ -318,12 +313,16 @@ export class SearchAutocomplete {
}
bindEvents() {
- this.searchInput.on('keydown', this.onSearchInputKeyDown);
+ this.searchInput.on('input', this.onSearchInputChange);
this.searchInput.on('keyup', this.onSearchInputKeyUp);
this.searchInput.on('focus', this.onSearchInputFocus);
this.searchInput.on('blur', this.onSearchInputBlur);
this.clearInput.on('click', this.onClearInputClick);
this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250));
+
+ this.searchInput.on('click', e => {
+ e.stopPropagation();
+ });
}
enableAutocomplete() {
@@ -338,47 +337,23 @@ export class SearchAutocomplete {
if (!this.dropdown.hasClass('show')) {
this.loadingSuggestions = false;
this.dropdownToggle.dropdown('toggle');
- return this.searchInput.removeClass('disabled');
+ return this.searchInput.removeClass('js-autocomplete-disabled');
}
}
- // Saves last length of the entered text
- onSearchInputKeyDown() {
- return this.saveTextLength();
+ onSearchInputChange() {
+ this.enableAutocomplete();
}
onSearchInputKeyUp(e) {
switch (e.keyCode) {
- case KEYCODE.BACKSPACE:
- // When removing the last character and no badge is present
- if (this.lastTextLength === 1) {
- this.disableAutocomplete();
- }
- // When removing any character from existin value
- if (this.lastTextLength > 1) {
- this.enableAutocomplete();
- }
- break;
case KEYCODE.ESCAPE:
this.restoreOriginalState();
break;
case KEYCODE.ENTER:
this.disableAutocomplete();
break;
- case KEYCODE.UP:
- case KEYCODE.DOWN:
- return;
default:
- // Handle the case when deleting the input value other than backspace
- // e.g. Pressing ctrl + backspace or ctrl + x
- if (this.searchInput.val() === '') {
- this.disableAutocomplete();
- } else {
- // We should display the menu only when input is not empty
- if (e.keyCode !== KEYCODE.ENTER) {
- this.enableAutocomplete();
- }
- }
}
this.wrap.toggleClass('has-value', Boolean(e.target.value));
}
@@ -412,36 +387,33 @@ export class SearchAutocomplete {
}
restoreOriginalState() {
- var i, input, inputs, len;
- inputs = Object.keys(this.originalState);
- for (i = 0, len = inputs.length; i < len; i += 1) {
- input = inputs[i];
+ const inputs = Object.keys(this.originalState);
+ for (let i = 0, len = inputs.length; i < len; i += 1) {
+ const input = inputs[i];
this.getElement(`#${input}`).val(this.originalState[input]);
}
}
resetSearchState() {
- var i, input, inputs, len, results;
- inputs = Object.keys(this.originalState);
- results = [];
- for (i = 0, len = inputs.length; i < len; i += 1) {
- input = inputs[i];
+ const inputs = Object.keys(this.originalState);
+ const results = [];
+ for (let i = 0, len = inputs.length; i < len; i += 1) {
+ const input = inputs[i];
results.push(this.getElement(`#${input}`).val(''));
}
return results;
}
disableAutocomplete() {
- if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) {
- this.searchInput.addClass('disabled');
- this.dropdown.removeClass('show').trigger('hidden.bs.dropdown');
+ if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) {
+ this.searchInput.addClass('js-autocomplete-disabled');
+ this.dropdown.dropdown('toggle');
this.restoreMenu();
}
}
restoreMenu() {
- var html;
- html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
+ const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
return this.dropdownContent.html(html);
}
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/sentry/index.js
index 4dd0175e528..06e4e0aa507 100644
--- a/app/assets/javascripts/raven/index.js
+++ b/app/assets/javascripts/sentry/index.js
@@ -1,8 +1,8 @@
-import RavenConfig from './raven_config';
+import SentryConfig from './sentry_config';
const index = function index() {
- RavenConfig.init({
- sentryDsn: gon.sentry_dsn,
+ SentryConfig.init({
+ dsn: gon.sentry_dsn,
currentUserId: gon.current_user_id,
whitelistUrls:
process.env.NODE_ENV === 'production'
@@ -15,7 +15,7 @@ const index = function index() {
},
});
- return RavenConfig;
+ return SentryConfig;
};
index();
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/sentry/sentry_config.js
index 7259e0df104..bc3b2f16a6a 100644
--- a/app/assets/javascripts/raven/raven_config.js
+++ b/app/assets/javascripts/sentry/sentry_config.js
@@ -1,4 +1,4 @@
-import Raven from 'raven-js';
+import * as Sentry from '@sentry/browser';
import $ from 'jquery';
import { __ } from '~/locale';
@@ -26,7 +26,7 @@ const IGNORE_ERRORS = [
'conduitPage',
];
-const IGNORE_URLS = [
+const BLACKLIST_URLS = [
// Facebook flakiness
/graph\.facebook\.com/i,
// Facebook blocked
@@ -43,62 +43,62 @@ const IGNORE_URLS = [
/metrics\.itunes\.apple\.com\.edgesuite\.net\//i,
];
-const SAMPLE_RATE = 95;
+const SAMPLE_RATE = 0.95;
-const RavenConfig = {
+const SentryConfig = {
IGNORE_ERRORS,
- IGNORE_URLS,
+ BLACKLIST_URLS,
SAMPLE_RATE,
init(options = {}) {
this.options = options;
this.configure();
- this.bindRavenErrors();
+ this.bindSentryErrors();
if (this.options.currentUserId) this.setUser();
},
configure() {
- Raven.config(this.options.sentryDsn, {
- release: this.options.release,
- tags: this.options.tags,
- whitelistUrls: this.options.whitelistUrls,
- environment: this.options.environment,
- ignoreErrors: this.IGNORE_ERRORS,
- ignoreUrls: this.IGNORE_URLS,
- shouldSendCallback: this.shouldSendSample.bind(this),
- }).install();
+ const { dsn, release, tags, whitelistUrls, environment } = this.options;
+ Sentry.init({
+ dsn,
+ release,
+ tags,
+ whitelistUrls,
+ environment,
+ ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144
+ blacklistUrls: this.BLACKLIST_URLS,
+ sampleRate: SAMPLE_RATE,
+ });
},
setUser() {
- Raven.setUserContext({
+ Sentry.setUser({
id: this.options.currentUserId,
});
},
- bindRavenErrors() {
- $(document).on('ajaxError.raven', this.handleRavenErrors);
+ bindSentryErrors() {
+ $(document).on('ajaxError.sentry', this.handleSentryErrors);
},
- handleRavenErrors(event, req, config, err) {
+ handleSentryErrors(event, req, config, err) {
const error = err || req.statusText;
- const responseText = req.responseText || __('Unknown response text');
+ const { responseText = __('Unknown response text') } = req;
+ const { type, url, data } = config;
+ const { status } = req;
- Raven.captureMessage(error, {
+ Sentry.captureMessage(error, {
extra: {
- type: config.type,
- url: config.url,
- data: config.data,
- status: req.status,
+ type,
+ url,
+ data,
+ status,
response: responseText,
error,
event,
},
});
},
-
- shouldSendSample() {
- return Math.random() * 100 <= this.SAMPLE_RATE;
- },
};
-export default RavenConfig;
+export default SentryConfig;
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
index 95a2c8cce6e..91fe5fc50a9 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue
@@ -33,6 +33,8 @@ export default {
<div class="block subscriptions">
<subscriptions
:loading="store.isFetching.subscriptions"
+ :project-emails-disabled="store.projectEmailsDisabled"
+ :subscribe-disabled-description="store.subscribeDisabledDescription"
:subscribed="store.subscribed"
@toggleSubscription="onToggleSubscription"
/>
diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
index ea5edb3ce3f..0e489b28593 100644
--- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
+++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue
@@ -26,6 +26,16 @@ export default {
required: false,
default: false,
},
+ projectEmailsDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ subscribeDisabledDescription: {
+ type: String,
+ required: false,
+ default: '',
+ },
subscribed: {
type: Boolean,
required: false,
@@ -42,11 +52,23 @@ export default {
return this.subscribed === null;
},
notificationIcon() {
+ if (this.projectEmailsDisabled) {
+ return ICON_OFF;
+ }
return this.subscribed ? ICON_ON : ICON_OFF;
},
notificationTooltip() {
+ if (this.projectEmailsDisabled) {
+ return this.subscribeDisabledDescription;
+ }
return this.subscribed ? LABEL_ON : LABEL_OFF;
},
+ notificationText() {
+ if (this.projectEmailsDisabled) {
+ return this.subscribeDisabledDescription;
+ }
+ return __('Notifications');
+ },
},
methods: {
/**
@@ -81,6 +103,7 @@ export default {
<template>
<div>
<span
+ ref="tooltip"
v-tooltip
class="sidebar-collapsed-icon"
:title="notificationTooltip"
@@ -96,8 +119,9 @@ export default {
class="sidebar-item-icon is-active"
/>
</span>
- <span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span>
+ <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span>
<toggle-button
+ v-if="!projectEmailsDisabled"
ref="toggleButton"
:is-loading="showLoadingState"
:value="subscribed"
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 63c4a2a3f84..66f7f9e3c66 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -28,6 +28,8 @@ export default class SidebarStore {
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
this.participants = [];
+ this.projectEmailsDisabled = false;
+ this.subscribeDisabledDescription = '';
this.subscribed = null;
SidebarStore.singleton = this;
@@ -53,6 +55,8 @@ export default class SidebarStore {
}
setSubscriptionsData(data) {
+ this.projectEmailsDisabled = data.project_emails_disabled || false;
+ this.subscribeDisabledDescription = data.subscribe_disabled_description;
this.isFetching.subscriptions = false;
this.subscribed = data.subscribed || false;
}
diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js
new file mode 100644
index 00000000000..796e90bf08e
--- /dev/null
+++ b/app/assets/javascripts/sourcegraph/index.js
@@ -0,0 +1,28 @@
+function loadScript(path) {
+ const script = document.createElement('script');
+ script.type = 'application/javascript';
+ script.src = path;
+ script.defer = true;
+ document.head.appendChild(script);
+}
+
+/**
+ * Loads the Sourcegraph integration for support for Sourcegraph extensions and
+ * code intelligence.
+ */
+export default function initSourcegraph() {
+ const { url } = gon.sourcegraph || {};
+
+ if (!url) {
+ return;
+ }
+
+ const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href);
+ const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href;
+
+ window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href;
+ window.SOURCEGRAPH_URL = url;
+ window.SOURCEGRAPH_INTEGRATION = 'gitlab-integration';
+
+ loadScript(scriptPath);
+}
diff --git a/app/assets/javascripts/sourcegraph/load.js b/app/assets/javascripts/sourcegraph/load.js
new file mode 100644
index 00000000000..f9491505d42
--- /dev/null
+++ b/app/assets/javascripts/sourcegraph/load.js
@@ -0,0 +1,6 @@
+import initSourcegraph from './index';
+
+/**
+ * Load sourcegraph in it's own listener so that it's isolated from failures.
+ */
+document.addEventListener('DOMContentLoaded', initSourcegraph);
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index 69b3d20914a..a530c4a99e2 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */
+/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */
import $ from 'jquery';
import { visitUrl } from './lib/utils/url_utility';
@@ -9,9 +9,8 @@ export default class TreeView {
// Code browser tree slider
// Make the entire tree-item row clickable, but not if clicking another link (like a commit message)
$('.tree-content-holder .tree-item').on('click', function(e) {
- var $clickedEl, path;
- $clickedEl = $(e.target);
- path = $('.tree-item-file-name a', this).attr('href');
+ const $clickedEl = $(e.target);
+ const path = $('.tree-item-file-name a', this).attr('href');
if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) {
if (e.metaKey || e.which === 2) {
e.preventDefault();
@@ -26,11 +25,10 @@ export default class TreeView {
}
initKeyNav() {
- var li, liSelected;
- li = $('tr.tree-item');
- liSelected = null;
+ const li = $('tr.tree-item');
+ let liSelected = null;
return $('body').keydown(e => {
- var next, path;
+ let next, path;
if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) {
return false;
}
diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js
index c0b7587be10..7d6a725b30f 100644
--- a/app/assets/javascripts/user_popovers.js
+++ b/app/assets/javascripts/user_popovers.js
@@ -73,9 +73,14 @@ const handleUserPopoverMouseOver = event => {
location: userData.location,
bio: userData.bio,
organization: userData.organization,
+ status: userData.status,
loaded: true,
});
+ if (userData.status) {
+ return Promise.resolve();
+ }
+
return UsersCache.retrieveStatusById(userId);
})
.then(status => {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
index 339e154affc..57be97855e3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue
@@ -65,9 +65,13 @@ export default {
simplePoll(this.checkRebaseStatus);
})
.catch(error => {
- this.rebasingError = error.merge_error;
this.isMakingRequest = false;
- Flash(__('Something went wrong. Please try again.'));
+
+ if (error.response && error.response.data && error.response.data.merge_error) {
+ this.rebasingError = error.response.data.merge_error;
+ } else {
+ Flash(__('Something went wrong. Please try again.'));
+ }
});
},
checkRebaseStatus(continuePolling, stopPolling) {
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 1e6f4c376c1..66155ddcdd9 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
@@ -18,6 +18,11 @@ export default {
required: false,
default: 0,
},
+ filePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: false,
@@ -52,6 +57,7 @@ export default {
<component
:is="viewer"
:path="path"
+ :file-path="filePath"
:file-size="fileSize"
:project-path="projectPath"
:content="content"
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 655f0054887..c50304f057d 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
@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
+ filePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
projectPath: {
type: String,
required: true,
@@ -48,6 +53,7 @@ export default {
this.isLoading = true;
const postBody = {
text: this.content,
+ path: this.filePath,
};
const postOptions = {
cancelToken: axiosSource.token,
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index ebb253ff422..b874bedab36 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -23,6 +23,11 @@ export default {
type: String,
required: true,
},
+ newSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
oldPath: {
type: String,
required: true,
@@ -31,6 +36,11 @@ export default {
type: String,
required: true,
},
+ oldSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
projectPath: {
type: String,
required: false,
@@ -85,6 +95,8 @@ export default {
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
+ :old-size="oldSize"
+ :new-size="newSize"
:project-path="projectPath"
:a-mode="aMode"
:b-mode="bMode"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
index a17fc022195..4dbfdb6d79c 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue
@@ -14,6 +14,16 @@ export default {
type: String,
required: true,
},
+ newSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ oldSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
};
</script>
@@ -22,12 +32,14 @@ export default {
<div class="two-up view d-flex">
<image-viewer
:path="oldPath"
+ :file-size="oldSize"
:render-info="true"
inner-css-classes="frame deleted"
class="wrap w-50"
/>
<image-viewer
:path="newPath"
+ :file-size="newSize"
:render-info="true"
:inner-css-classes="['frame', 'added']"
class="wrap w-50"
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
index cab92297ca7..e30871b66fc 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
@@ -22,6 +22,16 @@ export default {
type: String,
required: true,
},
+ newSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ oldSize: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 341c9534763..611001df32f 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -218,7 +218,7 @@ export default {
display: inline-block;
flex: 1;
max-width: inherit;
- height: 18px;
+ height: 19px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue
index 73f4dfef062..80908cbbc9c 100644
--- a/app/assets/javascripts/vue_shared/components/icon.vue
+++ b/app/assets/javascripts/vue_shared/components/icon.vue
@@ -61,7 +61,7 @@ export default {
</script>
<template>
- <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true">
+ <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners">
<use v-bind="{ 'xlink:href': spriteHref }" />
</svg>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
index 715cf97f0ac..1524b313f9f 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -1,7 +1,6 @@
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
-
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
@@ -16,44 +15,47 @@ export default {
type: Array,
required: true,
},
+ iconSize: {
+ type: Number,
+ required: false,
+ default: 24,
+ },
+ imgCssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ maxVisible: {
+ type: Number,
+ required: false,
+ default: 3,
+ },
},
data() {
return {
- maxVisibleAssignees: 2,
- maxAssigneeAvatars: 3,
maxAssignees: 99,
};
},
computed: {
- countOverLimit() {
- return this.assignees.length - this.maxVisibleAssignees;
- },
assigneesToShow() {
- if (this.assignees.length > this.maxAssigneeAvatars) {
- return this.assignees.slice(0, this.maxVisibleAssignees);
- }
- return this.assignees;
+ const numShownAssignees = this.assignees.length - this.numHiddenAssignees;
+ return this.assignees.slice(0, numShownAssignees);
},
assigneesCounterTooltip() {
- const { countOverLimit, maxAssignees } = this;
- const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
-
- return sprintf(__('%{count} more assignees'), { count });
+ return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees });
},
- shouldRenderAssigneesCounter() {
- const assigneesCount = this.assignees.length;
- if (assigneesCount <= this.maxAssigneeAvatars) {
- return false;
+ numHiddenAssignees() {
+ if (this.assignees.length > this.maxVisible) {
+ return this.assignees.length - this.maxVisible + 1;
}
-
- return assigneesCount > this.countOverLimit;
+ return 0;
},
assigneeCounterLabel() {
- if (this.countOverLimit > this.maxAssignees) {
+ if (this.numHiddenAssignees > this.maxAssignees) {
return `${this.maxAssignees}+`;
}
- return `+${this.countOverLimit}`;
+ return `+${this.numHiddenAssignees}`;
},
},
methods: {
@@ -81,8 +83,9 @@ export default {
:key="assignee.id"
:link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
+ :img-css-classes="imgCssClasses"
:img-src="avatarUrl(assignee)"
- :img-size="24"
+ :img-size="iconSize"
class="js-no-trigger"
tooltip-placement="bottom"
>
@@ -92,7 +95,7 @@ export default {
</span>
</user-avatar-link>
<span
- v-if="shouldRenderAssigneesCounter"
+ v-if="numHiddenAssignees > 0"
v-gl-tooltip
:title="assigneesCounterTooltip"
class="avatar-counter"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 4d27d1c9179..af4ac024e4f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -124,7 +124,7 @@ export default {
:cursor-offset="4"
:tag-content="lineContent"
icon="doc-code"
- class="qa-suggestion-btn js-suggestion-btn"
+ class="js-suggestion-btn"
@click="handleSuggestDismissed"
/>
<gl-popover
@@ -168,7 +168,7 @@ export default {
:prepend="true"
tag="* [ ] "
:button-title="__('Add a task list')"
- icon="task-done"
+ icon="list-task"
/>
<toolbar-button
:tag="mdTable"
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 12de3671477..cc700440a23 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -55,7 +55,7 @@ export default {
<gl-button
v-else-if="canApply"
v-gl-tooltip.viewport="__('This also resolves the discussion')"
- class="btn-inverted qa-apply-btn js-apply-btn"
+ class="btn-inverted js-apply-btn"
:disabled="isApplying"
variant="success"
@click="applySuggestion"
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index d6dfe9eded8..f8e010c4f42 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -17,9 +17,11 @@
* />
*/
import $ from 'jquery';
-import { mapGetters } from 'vuex';
+import { mapGetters, mapActions } from 'vuex';
+import { GlSkeletonLoading } from '@gitlab/ui';
import noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
+import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
import initMRPopovers from '~/mr_popover/';
@@ -32,7 +34,9 @@ export default {
Icon,
noteHeader,
TimelineEntryItem,
+ GlSkeletonLoading,
},
+ mixins: [descriptionVersionHistoryMixin],
props: {
note: {
type: Object,
@@ -75,13 +79,16 @@ export default {
mounted() {
initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
},
+ methods: {
+ ...mapActions(['fetchDescriptionVersion']),
+ },
};
</script>
<template>
<timeline-entry-item
:id="noteAnchorId"
- :class="{ target: isTargetNote }"
+ :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }"
class="note system-note note-wrapper"
>
<div class="timeline-icon" v-html="iconHtml"></div>
@@ -89,14 +96,18 @@ export default {
<div class="note-header">
<note-header :author="note.author" :created-at="note.created_at" :note-id="note.id">
<span v-html="actionTextHtml"></span>
+ <template v-if="canSeeDescriptionVersion" slot="extra-controls">
+ &middot;
+ <button type="button" class="btn-blank btn-link" @click="toggleDescriptionVersion">
+ {{ __('Compare with previous version') }}
+ <icon :name="descriptionVersionToggleIcon" :size="12" class="append-left-5" />
+ </button>
+ </template>
</note-header>
</div>
<div class="note-body">
<div
- :class="{
- 'system-note-commit-list': hasMoreCommits,
- 'hide-shade': expanded,
- }"
+ :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }"
class="note-text md"
v-html="note.note_html"
></div>
@@ -106,6 +117,12 @@ export default {
<span>{{ __('Toggle commit list') }}</span>
</div>
</div>
+ <div v-if="shouldShowDescriptionVersion" class="description-version pt-2">
+ <pre v-if="isLoadingDescriptionVersion" class="loading-state">
+ <gl-skeleton-loading />
+ </pre>
+ <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
+ </div>
</div>
</div>
</timeline-entry-item>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
index 478e44d104c..f984a0a6203 100644
--- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -1,6 +1,6 @@
<script>
import _ from 'underscore';
-import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
+import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui';
import ProjectListItem from './project_list_item.vue';
const SEARCH_INPUT_TIMEOUT_MS = 500;
@@ -10,6 +10,7 @@ export default {
components: {
GlLoadingIcon,
GlSearchBoxByType,
+ GlInfiniteScroll,
ProjectListItem,
},
props: {
@@ -41,6 +42,11 @@ export default {
required: false,
default: false,
},
+ totalResults: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -51,6 +57,9 @@ export default {
projectClicked(project) {
this.$emit('projectClicked', project);
},
+ bottomReached() {
+ this.$emit('bottomReached');
+ },
isSelected(project) {
return Boolean(_.find(this.selectedProjects, { id: project.id }));
},
@@ -71,18 +80,25 @@ export default {
@input="onInput"
/>
<div class="d-flex flex-column">
- <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
- <div v-if="!showLoadingIndicator" class="d-flex flex-column">
- <project-list-item
- v-for="project in projectSearchResults"
- :key="project.id"
- :selected="isSelected(project)"
- :project="project"
- :matcher="searchQuery"
- class="js-project-list-item"
- @click="projectClicked(project)"
- />
- </div>
+ <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" />
+ <gl-infinite-scroll
+ :max-list-height="402"
+ :fetched-items="projectSearchResults.length"
+ :total-items="totalResults"
+ @bottomReached="bottomReached"
+ >
+ <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column">
+ <project-list-item
+ v-for="project in projectSearchResults"
+ :key="project.id"
+ :selected="isSelected(project)"
+ :project="project"
+ :matcher="searchQuery"
+ class="js-project-list-item"
+ @click="projectClicked(project)"
+ />
+ </div>
+ </gl-infinite-scroll>
<div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
{{ __('Sorry, no projects matched your search') }}
</div>
diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue
new file mode 100644
index 00000000000..67726f01744
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue
@@ -0,0 +1,35 @@
+<script>
+/**
+ * Allows to toggle slots based on an array of slot names.
+ */
+export default {
+ name: 'SlotSwitch',
+
+ props: {
+ activeSlotNames: {
+ type: Array,
+ required: true,
+ },
+
+ tagName: {
+ type: String,
+ required: false,
+ default: 'div',
+ },
+ },
+
+ computed: {
+ allSlotNames() {
+ return Object.keys(this.$slots);
+ },
+ },
+};
+</script>
+
+<template>
+ <component :is="tagName">
+ <template v-for="slotName in allSlotNames">
+ <slot v-if="activeSlotNames.includes(slotName)" :name="slotName"></slot>
+ </template>
+ </component>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
new file mode 100644
index 00000000000..f7dc00a345c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -0,0 +1,76 @@
+<script>
+import _ from 'underscore';
+
+import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
+
+const isValidItem = item =>
+ _.isString(item.eventName) && _.isString(item.title) && _.isString(item.description);
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownItem,
+ },
+
+ props: {
+ actionItems: {
+ type: Array,
+ required: true,
+ validator(value) {
+ return value.length > 1 && value.every(isValidItem);
+ },
+ },
+ menuClass: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ data() {
+ return {
+ selectedItem: this.actionItems[0],
+ };
+ },
+
+ computed: {
+ dropdownToggleText() {
+ return this.selectedItem.title;
+ },
+ },
+
+ methods: {
+ triggerEvent() {
+ this.$emit(this.selectedItem.eventName);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown
+ :menu-class="`dropdown-menu-selectable ${menuClass}`"
+ split
+ :text="dropdownToggleText"
+ v-bind="$attrs"
+ @click="triggerEvent"
+ >
+ <template v-for="(item, itemIndex) in actionItems">
+ <gl-dropdown-item
+ :key="item.eventName"
+ :active="selectedItem === item"
+ active-class="is-active"
+ @click="selectedItem = item"
+ >
+ <strong>{{ item.title }}</strong>
+ <div>{{ item.description }}</div>
+ </gl-dropdown-item>
+
+ <gl-dropdown-divider
+ v-if="itemIndex < actionItems.length - 1"
+ :key="`${item.eventName}-divider`"
+ />
+ </template>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
index 8bcad7ac765..43935cf31d5 100644
--- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
+++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue
@@ -32,7 +32,7 @@ export default {
</script>
<template>
<time
- v-gl-tooltip="{ placement: tooltipPlacement }"
+ v-gl-tooltip.viewport="{ placement: tooltipPlacement }"
:class="cssClass"
:title="tooltipTitle(time)"
v-text="timeFormated(time)"
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 7c7d46ee759..4a72cca5f02 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -51,7 +51,7 @@ export default {
</script>
<template>
- <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show>
<div class="user-popover d-flex">
<div class="p-1 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
@@ -90,7 +90,7 @@ export default {
name="location"
class="category-icon flex-shrink-0"
/>
- <span class="ml-1">{{ user.location }}</span>
+ <span v-if="user.location" class="ml-1">{{ user.location }}</span>
<gl-skeleton-loading
v-if="locationIsLoading"
:lines="1"
diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js
index 7a60ab1380f..044d703630e 100644
--- a/app/assets/javascripts/zen_mode.js
+++ b/app/assets/javascripts/zen_mode.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */
+/* eslint-disable consistent-return, camelcase, class-methods-use-this */
// Zen Mode (full screen) textarea
//
@@ -47,26 +47,16 @@ export default class ZenMode {
e.preventDefault();
return $(e.currentTarget).trigger('zen_mode:leave');
});
- $(document).on(
- 'zen_mode:enter',
- (function(_this) {
- return function(e) {
- return _this.enter(
- $(e.target)
- .closest('.md-area')
- .find('.zen-backdrop'),
- );
- };
- })(this),
- );
- $(document).on(
- 'zen_mode:leave',
- (function(_this) {
- return function() {
- return _this.exit();
- };
- })(this),
- );
+ $(document).on('zen_mode:enter', e => {
+ this.enter(
+ $(e.target)
+ .closest('.md-area')
+ .find('.zen-backdrop'),
+ );
+ });
+ $(document).on('zen_mode:leave', () => {
+ this.exit();
+ });
$(document).on('keydown', e => {
// Esc
if (e.keyCode === 27) {