summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-01-21 14:21:10 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-01-21 14:21:10 +0000
commitcb0d23c455b73486fd1015f8ca9479b5b7e3585d (patch)
treed7dc129a407fd74266d2dc561bebf24665197c2f /app
parentc3e911be175c0aabfea1eb030f9e0ef23f5f3887 (diff)
downloadgitlab-ce-cb0d23c455b73486fd1015f8ca9479b5b7e3585d.tar.gz
Add latest changes from gitlab-org/gitlab@12-7-stable-ee
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/ext_snippet_icons/ext_snippet_icons.pngbin1018 -> 1319 bytes
-rw-r--r--app/assets/images/ext_snippet_icons/logo.pngbin494 -> 0 bytes
-rw-r--r--app/assets/images/ext_snippet_icons/logo.svg1
-rw-r--r--app/assets/javascripts/api.js15
-rw-r--r--app/assets/javascripts/awards_handler.js6
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/paste_markdown_table.js122
-rw-r--r--app/assets/javascripts/boards/components/board.js9
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue2
-rw-r--r--app/assets/javascripts/boards/components/boards_selector.vue2
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue6
-rw-r--r--app/assets/javascripts/breakpoints.js22
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js3
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue7
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue97
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js13
-rw-r--r--app/assets/javascripts/commit/image_file.js3
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.vue2
-rw-r--r--app/assets/javascripts/contextual_sidebar.js17
-rw-r--r--app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue (renamed from app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue)0
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue11
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue3
-rw-r--r--app/assets/javascripts/create_cluster/eks_cluster/store/index.js2
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue53
-rw-r--r--app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue44
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js)0
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js)0
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js)0
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js)0
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js)0
-rw-r--r--app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js (renamed from app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js)0
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_store.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue5
-rw-r--r--app/assets/javascripts/diffs/components/app.vue79
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue2
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue18
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue19
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue23
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue2
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue9
-rw-r--r--app/assets/javascripts/diffs/store/actions.js62
-rw-r--r--app/assets/javascripts/diffs/store/getters.js2
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js1
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js95
-rw-r--r--app/assets/javascripts/diffs/store/utils.js287
-rw-r--r--app/assets/javascripts/droplab/drop_down.js5
-rw-r--r--app/assets/javascripts/dropzone_input.js24
-rw-r--r--app/assets/javascripts/due_date_select.js4
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue190
-rw-r--r--app/assets/javascripts/environments/components/environment_pin.vue37
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue2
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue21
-rw-r--r--app/assets/javascripts/environments/components/stop_environment_modal.vue2
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js20
-rw-r--r--app/assets/javascripts/error_tracking/components/error_details.vue201
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue299
-rw-r--r--app/assets/javascripts/error_tracking/components/stacktrace_entry.vue39
-rw-r--r--app/assets/javascripts/error_tracking/details.js23
-rw-r--r--app/assets/javascripts/error_tracking/list.js10
-rw-r--r--app/assets/javascripts/error_tracking/queries/details.query.graphql20
-rw-r--r--app/assets/javascripts/error_tracking/services/index.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js19
-rw-r--r--app/assets/javascripts/error_tracking/store/details/state.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/index.js11
-rw-r--r--app/assets/javascripts/error_tracking/store/list/actions.js10
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutation_types.js1
-rw-r--r--app/assets/javascripts/error_tracking/store/list/mutations.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/list/state.js1
-rw-r--r--app/assets/javascripts/error_tracking/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/error_tracking/store/mutations.js10
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue5
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js4
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight_options.js4
-rw-r--r--app/assets/javascripts/filterable_list.js2
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js2
-rw-r--r--app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js4
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js6
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue2
-rw-r--r--app/assets/javascripts/filtered_search/constants.js6
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js41
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_operator.js65
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js38
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js20
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js47
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js161
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js7
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_tokenizer.js17
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js161
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js182
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js3
-rw-r--r--app/assets/javascripts/fly_out_nav.js7
-rw-r--r--app/assets/javascripts/frequent_items/utils.js8
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js11
-rw-r--r--app/assets/javascripts/grafana_integration/components/grafana_integration.vue3
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue2
-rw-r--r--app/assets/javascripts/helpers/diffs_helper.js19
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue32
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/form.vue44
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue8
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue78
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue41
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue15
-rw-r--r--app/assets/javascripts/ide/components/ide.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue2
-rw-r--r--app/assets/javascripts/ide/components/nav_dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue25
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue2
-rw-r--r--app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue151
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue103
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue4
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue15
-rw-r--r--app/assets/javascripts/ide/components/repo_tabs.vue2
-rw-r--r--app/assets/javascripts/ide/index.js1
-rw-r--r--app/assets/javascripts/ide/lib/editor.js14
-rw-r--r--app/assets/javascripts/ide/stores/actions.js181
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js76
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js15
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js11
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js11
-rw-r--r--app/assets/javascripts/ide/stores/getters.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/pane/actions.js8
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js64
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue2
-rw-r--r--app/assets/javascripts/issuable_context.js6
-rw-r--r--app/assets/javascripts/issuable_form.js17
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue5
-rw-r--r--app/assets/javascripts/jobs/components/log/log.vue2
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue2
-rw-r--r--app/assets/javascripts/jobs/svg/scroll_down.svg7
-rw-r--r--app/assets/javascripts/labels_select.js5
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js17
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js74
-rw-r--r--app/assets/javascripts/lib/utils/keycodes.js1
-rw-r--r--app/assets/javascripts/lib/utils/poll_until_complete.js42
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js28
-rw-r--r--app/assets/javascripts/main.js21
-rw-r--r--app/assets/javascripts/merge_request_tabs.js4
-rw-r--r--app/assets/javascripts/milestone_select.js7
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue3
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue106
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue139
-rw-r--r--app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue143
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue138
-rw-r--r--app/assets/javascripts/monitoring/components/embed.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/shared/prometheus_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js26
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js38
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue2
-rw-r--r--app/assets/javascripts/mr_tabs_popover/components/popover.vue7
-rw-r--r--app/assets/javascripts/notes.js3
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue1
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue5
-rw-r--r--app/assets/javascripts/notifications_dropdown.js4
-rw-r--r--app/assets/javascripts/notifications_form.js2
-rw-r--r--app/assets/javascripts/pages/admin/admin.js2
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js31
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/index/index.js15
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue37
-rw-r--r--app/assets/javascripts/pages/projects/wikis/wikis.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue3
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue6
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue34
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue49
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue52
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue84
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue104
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js10
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js8
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js5
-rw-r--r--app/assets/javascripts/project_find_file.js35
-rw-r--r--app/assets/javascripts/projects/project_import_gitlab_project.js42
-rw-r--r--app/assets/javascripts/projects/project_new.js58
-rw-r--r--app/assets/javascripts/registry/list/components/collapsible_container.vue9
-rw-r--r--app/assets/javascripts/registry/settings/components/registry_settings_app.vue42
-rw-r--r--app/assets/javascripts/registry/settings/components/settings_form.vue178
-rw-r--r--app/assets/javascripts/registry/settings/constants.js15
-rw-r--r--app/assets/javascripts/registry/settings/registry_settings_bundle.js2
-rw-r--r--app/assets/javascripts/registry/settings/store/actions.js42
-rw-r--r--app/assets/javascripts/registry/settings/store/getters.js8
-rw-r--r--app/assets/javascripts/registry/settings/store/index.js (renamed from app/assets/javascripts/registry/settings/stores/index.js)2
-rw-r--r--app/assets/javascripts/registry/settings/store/mutation_types.js5
-rw-r--r--app/assets/javascripts/registry/settings/store/mutations.js25
-rw-r--r--app/assets/javascripts/registry/settings/store/state.js30
-rw-r--r--app/assets/javascripts/registry/settings/stores/actions.js6
-rw-r--r--app/assets/javascripts/registry/settings/stores/mutation_types.js4
-rw-r--r--app/assets/javascripts/registry/settings/stores/mutations.js8
-rw-r--r--app/assets/javascripts/registry/settings/stores/state.js10
-rw-r--r--app/assets/javascripts/registry/settings/utils.js6
-rw-r--r--app/assets/javascripts/releases/list/components/app.vue2
-rw-r--r--app/assets/javascripts/releases/list/components/release_block.vue184
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_assets.vue65
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_author.vue42
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_header.vue47
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_metadata.vue84
-rw-r--r--app/assets/javascripts/releases/list/components/release_block_milestones.vue51
-rw-r--r--app/assets/javascripts/reports/components/modal.vue2
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue20
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue8
-rw-r--r--app/assets/javascripts/repository/components/preview/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue13
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue25
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue16
-rw-r--r--app/assets/javascripts/repository/components/tree_content.vue18
-rw-r--r--app/assets/javascripts/repository/index.js1
-rw-r--r--app/assets/javascripts/repository/mixins/preload.js36
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue4
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.query.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql1
-rw-r--r--app/assets/javascripts/repository/utils/readme.js45
-rw-r--r--app/assets/javascripts/self_monitor/components/self_monitor_form.vue160
-rw-r--r--app/assets/javascripts/self_monitor/index.js23
-rw-r--r--app/assets/javascripts/self_monitor/store/actions.js126
-rw-r--r--app/assets/javascripts/self_monitor/store/index.js21
-rw-r--r--app/assets/javascripts/self_monitor/store/mutation_types.js6
-rw-r--r--app/assets/javascripts/self_monitor/store/mutations.js22
-rw-r--r--app/assets/javascripts/self_monitor/store/state.js15
-rw-r--r--app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue43
-rw-r--r--app/assets/javascripts/sentry_error_stack_trace/index.js22
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/participants/participants.vue8
-rw-r--r--app/assets/javascripts/snippets/components/app.vue7
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue35
-rw-r--r--app/assets/javascripts/tree.js4
-rw-r--r--app/assets/javascripts/users_select.js1142
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue18
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue7
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue11
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue20
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue3
-rw-r--r--app/assets/javascripts/vue_shared/components/split_button.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue9
-rw-r--r--app/assets/javascripts/vuex_shared/bindings.js36
-rw-r--r--app/assets/stylesheets/disable_animations.scss (renamed from app/assets/stylesheets/test.scss)0
-rw-r--r--app/assets/stylesheets/framework/broadcast_messages.scss28
-rw-r--r--app/assets/stylesheets/framework/buttons.scss2
-rw-r--r--app/assets/stylesheets/framework/common.scss7
-rw-r--r--app/assets/stylesheets/framework/files.scss8
-rw-r--r--app/assets/stylesheets/framework/filters.scss16
-rw-r--r--app/assets/stylesheets/framework/highlight.scss2
-rw-r--r--app/assets/stylesheets/framework/icons.scss1
-rw-r--r--app/assets/stylesheets/framework/job_log.scss1
-rw-r--r--app/assets/stylesheets/framework/lists.scss7
-rw-r--r--app/assets/stylesheets/framework/mixins.scss15
-rw-r--r--app/assets/stylesheets/framework/typography.scss10
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss1
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss5
-rw-r--r--app/assets/stylesheets/pages/boards.scss14
-rw-r--r--app/assets/stylesheets/pages/builds.scss8
-rw-r--r--app/assets/stylesheets/pages/diff.scss15
-rw-r--r--app/assets/stylesheets/pages/error_details.scss5
-rw-r--r--app/assets/stylesheets/pages/error_list.scss69
-rw-r--r--app/assets/stylesheets/pages/issuable.scss30
-rw-r--r--app/assets/stylesheets/pages/members.scss2
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss5
-rw-r--r--app/assets/stylesheets/pages/status.scss1
-rw-r--r--app/assets/stylesheets/snippets.scss38
-rw-r--r--app/assets/stylesheets/utilities.scss9
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss3
-rw-r--r--app/controllers/admin/application_settings_controller.rb112
-rw-r--r--app/controllers/admin/system_info_controller.rb8
-rw-r--r--app/controllers/application_controller.rb9
-rw-r--r--app/controllers/boards/issues_controller.rb31
-rw-r--r--app/controllers/clusters/applications_controller.rb2
-rw-r--r--app/controllers/clusters/clusters_controller.rb1
-rw-r--r--app/controllers/concerns/record_user_last_activity.rb2
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb2
-rw-r--r--app/controllers/concerns/sourcegraph_decorator.rb (renamed from app/controllers/concerns/sourcegraph_gon.rb)11
-rw-r--r--app/controllers/concerns/spammable_actions.rb2
-rw-r--r--app/controllers/graphql_controller.rb1
-rw-r--r--app/controllers/groups/group_links_controller.rb26
-rw-r--r--app/controllers/groups/group_members_controller.rb41
-rw-r--r--app/controllers/groups/milestones_controller.rb4
-rw-r--r--app/controllers/ide_controller.rb4
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb4
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb9
-rw-r--r--app/controllers/profiles/preferences_controller.rb3
-rw-r--r--app/controllers/projects/blame_controller.rb1
-rw-r--r--app/controllers/projects/blob_controller.rb3
-rw-r--r--app/controllers/projects/ci/lints_controller.rb4
-rw-r--r--app/controllers/projects/commit_controller.rb4
-rw-r--r--app/controllers/projects/compare_controller.rb1
-rw-r--r--app/controllers/projects/environments/sample_metrics_controller.rb2
-rw-r--r--app/controllers/projects/environments_controller.rb11
-rw-r--r--app/controllers/projects/error_tracking/base_controller.rb9
-rw-r--r--app/controllers/projects/error_tracking/projects_controller.rb41
-rw-r--r--app/controllers/projects/error_tracking/stack_traces_controller.rb37
-rw-r--r--app/controllers/projects/error_tracking_controller.rb85
-rw-r--r--app/controllers/projects/forks_controller.rb3
-rw-r--r--app/controllers/projects/git_http_client_controller.rb17
-rw-r--r--app/controllers/projects/git_http_controller.rb17
-rw-r--r--app/controllers/projects/issues_controller.rb4
-rw-r--r--app/controllers/projects/jobs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb7
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb9
-rw-r--r--app/controllers/projects/pages_controller.rb10
-rw-r--r--app/controllers/projects/performance_monitoring/dashboards_controller.rb59
-rw-r--r--app/controllers/projects/pipelines_controller.rb6
-rw-r--r--app/controllers/projects/prometheus/metrics_controller.rb2
-rw-r--r--app/controllers/projects/raw_controller.rb12
-rw-r--r--app/controllers/projects/releases_controller.rb2
-rw-r--r--app/controllers/projects/snippets_controller.rb23
-rw-r--r--app/controllers/projects/starrers_controller.rb4
-rw-r--r--app/controllers/projects/tree_controller.rb7
-rw-r--r--app/controllers/projects/uploads_controller.rb10
-rw-r--r--app/controllers/projects/wikis_controller.rb4
-rw-r--r--app/controllers/projects_controller.rb17
-rw-r--r--app/controllers/registrations_controller.rb10
-rw-r--r--app/controllers/search_controller.rb16
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/controllers/snippets_controller.rb21
-rw-r--r--app/finders/deployments_finder.rb20
-rw-r--r--app/finders/environments_finder.rb40
-rw-r--r--app/finders/events_finder.rb12
-rw-r--r--app/finders/group_members_finder.rb46
-rw-r--r--app/finders/issuable_finder.rb2
-rw-r--r--app/finders/merge_requests_finder.rb12
-rw-r--r--app/finders/pipelines_finder.rb2
-rw-r--r--app/finders/sentry_issue_finder.rb23
-rw-r--r--app/graphql/gitlab_schema.rb55
-rw-r--r--app/graphql/mutations/award_emojis/toggle.rb7
-rw-r--r--app/graphql/mutations/snippets/create.rb5
-rw-r--r--app/graphql/mutations/snippets/destroy.rb4
-rw-r--r--app/graphql/mutations/snippets/mark_as_spam.rb2
-rw-r--r--app/graphql/mutations/snippets/update.rb8
-rw-r--r--app/graphql/resolvers/base_resolver.rb14
-rw-r--r--app/graphql/resolvers/environments_resolver.rb23
-rw-r--r--app/graphql/resolvers/projects/grafana_integration_resolver.rb17
-rw-r--r--app/graphql/types/award_emojis/award_emoji_type.rb1
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb26
-rw-r--r--app/graphql/types/ci/pipeline_type.rb47
-rw-r--r--app/graphql/types/environment_type.rb16
-rw-r--r--app/graphql/types/error_tracking/sentry_detailed_error_type.rb6
-rw-r--r--app/graphql/types/grafana_integration_type.rb23
-rw-r--r--app/graphql/types/group_type.rb3
-rw-r--r--app/graphql/types/issue_type.rb2
-rw-r--r--app/graphql/types/permission_types/base_permission_type.rb2
-rw-r--r--app/graphql/types/project_type.rb18
-rw-r--r--app/graphql/types/tree/blob_type.rb11
-rw-r--r--app/graphql/types/tree/entry_type.rb18
-rw-r--r--app/graphql/types/tree/submodule_type.rb6
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb3
-rw-r--r--app/graphql/types/tree/tree_type.rb26
-rw-r--r--app/helpers/application_helper.rb2
-rw-r--r--app/helpers/application_settings_helper.rb23
-rw-r--r--app/helpers/broadcast_messages_helper.rb24
-rw-r--r--app/helpers/ci_status_helper.rb4
-rw-r--r--app/helpers/container_expiration_policies_helper.rb12
-rw-r--r--app/helpers/dashboard_helper.rb6
-rw-r--r--app/helpers/environments_helper.rb2
-rw-r--r--app/helpers/gitlab_routing_helper.rb23
-rw-r--r--app/helpers/groups/group_members_helper.rb4
-rw-r--r--app/helpers/ide_helper.rb3
-rw-r--r--app/helpers/markup_helper.rb7
-rw-r--r--app/helpers/projects/error_tracking_helper.rb8
-rw-r--r--app/helpers/projects_helper.rb2
-rw-r--r--app/helpers/search_helper.rb2
-rw-r--r--app/helpers/selects_helper.rb3
-rw-r--r--app/helpers/snippets_helper.rb2
-rw-r--r--app/helpers/user_callouts_helper.rb2
-rw-r--r--app/mailers/emails/projects.rb2
-rw-r--r--app/mailers/notify.rb4
-rw-r--r--app/models/active_session.rb33
-rw-r--r--app/models/appearance.rb5
-rw-r--r--app/models/application_setting.rb12
-rw-r--r--app/models/blob.rb1
-rw-r--r--app/models/blob_viewer/cargo_toml.rb17
-rw-r--r--app/models/board.rb2
-rw-r--r--app/models/ci/bridge.rb7
-rw-r--r--app/models/ci/build.rb71
-rw-r--r--app/models/ci/job_artifact.rb11
-rw-r--r--app/models/ci/pipeline.rb73
-rw-r--r--app/models/ci/pipeline_config.rb14
-rw-r--r--app/models/ci/pipeline_enums.rb8
-rw-r--r--app/models/ci/pipeline_schedule.rb10
-rw-r--r--app/models/ci/processable.rb51
-rw-r--r--app/models/ci/resource.rb13
-rw-r--r--app/models/ci/resource_group.rb39
-rw-r--r--app/models/ci/sources/pipeline.rb2
-rw-r--r--app/models/ci/stage.rb17
-rw-r--r--app/models/ci/trigger.rb13
-rw-r--r--app/models/clusters/applications/elastic_stack.rb33
-rw-r--r--app/models/clusters/applications/ingress.rb9
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/prometheus.rb4
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/concerns/application_core.rb2
-rw-r--r--app/models/commit_status.rb85
-rw-r--r--app/models/concerns/atomic_internal_id.rb6
-rw-r--r--app/models/concerns/awardable.rb4
-rw-r--r--app/models/concerns/ci/processable.rb41
-rw-r--r--app/models/concerns/has_status.rb18
-rw-r--r--app/models/concerns/issuable.rb37
-rw-r--r--app/models/concerns/milestoneable.rb62
-rw-r--r--app/models/concerns/project_features_compatibility.rb4
-rw-r--r--app/models/concerns/protected_ref.rb2
-rw-r--r--app/models/concerns/reactive_caching.rb83
-rw-r--r--app/models/concerns/referable.rb2
-rw-r--r--app/models/concerns/schedulable.rb21
-rw-r--r--app/models/concerns/sha256_attribute.rb6
-rw-r--r--app/models/concerns/sha_attribute.rb6
-rw-r--r--app/models/concerns/taskable.rb10
-rw-r--r--app/models/container_expiration_policy.rb11
-rw-r--r--app/models/deployment.rb26
-rw-r--r--app/models/deployment_metrics.rb18
-rw-r--r--app/models/diff_note.rb40
-rw-r--r--app/models/diff_viewer/base.rb5
-rw-r--r--app/models/diff_viewer/collapsed.rb10
-rw-r--r--app/models/environment.rb33
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb70
-rw-r--r--app/models/event_collection.rb11
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/group_group_link.rb4
-rw-r--r--app/models/import_failure.rb4
-rw-r--r--app/models/issue.rb9
-rw-r--r--app/models/issue_milestone.rb6
-rw-r--r--app/models/key.rb8
-rw-r--r--app/models/merge_request.rb37
-rw-r--r--app/models/merge_request_diff.rb2
-rw-r--r--app/models/merge_request_milestone.rb6
-rw-r--r--app/models/milestone.rb8
-rw-r--r--app/models/namespace.rb8
-rw-r--r--app/models/note.rb16
-rw-r--r--app/models/project.rb30
-rw-r--r--app/models/project_ci_cd_setting.rb1
-rw-r--r--app/models/project_feature.rb17
-rw-r--r--app/models/project_group_link.rb2
-rw-r--r--app/models/project_services/chat_message/base_message.rb8
-rw-r--r--app/models/project_services/chat_message/wiki_page_message.rb2
-rw-r--r--app/models/project_services/emails_on_push_service.rb20
-rw-r--r--app/models/project_services/external_wiki_service.rb15
-rw-r--r--app/models/project_wiki.rb3
-rw-r--r--app/models/release.rb9
-rw-r--r--app/models/repository.rb26
-rw-r--r--app/models/resource_weight_event.rb26
-rw-r--r--app/models/sentry_issue.rb10
-rw-r--r--app/models/snippet.rb4
-rw-r--r--app/models/user.rb18
-rw-r--r--app/models/user_preference.rb1
-rw-r--r--app/policies/ci/trigger_policy.rb3
-rw-r--r--app/policies/grafana_integration_policy.rb5
-rw-r--r--app/policies/project_policy.rb11
-rw-r--r--app/policies/user_policy.rb2
-rw-r--r--app/presenters/ci/bridge_presenter.rb2
-rw-r--r--app/presenters/ci/build_presenter.rb2
-rw-r--r--app/presenters/ci/build_runner_presenter.rb15
-rw-r--r--app/presenters/ci/processable_presenter.rb6
-rw-r--r--app/presenters/clusters/cluster_presenter.rb8
-rw-r--r--app/presenters/project_presenter.rb44
-rw-r--r--app/serializers/build_artifact_entity.rb7
-rw-r--r--app/serializers/cluster_application_entity.rb2
-rw-r--r--app/serializers/deploy_key_entity.rb1
-rw-r--r--app/serializers/error_tracking/detailed_error_entity.rb3
-rw-r--r--app/serializers/pipeline_details_entity.rb2
-rw-r--r--app/serializers/pipeline_entity.rb8
-rw-r--r--app/serializers/pipeline_serializer.rb1
-rw-r--r--app/serializers/review_app_setup_entity.rb25
-rw-r--r--app/serializers/review_app_setup_serializer.rb5
-rw-r--r--app/serializers/suggestion_entity.rb4
-rw-r--r--app/services/akismet_service.rb17
-rw-r--r--app/services/boards/issues/list_service.rb4
-rw-r--r--app/services/boards/list_service.rb17
-rw-r--r--app/services/ci/create_pipeline_service.rb7
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service.rb118
-rw-r--r--app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb135
-rw-r--r--app/services/ci/pipeline_processing/legacy_processing_service.rb119
-rw-r--r--app/services/ci/prepare_build_service.rb2
-rw-r--r--app/services/ci/process_pipeline_service.rb108
-rw-r--r--app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb17
-rw-r--r--app/services/ci/retry_build_service.rb15
-rw-r--r--app/services/clusters/applications/base_service.rb10
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb2
-rw-r--r--app/services/clusters/kubernetes/create_or_update_namespace_service.rb5
-rw-r--r--app/services/clusters/kubernetes/create_or_update_service_account_service.rb11
-rw-r--r--app/services/concerns/akismet_methods.rb25
-rw-r--r--app/services/concerns/spam_check_methods.rb (renamed from app/services/spam_check_service.rb)8
-rw-r--r--app/services/container_expiration_policy_service.rb15
-rw-r--r--app/services/create_snippet_service.rb35
-rw-r--r--app/services/deployments/after_create_service.rb9
-rw-r--r--app/services/deployments/link_merge_requests_service.rb5
-rw-r--r--app/services/error_tracking/issue_update_service.rb22
-rw-r--r--app/services/event_create_service.rb2
-rw-r--r--app/services/git/base_hooks_service.rb2
-rw-r--r--app/services/ham_service.rb4
-rw-r--r--app/services/issuable/clone/attributes_rewriter.rb15
-rw-r--r--app/services/issues/create_service.rb2
-rw-r--r--app/services/issues/update_service.rb2
-rw-r--r--app/services/members/update_service.rb3
-rw-r--r--app/services/merge_requests/get_urls_service.rb2
-rw-r--r--app/services/merge_requests/rebase_service.rb5
-rw-r--r--app/services/metrics/dashboard/clone_dashboard_service.rb113
-rw-r--r--app/services/metrics/sample_metrics_service.rb16
-rw-r--r--app/services/notes/create_service.rb14
-rw-r--r--app/services/notes/destroy_service.rb2
-rw-r--r--app/services/pages_domains/create_acme_order_service.rb5
-rw-r--r--app/services/projects/operations/update_service.rb2
-rw-r--r--app/services/projects/update_pages_service.rb13
-rw-r--r--app/services/prometheus/adapter_service.rb35
-rw-r--r--app/services/prometheus/proxy_service.rb12
-rw-r--r--app/services/prometheus/proxy_variable_substitution_service.rb55
-rw-r--r--app/services/quick_actions/interpret_service.rb4
-rw-r--r--app/services/releases/update_service.rb13
-rw-r--r--app/services/resource_events/base_synthetic_notes_builder_service.rb39
-rw-r--r--app/services/resource_events/merge_into_notes_service.rb43
-rw-r--r--app/services/resource_events/synthetic_label_notes_builder_service.rb27
-rw-r--r--app/services/search_service.rb11
-rw-r--r--app/services/snippets/base_service.rb15
-rw-r--r--app/services/snippets/create_service.rb40
-rw-r--r--app/services/snippets/destroy_service.rb48
-rw-r--r--app/services/snippets/update_service.rb36
-rw-r--r--app/services/spam/mark_as_spam_service.rb24
-rw-r--r--app/services/spam_service.rb35
-rw-r--r--app/services/suggestions/apply_service.rb37
-rw-r--r--app/services/system_hooks_service.rb2
-rw-r--r--app/services/system_note_service.rb29
-rw-r--r--app/services/system_notes/time_tracking_service.rb73
-rw-r--r--app/services/template_engines/liquid_service.rb48
-rw-r--r--app/services/update_snippet_service.rb36
-rw-r--r--app/services/users/activity_service.rb3
-rw-r--r--app/services/users/build_service.rb8
-rw-r--r--app/services/users/destroy_service.rb7
-rw-r--r--app/services/users/update_service.rb10
-rw-r--r--app/uploaders/avatar_uploader.rb3
-rw-r--r--app/uploaders/favicon_uploader.rb4
-rw-r--r--app/uploaders/upload_type_check.rb98
-rw-r--r--app/validators/key_restriction_validator.rb3
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml1
-rw-r--r--app/views/admin/application_settings/_gitaly.html.haml3
-rw-r--r--app/views/admin/application_settings/_pages.html.haml9
-rw-r--r--app/views/admin/application_settings/_signup.html.haml2
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml3
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml27
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml5
-rw-r--r--app/views/admin/hooks/index.html.haml2
-rw-r--r--app/views/admin/runners/index.html.haml20
-rw-r--r--app/views/admin/users/_user.html.haml4
-rw-r--r--app/views/admin/users/index.html.haml2
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml2
-rw-r--r--app/views/clusters/clusters/aws/_new.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml1
-rw-r--r--app/views/dashboard/projects/_projects.html.haml2
-rw-r--r--app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml16
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/errors/_footer.html.haml2
-rw-r--r--app/views/explore/projects/_projects.html.haml2
-rw-r--r--app/views/groups/edit.html.haml2
-rw-r--r--app/views/groups/group_members/_new_group_member.html.haml22
-rw-r--r--app/views/groups/group_members/index.html.haml42
-rw-r--r--app/views/groups/settings/_pages_settings.html.haml5
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml4
-rw-r--r--app/views/import/github/new.html.haml4
-rw-r--r--app/views/layouts/_broadcast.html.haml4
-rw-r--r--app/views/layouts/_flash.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/application.html.haml5
-rw-r--r--app/views/layouts/header/_current_user_dropdown.html.haml2
-rw-r--r--app/views/layouts/instance_statistics.html.haml4
-rw-r--r--app/views/layouts/nav/_analytics_link.html.haml4
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml9
-rw-r--r--app/views/layouts/nav/sidebar/_analytics_link.html.haml4
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_instance_statistics.html.haml29
-rw-r--r--app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml25
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/profiles/_name.html.haml5
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml6
-rw-r--r--app/views/profiles/notifications/show.html.haml4
-rw-r--r--app/views/profiles/preferences/show.html.haml6
-rw-r--r--app/views/profiles/show.html.haml6
-rw-r--r--app/views/projects/_merge_request_merge_suggestions_settings.html.haml17
-rw-r--r--app/views/projects/_merge_request_settings.html.haml2
-rw-r--r--app/views/projects/_merge_request_settings_description_text.html.haml2
-rw-r--r--app/views/projects/blame/show.html.haml10
-rw-r--r--app/views/projects/blob/_render_error.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_contributing.html.haml2
-rw-r--r--app/views/projects/ci/lints/_create.html.haml21
-rw-r--r--app/views/projects/commits/_commit.html.haml4
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml24
-rw-r--r--app/views/projects/diffs/_viewer.html.haml2
-rw-r--r--app/views/projects/diffs/viewers/_collapsed.html.haml (renamed from app/views/projects/diffs/_collapsed.html.haml)0
-rw-r--r--app/views/projects/environments/_pin_button.html.haml3
-rw-r--r--app/views/projects/environments/show.html.haml11
-rw-r--r--app/views/projects/graphs/charts.html.haml3
-rw-r--r--app/views/projects/issues/show.html.haml3
-rw-r--r--app/views/projects/pages/_https_only.html.haml11
-rw-r--r--app/views/projects/pages/_pages_settings.html.haml13
-rw-r--r--app/views/projects/pages/_ssl_limitations_warning.html.haml7
-rw-r--r--app/views/projects/pages/show.html.haml5
-rw-r--r--app/views/projects/project_members/_groups.html.haml4
-rw-r--r--app/views/projects/project_members/_team.html.haml2
-rw-r--r--app/views/projects/project_members/index.html.haml8
-rw-r--r--app/views/projects/registry/settings/_index.haml6
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml14
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml2
-rw-r--r--app/views/projects/triggers/_content.html.haml9
-rw-r--r--app/views/projects/triggers/_index.html.haml1
-rw-r--r--app/views/projects/triggers/_trigger.html.haml7
-rw-r--r--app/views/projects/triggers/edit.html.haml4
-rw-r--r--app/views/registrations/welcome.html.haml6
-rw-r--r--app/views/shared/_auto_devops_callout.html.haml15
-rw-r--r--app/views/shared/boards/components/_board.html.haml5
-rw-r--r--app/views/shared/file_hooks/_index.html.haml24
-rw-r--r--app/views/shared/form_elements/_description.html.haml4
-rw-r--r--app/views/shared/issuable/_form.html.haml1
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml22
-rw-r--r--app/views/shared/issuable/form/_template_selector.html.haml2
-rw-r--r--app/views/shared/members/_group.html.haml9
-rw-r--r--app/views/shared/members/_invite_group.html.haml (renamed from app/views/projects/project_members/_new_project_group.html.haml)15
-rw-r--r--app/views/shared/members/_invite_member.html.haml (renamed from app/views/projects/project_members/_new_project_member.html.haml)17
-rw-r--r--app/views/shared/plugins/_index.html.haml23
-rw-r--r--app/views/shared/projects/_list.html.haml3
-rw-r--r--app/views/shared/snippets/_embed.html.haml6
-rw-r--r--app/workers/all_queues.yml6
-rw-r--r--app/workers/chat_notification_worker.rb16
-rw-r--r--app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb20
-rw-r--r--app/workers/concerns/cluster_queue.rb2
-rw-r--r--app/workers/concerns/reenqueuer.rb101
-rw-r--r--app/workers/concerns/self_monitoring_project_worker.rb36
-rw-r--r--app/workers/concerns/worker_attributes.rb (renamed from app/models/concerns/worker_attributes.rb)0
-rw-r--r--app/workers/container_expiration_policy_worker.rb16
-rw-r--r--app/workers/deployments/finished_worker.rb5
-rw-r--r--app/workers/file_hook_worker.rb (renamed from app/workers/plugin_worker.rb)6
-rw-r--r--app/workers/group_destroy_worker.rb2
-rw-r--r--app/workers/pipeline_update_worker.rb5
-rw-r--r--app/workers/rebase_worker.rb4
-rw-r--r--app/workers/self_monitoring_project_create_worker.rb13
-rw-r--r--app/workers/self_monitoring_project_delete_worker.rb13
-rw-r--r--app/workers/stage_update_worker.rb6
660 files changed, 9935 insertions, 4020 deletions
diff --git a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png
index 20380adc4e5..c864e558bfd 100644
--- a/app/assets/images/ext_snippet_icons/ext_snippet_icons.png
+++ b/app/assets/images/ext_snippet_icons/ext_snippet_icons.png
Binary files differ
diff --git a/app/assets/images/ext_snippet_icons/logo.png b/app/assets/images/ext_snippet_icons/logo.png
deleted file mode 100644
index 794c9cc2dbc..00000000000
--- a/app/assets/images/ext_snippet_icons/logo.png
+++ /dev/null
Binary files differ
diff --git a/app/assets/images/ext_snippet_icons/logo.svg b/app/assets/images/ext_snippet_icons/logo.svg
new file mode 100644
index 00000000000..9cb3042213a
--- /dev/null
+++ b/app/assets/images/ext_snippet_icons/logo.svg
@@ -0,0 +1 @@
+<svg width="100" height="32" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero" fill="none"><path fill="#8C929D" d="M67.67 8.11h-2.06l.009 15.364h8.348v-1.9H67.68l-.01-13.465zM81.913 20.778a3.517 3.517 0 01-2.553 1.078c-1.57 0-2.203-.775-2.203-1.787 0-1.522 1.059-2.25 3.309-2.25.487.002.974.04 1.456.113v2.846h-.01zm-2.137-9.313a6.826 6.826 0 00-4.387 1.579l.728 1.267c.841-.492 1.872-.983 3.356-.983 1.693 0 2.44.87 2.44 2.326v.747a9.4 9.4 0 00-1.428-.114c-3.612 0-5.446 1.267-5.446 3.914 0 2.374 1.456 3.565 3.659 3.565 1.484 0 2.912-.68 3.404-1.787l.378 1.503h1.456v-7.866c-.01-2.487-1.087-4.151-4.16-4.151zM90.587 21.926c-.776 0-1.456-.094-1.967-.33v-7.102c.7-.586 1.57-1.011 2.676-1.011 1.995 0 2.76 1.408 2.76 3.687 0 3.234-1.238 4.756-3.47 4.756m.87-10.457a3.775 3.775 0 00-2.836 1.257V10.74l-.01-2.629h-2.013l.01 14.987c1.01.425 2.391.652 3.895.652 3.848 0 5.701-2.458 5.701-6.704-.01-3.356-1.72-5.578-4.746-5.578M45.228 9.776c1.825 0 3.006.605 3.772 1.22l.889-1.541c-1.2-1.06-2.827-1.627-4.567-1.627-4.387 0-7.46 2.676-7.46 8.075 0 5.654 3.319 7.857 7.11 7.857a12.083 12.083 0 004.577-.888L49.5 16.83v-1.9h-5.63v1.9h3.594l.047 4.586c-.473.236-1.286.425-2.392.425-3.045 0-5.087-1.92-5.087-5.957-.01-4.113 2.1-6.108 5.19-6.108M59.744 8.107H57.73l.01 2.582v8.916c0 2.487 1.078 4.15 4.15 4.15.416.002.83-.036 1.24-.113v-1.806c-.31.047-.624.07-.937.066-1.692 0-2.44-.87-2.44-2.326v-6.145h3.376v-1.683h-3.373l-.009-3.64h-.003zM52.608 23.474h2.014V11.75h-2.014zM52.608 10.133h2.014V8.119h-2.014z"/><path d="M31.864 17.907l-1.788-5.496-3.538-10.9a.612.612 0 00-1.16 0L21.84 12.406H10.085L6.547 1.512a.612.612 0 00-1.16 0L1.855 12.405.066 17.907c-.162.5.015 1.05.44 1.36L15.963 30.5l15.456-11.233a1.22 1.22 0 00.446-1.36" fill="#FC6D26"/><path d="M15.966 30.49l5.875-18.086H10.09z" fill="#E24329"/><path d="M15.962 30.49l-5.877-18.086H1.859z" fill="#FC6D26"/><path d="M1.852 12.41L.063 17.906c-.162.5.015 1.05.441 1.36L15.959 30.5 1.852 12.41z" fill="#FCA326"/><path d="M1.854 12.41h8.237L6.546 1.517a.612.612 0 00-1.16 0L1.854 12.41z" fill="#E24329"/><path d="M15.966 30.49l5.875-18.086h8.236z" fill="#FC6D26"/><path d="M30.074 12.41l1.79 5.496a1.219 1.219 0 01-.44 1.36L15.966 30.49l14.107-18.08z" fill="#FCA326"/><path d="M30.079 12.41H21.84L25.38 1.517a.612.612 0 011.16 0l3.539 10.893z" fill="#E24329"/></g></svg> \ No newline at end of file
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 071ae8ca8cf..bee079c6643 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -54,10 +54,15 @@ const Api = {
});
},
- groupMembers(id) {
+ groupMembers(id, options) {
const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
- return axios.get(url);
+ return axios.get(url, {
+ params: {
+ per_page: DEFAULT_PER_PAGE,
+ ...options,
+ },
+ });
},
// Return groups list. Filtered by query
@@ -142,6 +147,12 @@ const Api = {
return axios.get(url);
},
+ // Update a single project
+ updateProject(projectPath, data) {
+ const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath));
+ return axios.put(url, data);
+ },
+
/**
* Get all projects for a forked relationship to a specified project
* @param {string} projectPath - Path or ID of a project
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index aaab217964c..0e403d023df 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -2,13 +2,13 @@
import $ from 'jquery';
import _ from 'underscore';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import { __ } from './locale';
import { updateTooltipTitle } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
-import bp from './breakpoints';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -266,7 +266,7 @@ export class AwardsHandler {
top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
};
// for xs screen we position the element on center
- if (bp.getBreakpointSize() === 'xs') {
+ if (bp.getBreakpointSize() === 'xs' || bp.getBreakpointSize() === 'sm') {
css.left = '5%';
} else if (position === 'right') {
css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`;
@@ -506,6 +506,8 @@ export class AwardsHandler {
const options = {
scrollTop: $('.awards').offset().top - 110,
};
+
+ // eslint-disable-next-line no-jquery/no-animate
return $('body, html').animate(options, 200);
}
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index 318b7f77c7b..03c1b5a0169 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -183,7 +183,7 @@ export class CopyAsGFM {
}
// Export CopyAsGFM as a global for rspec to access
-// see /spec/features/copy_as_gfm_spec.rb
+// see /spec/features/markdown/copy_as_gfm_spec.rb
if (process.env.NODE_ENV !== 'production') {
window.CopyAsGFM = CopyAsGFM;
}
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
index 8bd2145db1c..308e31e7047 100644
--- a/app/assets/javascripts/behaviors/markdown/editor_extensions.js
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -53,7 +53,7 @@ import InlineHTML from './marks/inline_html';
// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard.
// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
// from GFM should have a node or mark here.
-// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
+// The GFM-to-HTML-to-GFM cycle is tested in spec/features/markdown/copy_as_gfm_spec.rb.
export default [
new Doc(),
diff --git a/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
new file mode 100644
index 00000000000..665a7216424
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/paste_markdown_table.js
@@ -0,0 +1,122 @@
+const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length));
+
+export default class PasteMarkdownTable {
+ constructor(clipboardData) {
+ this.data = clipboardData;
+ this.columnWidths = [];
+ this.rows = [];
+ this.tableFound = this.parseTable();
+ }
+
+ isTable() {
+ return this.tableFound;
+ }
+
+ convertToTableMarkdown() {
+ this.calculateColumnWidths();
+
+ const markdownRows = this.rows.map(
+ row =>
+ // | Name | Title | Email Address |
+ // |--------------|-------|----------------|
+ // | Jane Atler | CEO | jane@acme.com |
+ // | John Doherty | CTO | john@acme.com |
+ // | Sally Smith | CFO | sally@acme.com |
+ `| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`,
+ );
+
+ // Insert a header break (e.g. -----) to the second row
+ markdownRows.splice(1, 0, this.generateHeaderBreak());
+
+ return markdownRows.join('\n');
+ }
+
+ // Private methods below
+
+ // To determine whether the cut data is a table, the following criteria
+ // must be satisfied with the clipboard data:
+ //
+ // 1. MIME types "text/plain" and "text/html" exist
+ // 2. The "text/html" data must have a single <table> element
+ // 3. The number of rows in the "text/plain" data matches that of the "text/html" data
+ // 4. The max number of columns in "text/plain" matches that of the "text/html" data
+ parseTable() {
+ if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) {
+ return false;
+ }
+
+ const htmlData = this.data.getData('text/html');
+ this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
+ const tables = this.doc.querySelectorAll('table');
+
+ // We're only looking for exactly one table. If there happens to be
+ // multiple tables, it's possible an application copied data into
+ // the clipboard that is not related to a simple table. It may also be
+ // complicated converting multiple tables into Markdown.
+ if (tables.length !== 1) {
+ return false;
+ }
+
+ const text = this.data.getData('text/plain').trim();
+ const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g);
+
+ // Now check that the number of rows matches between HTML and text
+ if (this.doc.querySelectorAll('tr').length !== splitRows.length) {
+ return false;
+ }
+
+ this.rows = splitRows.map(row => row.split('\t'));
+ this.normalizeRows();
+
+ // Check that the max number of columns in the HTML matches the number of
+ // columns in the text. GitHub, for example, copies a line number and the
+ // line itself into the HTML data.
+ if (!this.columnCountsMatch()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ // Ensure each row has the same number of columns
+ normalizeRows() {
+ const rowLengths = this.rows.map(row => row.length);
+ const maxLength = Math.max(...rowLengths);
+
+ this.rows.forEach(row => {
+ while (row.length < maxLength) {
+ row.push('');
+ }
+ });
+ }
+
+ calculateColumnWidths() {
+ this.columnWidths = this.rows[0].map((_column, columnIndex) =>
+ maxColumnWidth(this.rows, columnIndex),
+ );
+ }
+
+ columnCountsMatch() {
+ const textColumnCount = this.rows[0].length;
+ let htmlColumnCount = 0;
+
+ this.doc.querySelectorAll('table tr').forEach(row => {
+ htmlColumnCount = Math.max(row.cells.length, htmlColumnCount);
+ });
+
+ return textColumnCount === htmlColumnCount;
+ }
+
+ formatColumn(column, index) {
+ const spaces = Array(this.columnWidths[index] - column.length + 1).join(' ');
+ return column + spaces;
+ }
+
+ generateHeaderBreak() {
+ // Add 3 dashes to line things up: there is additional spacing for the pipe characters
+ const dashes = this.columnWidths.map((width, index) =>
+ Array(this.columnWidths[index] + 3).join('-'),
+ );
+ return `|${dashes.join('|')}|`;
+ }
+}
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 8ebdfede8f7..a6deb656b37 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -3,7 +3,7 @@ import Sortable from 'sortablejs';
import Vue from 'vue';
import { GlButtonGroup, GlButton, GlTooltip } from '@gitlab/ui';
import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits';
-import { n__, s__ } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import AccessorUtilities from '../../lib/utils/accessor';
@@ -67,10 +67,13 @@ export default Vue.extend({
!this.disabled && this.list.type !== ListType.closed && this.list.type !== ListType.blank
);
},
- counterTooltip() {
+ issuesTooltip() {
const { issuesSize } = this.list;
- return `${n__('%d issue', '%d issues', issuesSize)}`;
+
+ return sprintf(__('%{issuesSize} issues'), { issuesSize });
},
+ // Only needed to make karma pass.
+ weightCountToolTip() {}, // eslint-disable-line vue/return-in-computed-property
caretTooltip() {
return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
},
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index 1e54d4d6b7d..ee889e0f7e0 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -256,7 +256,7 @@ export default {
let toList;
if (to) {
const containerEl = to.closest('.js-board-list');
- toList = boardsStore.findList('id', Number(containerEl.dataset.board));
+ toList = boardsStore.findList('id', Number(containerEl.dataset.board), '');
}
/**
diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue
index 5d7be0c705a..eeb0fbec1ed 100644
--- a/app/assets/javascripts/boards/components/boards_selector.vue
+++ b/app/assets/javascripts/boards/components/boards_selector.vue
@@ -9,7 +9,6 @@ import {
GlDropdownItem,
} from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
import httpStatusCodes from '~/lib/utils/http_status';
import boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue';
@@ -19,7 +18,6 @@ const MIN_BOARDS_TO_VIEW_RECENT = 10;
export default {
name: 'BoardsSelector',
components: {
- Icon,
BoardForm,
GlLoadingIcon,
GlSearchBoxByType,
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index 1802b543687..78e3351a79e 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -1,6 +1,6 @@
<script>
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Icon from '~/vue_shared/components/icon.vue';
-import bp from '../../../breakpoints';
import ModalStore from '../../stores/modal_store';
import IssueCardInner from '../issue_card_inner.vue';
@@ -105,9 +105,9 @@ export default {
setColumnCount() {
const breakpoint = bp.getBreakpointSize();
- if (breakpoint === 'lg' || breakpoint === 'md') {
+ if (breakpoint === 'xl' || breakpoint === 'lg') {
this.columns = 3;
- } else if (breakpoint === 'sm') {
+ } else if (breakpoint === 'md') {
this.columns = 2;
} else {
this.columns = 1;
diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js
deleted file mode 100644
index 93aacba0e8e..00000000000
--- a/app/assets/javascripts/breakpoints.js
+++ /dev/null
@@ -1,22 +0,0 @@
-export const breakpoints = {
- lg: 1200,
- md: 992,
- sm: 768,
- xs: 0,
-};
-
-const BreakpointInstance = {
- windowWidth: () => window.innerWidth,
- getBreakpointSize() {
- const windowWidth = this.windowWidth();
-
- const breakpoint = Object.keys(breakpoints).find(key => windowWidth > breakpoints[key]);
-
- return breakpoint;
- },
- isDesktop() {
- return ['lg', 'md'].includes(this.getBreakpointSize());
- },
-};
-
-export default BreakpointInstance;
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index d990d2677a8..b764348eb3c 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -53,6 +53,7 @@ export default class Clusters {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
+ ingressModSecurityHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
@@ -69,6 +70,7 @@ export default class Clusters {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
+ ingressModSecurityHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
@@ -169,6 +171,7 @@ export default class Clusters {
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
+ ingressModSecurityHelpPath: this.state.ingressModSecurityHelpPath,
cloudRunHelpPath: this.state.cloudRunHelpPath,
providerType: this.state.providerType,
preInstalledKnative: this.state.preInstalledKnative,
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index c6c8dc6352c..7db9898396b 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -2,7 +2,6 @@
/* eslint-disable vue/require-default-prop */
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlLink, GlModalDirective } from '@gitlab/ui';
-import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import { s__, __, sprintf } from '~/locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
@@ -16,7 +15,6 @@ export default {
components: {
loadingButton,
identicon,
- TimeagoTooltip,
GlLink,
UninstallApplicationButton,
UninstallApplicationConfirmationModal,
@@ -292,6 +290,7 @@ export default {
disabled && 'cluster-application-disabled',
]"
class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span"
+ :data-qa-selector="id"
>
<div class="gl-responsive-table-row-layout" role="row">
<div class="table-section append-right-8 section-align-top" role="gridcell">
@@ -383,12 +382,16 @@ export default {
:disabled="disabled || installButtonDisabled"
:label="installButtonLabel"
class="js-cluster-application-install-button"
+ data-qa-selector="install_button"
+ :data-qa-application="id"
@click="installClicked"
/>
<uninstall-application-button
v-if="displayUninstallButton"
v-gl-modal-directive="'uninstall-' + id"
:status="status"
+ data-qa-selector="uninstall_button"
+ :data-qa-application="id"
class="js-cluster-application-uninstall-button"
/>
<uninstall-application-confirmation-modal
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index a0ab20a97aa..704515cf70c 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -19,7 +19,6 @@ import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import KnativeDomainEditor from './knative_domain_editor.vue';
import { CLUSTER_TYPE, 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';
@@ -27,7 +26,6 @@ export default {
components: {
applicationRow,
clipboardButton,
- LoadingButton,
GlLoadingIcon,
KnativeDomainEditor,
CrossplaneProviderStack,
@@ -58,6 +56,11 @@ export default {
required: false,
default: '',
},
+ ingressModSecurityHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
cloudRunHelpPath: {
type: String,
required: false,
@@ -114,6 +117,9 @@ export default {
ingressInstalled() {
return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED;
},
+ ingressEnableModsecurity() {
+ return this.applications.ingress.modsecurity_enabled;
+ },
ingressExternalEndpoint() {
return this.applications.ingress.externalIp || this.applications.ingress.externalHostname;
},
@@ -123,12 +129,21 @@ export default {
crossplaneInstalled() {
return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED;
},
- enableClusterApplicationCrossplane() {
- return gon.features && gon.features.enableClusterApplicationCrossplane;
- },
enableClusterApplicationElasticStack() {
return gon.features && gon.features.enableClusterApplicationElasticStack;
},
+ ingressModSecurityDescription() {
+ const escapedUrl = _.escape(this.ingressModSecurityHelpPath);
+
+ return sprintf(
+ s__('ClusterIntegration|Learn more about %{startLink}ModSecurity%{endLink}'),
+ {
+ startLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`,
+ endLink: '</a>',
+ },
+ false,
+ );
+ },
ingressDescription() {
return sprintf(
_.escape(
@@ -137,9 +152,9 @@ export default {
),
),
{
- pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb"
+ pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb"
target="_blank" rel="noopener noreferrer">
- ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`,
+ ${_.escape(s__('ClusterIntegration|pricing'))}</a>`,
},
false,
);
@@ -204,9 +219,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
elasticStackInstalled() {
return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED;
},
- elasticStackKibanaHostname() {
- return this.applications.elastic_stack.kibana_hostname;
- },
knative() {
return this.applications.knative;
},
@@ -313,6 +325,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
:request-reason="applications.ingress.requestReason"
:installed="applications.ingress.installed"
:install-failed="applications.ingress.installFailed"
+ :install-application-request-params="{
+ modsecurity_enabled: applications.ingress.modsecurity_enabled,
+ }"
:uninstallable="applications.ingress.uninstallable"
:uninstall-successful="applications.ingress.uninstallSuccessful"
:uninstall-failed="applications.ingress.uninstallFailed"
@@ -328,6 +343,26 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
}}
</p>
+ <template>
+ <div class="form-group">
+ <div class="form-check form-check-inline">
+ <input
+ v-model="applications.ingress.modsecurity_enabled"
+ :disabled="ingressInstalled"
+ type="checkbox"
+ autocomplete="off"
+ class="form-check-input"
+ />
+ <label class="form-check-label label-bold" for="ingress-enable-modsecurity">
+ {{ s__('ClusterIntegration|Enable Web Application Firewall') }}
+ </label>
+ </div>
+ <p class="form-text text-muted">
+ <strong v-html="ingressModSecurityDescription"></strong>
+ </p>
+ </div>
+ </template>
+
<template v-if="ingressInstalled">
<div class="form-group">
<label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
@@ -377,7 +412,9 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</p>
</template>
<template v-if="!ingressInstalled">
- <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div>
+ <div class="bs-callout bs-callout-info">
+ <strong v-html="ingressDescription"></strong>
+ </div>
</template>
</div>
</application-row>
@@ -479,7 +516,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
</div>
</application-row>
<application-row
- v-if="enableClusterApplicationCrossplane"
id="crossplane"
:logo-url="crossplaneLogo"
:title="applications.crossplane.title"
@@ -638,9 +674,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
: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">
@@ -651,40 +684,6 @@ Crossplane runs inside your Kubernetes cluster and supports secure connectivity
)
}}
</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>
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index 35dbf951551..26456fb28db 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -5,7 +5,6 @@ import {
JUPYTER,
KNATIVE,
CERT_MANAGER,
- ELASTIC_STACK,
CROSSPLANE,
RUNNER,
APPLICATION_INSTALLED_STATUSES,
@@ -52,6 +51,7 @@ export default class ClusterStore {
ingress: {
...applicationInitialState,
title: s__('ClusterIntegration|Ingress'),
+ modsecurity_enabled: false,
externalIp: null,
externalHostname: null,
},
@@ -96,7 +96,6 @@ export default class ClusterStore {
elastic_stack: {
...applicationInitialState,
title: s__('ClusterIntegration|Elastic Stack'),
- kibana_hostname: null,
},
},
environments: [],
@@ -108,6 +107,7 @@ export default class ClusterStore {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
+ ingressModSecurityHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
@@ -116,6 +116,7 @@ export default class ClusterStore {
this.state.helpPath = helpPath;
this.state.ingressHelpPath = ingressHelpPath;
this.state.ingressDnsHelpPath = ingressDnsHelpPath;
+ this.state.ingressModSecurityHelpPath = ingressModSecurityHelpPath;
this.state.environmentsHelpPath = environmentsHelpPath;
this.state.clustersHelpPath = clustersHelpPath;
this.state.deployBoardsHelpPath = deployBoardsHelpPath;
@@ -207,6 +208,8 @@ export default class ClusterStore {
if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname;
+ this.state.applications.ingress.modsecurity_enabled =
+ serverAppEntry.modsecurity_enabled || this.state.applications.ingress.modsecurity_enabled;
} else if (appId === CERT_MANAGER) {
this.state.applications.cert_manager.email =
this.state.applications.cert_manager.email || serverAppEntry.email;
@@ -231,12 +234,6 @@ 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',
- );
}
});
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index a28e17f7a56..fb8b1c17407 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -40,7 +40,10 @@ export default class ImageFile {
.removeClass('active')
.filter(`.${viewMode}`)
.addClass('active');
+
+ // eslint-disable-next-line no-jquery/no-fade
return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => {
+ // eslint-disable-next-line no-jquery/no-fade
$(`.view.${viewMode}`, this.file).fadeIn(200);
return this.initView(viewMode);
});
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
index e5b030d4900..6b0d184faec 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue
@@ -1,5 +1,6 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import PipelinesService from '~/pipelines/services/pipelines_service';
import PipelineStore from '~/pipelines/stores/pipelines_store';
import pipelinesMixin from '~/pipelines/mixins/pipelines';
@@ -7,7 +8,6 @@ import eventHub from '~/pipelines/event_hub';
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
import { getParameterByName } from '~/lib/utils/common_utils';
import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin';
-import bp from '~/breakpoints';
export default {
components: {
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index f43b6f3d777..51879f280e0 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -1,13 +1,9 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
import _ from 'underscore';
-import bp from './breakpoints';
+import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils';
import { parseBoolean } from '~/lib/utils/common_utils';
-// NOTE: at 1200px nav sidebar should not overlap the content
-// https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110
-const NAV_SIDEBAR_BREAKPOINT = 1200;
-
export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
export default class ContextualSidebar {
@@ -50,9 +46,10 @@ export default class ContextualSidebar {
$(window).on('resize', () => _.debounce(this.render(), 100));
}
- // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4
// See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation
- static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT;
+ // NOTE: at 1200px nav sidebar should not overlap the content
+ // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110
+ static isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl;
static setCollapsedCookie(value) {
if (!ContextualSidebar.isDesktopBreakpoint()) {
return;
@@ -63,12 +60,13 @@ export default class ContextualSidebar {
toggleSidebarNav(show) {
const breakpoint = bp.getBreakpointSize();
const dbp = ContextualSidebar.isDesktopBreakpoint();
+ const supportedSizes = ['xs', 'sm', 'md'];
this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false);
this.$overlay.toggleClass(
'mobile-nav-open',
- breakpoint === 'xs' || breakpoint === 'sm' ? show : false,
+ supportedSizes.includes(breakpoint) ? show : false,
);
this.$sidebar.removeClass('sidebar-collapsed-desktop');
}
@@ -76,13 +74,14 @@ export default class ContextualSidebar {
toggleCollapsedSidebar(collapsed, saveCookie) {
const breakpoint = bp.getBreakpointSize();
const dbp = ContextualSidebar.isDesktopBreakpoint();
+ const supportedSizes = ['xs', 'sm', 'md'];
if (this.$sidebar.length) {
this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed);
this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false);
this.$page.toggleClass(
'page-with-icon-sidebar',
- breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed,
+ supportedSizes.includes(breakpoint) ? true : collapsed,
);
}
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue
index 2f7fcfcb755..2f7fcfcb755 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue
+++ b/app/assets/javascripts/create_cluster/components/cluster_form_dropdown.vue
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 d04d0ff2a6d..3d389cf3db5 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
@@ -3,7 +3,7 @@ import { createNamespacedHelpers, mapState, mapActions } from 'vuex';
import _ from 'underscore';
import { GlFormInput, GlFormCheckbox } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
-import ClusterFormDropdown from './cluster_form_dropdown.vue';
+import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
import { KUBERNETES_VERSIONS } from '../constants';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
@@ -149,11 +149,11 @@ 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 %{externalLinkIcon} %{endLink}.',
+ 'ClusterIntegration|Your service role is distinct from the provision role used when authenticating. It will allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role, 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">',
+ '<a href="https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html#create-service-role" target="_blank" rel="noopener noreferrer">',
externalLinkIcon: this.externalLinkIcon,
endLink: '</a>',
},
@@ -342,10 +342,9 @@ export default {
:empty-text="s__('ClusterIntegration|Kubernetes version not found')"
@input="setKubernetesVersion({ kubernetesVersion: $event })"
/>
- <p class="form-text text-muted" v-html="roleDropdownHelpText"></p>
</div>
<div class="form-group">
- <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Role name') }}</label>
+ <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Service role') }}</label>
<cluster-form-dropdown
field-id="eks-role"
field-name="eks-role"
@@ -353,7 +352,7 @@ export default {
:items="roles"
:loading="isLoadingRoles"
:loading-text="s__('ClusterIntegration|Loading IAM Roles')"
- :placeholder="s__('ClusterIntergation|Select role name')"
+ :placeholder="s__('ClusterIntergation|Select service role')"
:search-field-placeholder="s__('ClusterIntegration|Search IAM Roles')"
:empty-text="s__('ClusterIntegration|No IAM Roles found')"
:has-errors="Boolean(loadingRolesError)"
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 1dd4c468ae6..49a5d4657af 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
@@ -82,7 +82,7 @@ export default {
};
</script>
<template>
- <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })">
+ <form name="service-credentials-form">
<h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2>
<p>
{{
@@ -136,6 +136,7 @@ export default {
:disabled="submitButtonDisabled"
:loading="isCreatingRole"
:label="submitButtonLabel"
+ @click.prevent="createRole({ roleArn, externalId })"
/>
</form>
</template>
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 09fd560240d..8dc55506dc2 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
+++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js
@@ -4,7 +4,7 @@ import * as getters from './getters';
import mutations from './mutations';
import state from './state';
-import clusterDropdownStore from './cluster_dropdown';
+import clusterDropdownStore from '~/create_cluster/store/cluster_dropdown';
import {
fetchRoles,
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
new file mode 100644
index 00000000000..12b6070a79a
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_network_dropdown.vue
@@ -0,0 +1,53 @@
+<script>
+import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex';
+
+import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
+
+const { mapState: mapDropdownState } = createNamespacedHelpers('networks');
+const { mapActions: mapSubnetworkActions } = createNamespacedHelpers('subnetworks');
+
+export default {
+ components: {
+ ClusterFormDropdown,
+ },
+ props: {
+ fieldName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['selectedNetwork']),
+ ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']),
+ ...mapGetters(['hasZone', 'projectId', 'region']),
+ },
+ methods: {
+ ...mapActions(['setNetwork', 'setSubnetwork']),
+ ...mapSubnetworkActions({ fetchSubnetworks: 'fetchItems' }),
+ setNetworkAndFetchSubnetworks(network) {
+ const { projectId: project, region } = this;
+
+ this.setSubnetwork('');
+ this.setNetwork(network);
+ this.fetchSubnetworks({ project, region, network: network.selfLink });
+ },
+ },
+};
+</script>
+<template>
+ <cluster-form-dropdown
+ :field-name="fieldName"
+ :value="selectedNetwork"
+ :items="items"
+ :disabled="!hasZone"
+ :loading="isLoadingItems"
+ :has-errors="Boolean(loadingItemsError)"
+ :loading-text="s__('ClusterIntegration|Loading networks')"
+ :placeholder="s__('ClusterIntergation|Select a network')"
+ :search-field-placeholder="s__('ClusterIntegration|Search networks')"
+ :empty-text="s__('ClusterIntegration|No networks found')"
+ :error-message="s__('ClusterIntegration|Could not load networks')"
+ :disabled-text="s__('ClusterIntegration|Select a zone to choose a network')"
+ @input="setNetworkAndFetchSubnetworks"
+ />
+</template>
diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
new file mode 100644
index 00000000000..ec7889e2907
--- /dev/null
+++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_subnetwork_dropdown.vue
@@ -0,0 +1,44 @@
+<script>
+import { createNamespacedHelpers, mapState, mapGetters, mapActions } from 'vuex';
+
+import ClusterFormDropdown from '~/create_cluster/components/cluster_form_dropdown.vue';
+
+const { mapState: mapDropdownState } = createNamespacedHelpers('subnetworks');
+
+export default {
+ components: {
+ ClusterFormDropdown,
+ },
+ props: {
+ fieldName: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['selectedSubnetwork']),
+ ...mapDropdownState(['items', 'isLoadingItems', 'loadingItemsError']),
+ ...mapGetters(['hasNetwork']),
+ },
+ methods: {
+ ...mapActions(['setSubnetwork']),
+ },
+};
+</script>
+<template>
+ <cluster-form-dropdown
+ :field-name="fieldName"
+ :value="selectedSubnetwork"
+ :items="items"
+ :disabled="!hasNetwork"
+ :loading="isLoadingItems"
+ :has-errors="Boolean(loadingItemsError)"
+ :loading-text="s__('ClusterIntegration|Loading subnetworks')"
+ :placeholder="s__('ClusterIntergation|Select a subnetwork')"
+ :search-field-placeholder="s__('ClusterIntegration|Search subnetworks')"
+ :empty-text="s__('ClusterIntegration|No subnetworks found')"
+ :error-message="s__('ClusterIntegration|Could not load subnetworks')"
+ :disabled-text="s__('ClusterIntegration|Select a network to choose a subnetwork')"
+ @input="setSubnetwork"
+ />
+</template>
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js
index 5d250b2e29e..5d250b2e29e 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/actions.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/actions.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js
index e69de29bb2d..e69de29bb2d 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/getters.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/getters.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js
index 0b19589215c..0b19589215c 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/index.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/index.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js
index 48959a73924..48959a73924 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutation_types.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutation_types.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js
index d09689f1f6c..d09689f1f6c 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/mutations.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/mutations.js
diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js
index b949a24216e..b949a24216e 100644
--- a/app/assets/javascripts/create_cluster/eks_cluster/store/cluster_dropdown/state.js
+++ b/app/assets/javascripts/create_cluster/store/cluster_dropdown/state.js
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
index 18fb57c8b4f..304a0726597 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js
@@ -24,7 +24,7 @@ const EMPTY_STAGE_TEXTS = {
'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
),
production: __(
- 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
+ 'The total stage shows the time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
),
};
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index 4d36a492c1c..c856e380c41 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -115,7 +115,10 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
<div class="table-mobile-content qa-key">
<strong class="title qa-key-title"> {{ deployKey.title }} </strong>
- <div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div>
+ <div class="fingerprint" data-qa-selector="key_md5_fingerprint">
+ {{ __('MD5') }}:{{ deployKey.fingerprint }}
+ </div>
+ <div class="fingerprint">{{ __('SHA256') }}:{{ deployKey.fingerprint_sha256 }}</div>
</div>
</div>
<div class="table-section section-30 section-wrap">
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index 8ea443814e9..878b54f7d53 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -2,11 +2,11 @@
import { mapState, mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import Mousetrap from 'mousetrap';
-import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import createFlash from '~/flash';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { isSingleViewStyle } from '~/helpers/diffs_helper';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -27,7 +27,6 @@ import {
export default {
name: 'DiffsApp',
components: {
- Icon,
CompareVersions,
DiffFile,
NoChanges,
@@ -95,8 +94,8 @@ export default {
parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
return {
- assignedDiscussions: false,
treeWidth,
+ diffFilesLength: 0,
};
},
computed: {
@@ -114,6 +113,7 @@ export default {
numVisibleFiles: state => state.diffs.size,
plainDiffPath: state => state.diffs.plainDiffPath,
emailPatchPath: state => state.diffs.emailPatchPath,
+ retrievingBatches: state => state.diffs.retrievingBatches,
}),
...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
@@ -144,12 +144,12 @@ export default {
isLimitedContainer() {
return !this.showTreeList && !this.isParallelView && !this.isFluidLayout;
},
- shouldSetDiscussions() {
- return this.isNotesFetched && !this.assignedDiscussions && !this.isLoading;
- },
},
watch: {
diffViewType() {
+ if (this.needsReload() || this.needsFirstLoad()) {
+ this.refetchDiffData();
+ }
this.adjustView();
},
shouldShow() {
@@ -163,11 +163,6 @@ export default {
},
isLoading: 'adjustView',
showTreeList: 'adjustView',
- shouldSetDiscussions(newVal) {
- if (newVal) {
- this.setDiscussions();
- }
- },
},
mounted() {
this.setBaseConfig({
@@ -192,10 +187,24 @@ export default {
},
created() {
this.adjustView();
- eventHub.$once('fetchedNotesData', this.setDiscussions);
eventHub.$once('fetchDiffData', this.fetchData);
eventHub.$on('refetchDiffData', this.refetchDiffData);
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
+
+ this.unwatchDiscussions = this.$watch(
+ () => `${this.diffFiles.length}:${this.$store.state.notes.discussions.length}`,
+ () => this.setDiscussions(),
+ );
+
+ this.unwatchRetrievingBatches = this.$watch(
+ () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`,
+ () => {
+ if (!this.retrievingBatches && this.$store.state.notes.discussions.length) {
+ this.unwatchDiscussions();
+ this.unwatchRetrievingBatches();
+ }
+ },
+ );
},
beforeDestroy() {
eventHub.$off('fetchDiffData', this.fetchData);
@@ -217,7 +226,6 @@ export default {
'toggleShowTreeList',
]),
refetchDiffData() {
- this.assignedDiscussions = false;
this.fetchData(false);
},
startDiffRendering() {
@@ -228,10 +236,21 @@ export default {
{ timeout: 1000 },
);
},
+ needsReload() {
+ return (
+ this.glFeatures.singleMrDiffView &&
+ this.diffFiles.length &&
+ isSingleViewStyle(this.diffFiles[0])
+ );
+ },
+ needsFirstLoad() {
+ return this.glFeatures.singleMrDiffView && !this.diffFiles.length;
+ },
fetchData(toggleTree = true) {
if (this.glFeatures.diffsBatchLoad) {
this.fetchDiffFilesMeta()
- .then(() => {
+ .then(({ real_size }) => {
+ this.diffFilesLength = parseInt(real_size, 10);
if (toggleTree) this.hideTreeListIfJustOneFile();
this.startDiffRendering();
@@ -241,19 +260,28 @@ export default {
});
this.fetchDiffFilesBatch()
+ .then(() => {
+ // Guarantee the discussions are assigned after the batch finishes.
+ // Just watching the length of the discussions or the diff files
+ // isn't enough, because with split diff loading, neither will
+ // change when loading the other half of the diff files.
+ this.setDiscussions();
+ })
.then(() => this.startDiffRendering())
.catch(() => {
createFlash(__('Something went wrong on our end. Please try again!'));
});
} else {
this.fetchDiffFiles()
- .then(() => {
+ .then(({ real_size }) => {
+ this.diffFilesLength = parseInt(real_size, 10);
if (toggleTree) {
this.hideTreeListIfJustOneFile();
}
requestIdleCallback(
() => {
+ this.setDiscussions();
this.startRenderDiffsQueue();
},
{ timeout: 1000 },
@@ -269,17 +297,13 @@ export default {
}
},
setDiscussions() {
- if (this.shouldSetDiscussions) {
- this.assignedDiscussions = true;
-
- requestIdleCallback(
- () =>
- this.assignDiscussionsToDiff()
- .then(this.$nextTick)
- .then(this.startTaskList),
- { timeout: 1000 },
- );
- }
+ requestIdleCallback(
+ () =>
+ this.assignDiscussionsToDiff()
+ .then(this.$nextTick)
+ .then(this.startTaskList),
+ { timeout: 1000 },
+ );
},
adjustView() {
if (this.shouldShow) {
@@ -337,6 +361,7 @@ export default {
:merge-request-diff="mergeRequestDiff"
:target-branch="targetBranch"
:is-limited-container="isLimitedContainer"
+ :diff-files-length="diffFilesLength"
/>
<hidden-files-warning
@@ -349,7 +374,7 @@ export default {
<div
:data-can-create-note="getNoteableData.current_user.can_create_note"
- class="files d-flex prepend-top-default"
+ class="files d-flex"
>
<div
v-show="showTreeList"
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index 43a7703f611..cfffccd54eb 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -2,7 +2,6 @@
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Icon from '~/vue_shared/components/icon.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
-import CIIcon from '~/vue_shared/components/ci_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
import initUserPopovers from '../../user_popovers';
@@ -25,7 +24,6 @@ export default {
UserAvatarLink,
Icon,
ClipboardButton,
- CIIcon,
TimeAgoTooltip,
CommitPipelineStatus,
},
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index 2e57a47f2f7..63ce43a193d 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -1,5 +1,4 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
@@ -42,9 +41,13 @@ export default {
required: false,
default: false,
},
+ diffFilesLength: {
+ type: Number,
+ required: true,
+ },
},
computed: {
- ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']),
+ ...mapGetters('diffs', ['hasCollapsedFile']),
...mapState('diffs', [
'commit',
'showTreeList',
@@ -59,9 +62,6 @@ export default {
showDropdowns() {
return !this.commit && this.mergeRequestDiffs.length;
},
- fileTreeIcon() {
- return this.showTreeList ? 'collapse-left' : 'expand-left';
- },
toggleFileBrowserTitle() {
return this.showTreeList ? __('Hide file browser') : __('Show file browser');
},
@@ -87,7 +87,7 @@ export default {
</script>
<template>
- <div class="mr-version-controls border-top border-bottom">
+ <div class="mr-version-controls border-top">
<div
class="mr-version-menus-container content-block"
:class="{
@@ -104,17 +104,17 @@ export default {
:title="toggleFileBrowserTitle"
@click="toggleShowTreeList"
>
- <icon :name="fileTreeIcon" />
+ <icon name="file-tree" />
</button>
<div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container">
- Changes between
+ {{ __('Compare') }}
<compare-versions-dropdown
:other-versions="mergeRequestDiffs"
:merge-request-version="mergeRequestDiff"
:show-commit-count="true"
class="mr-version-dropdown"
/>
- and
+ {{ __('and') }}
<compare-versions-dropdown
:other-versions="comparableDiffs"
:base-version-path="baseVersionPath"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 0dbff4ffcec..f5051748f10 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -4,6 +4,7 @@ import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
+import { hasDiff } from '~/helpers/diffs_helper';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
@@ -55,12 +56,7 @@ export default {
return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
},
hasDiff() {
- return (
- (this.file.highlighted_diff_lines &&
- this.file.parallel_diff_lines &&
- this.file.parallel_diff_lines.length > 0) ||
- !this.file.blob.readable_text
- );
+ return hasDiff(this.file);
},
isFileTooLarge() {
return this.file.viewer.error === diffViewerErrors.too_large;
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index 91d374eafc0..e78bea789c3 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,7 +1,7 @@
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
-import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { polyfillSticky } from '~/lib/utils/sticky';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -15,7 +15,6 @@ import { scrollToElement } from '~/lib/utils/common_utils';
export default {
components: {
- GlTooltip,
GlLoadingIcon,
GlButton,
ClipboardButton,
@@ -124,6 +123,20 @@ export default {
}
return s__('MRDiff|Show full file');
},
+ changedFile() {
+ const {
+ new_path: changed,
+ deleted_file: deleted,
+ new_file: tempFile,
+ ...diffFile
+ } = this.diffFile;
+ return {
+ ...diffFile,
+ changed: Boolean(changed),
+ deleted,
+ tempFile,
+ };
+ },
},
mounted() {
polyfillSticky(this.$refs.header);
@@ -222,7 +235,7 @@ export default {
<div
v-if="!diffFile.submodule && addMergeRequestButtons"
- class="file-actions d-none d-sm-block"
+ class="file-actions d-none d-sm-flex align-items-center"
>
<diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
<div class="btn-group" role="group">
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
index 2e5855380af..1fa1fda7bd7 100644
--- a/app/assets/javascripts/diffs/components/diff_stats.vue
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -1,9 +1,7 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import { n__ } from '~/locale';
export default {
- components: { Icon },
props: {
addedLines: {
type: Number,
@@ -21,7 +19,7 @@ export default {
},
computed: {
filesText() {
- return n__('File', 'Files', this.diffFilesLength);
+ return n__('file', 'files', this.diffFilesLength);
},
isCompareVersionsHeader() {
return Boolean(this.diffFilesLength);
@@ -39,14 +37,21 @@ export default {
}"
>
<div v-if="diffFilesLength !== null" class="diff-stats-group">
- <icon name="doc-code" class="diff-stats-icon text-secondary" />
- <strong>{{ diffFilesLength }} {{ filesText }}</strong>
+ <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span>
</div>
- <div class="diff-stats-group cgreen">
- <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong>
+ <div
+ class="diff-stats-group cgreen d-flex align-items-center"
+ :class="{ bold: isCompareVersionsHeader }"
+ >
+ <span>+</span>
+ <span class="js-file-addition-line">{{ addedLines }}</span>
</div>
- <div class="diff-stats-group cred">
- <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong>
+ <div
+ class="diff-stats-group cred d-flex align-items-center"
+ :class="{ bold: isCompareVersionsHeader }"
+ >
+ <span>-</span>
+ <span class="js-file-deletion-line">{{ removedLines }}</span>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
index 6e732727f42..071a988d789 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_expansion_row.vue
@@ -1,11 +1,9 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import { MATCH_LINE_TYPE } from '../constants';
export default {
components: {
- Icon,
DiffExpansionCell,
},
props: {
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index 30be2e68e76..7956d05b4f1 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -4,7 +4,6 @@ import { GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
-import FileRowStats from './file_row_stats.vue';
export default {
directives: {
@@ -48,9 +47,6 @@ export default {
return acc;
}, []);
},
- fileRowExtraComponent() {
- return this.hideFileStats ? null : FileRowStats;
- },
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
@@ -58,8 +54,8 @@ export default {
this.search = '';
},
},
- searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), {
- modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl',
+ searchPlaceholder: sprintf(s__('MergeRequest|Search files (%{modifier_key}P)'), {
+ modifier_key: /Mac/i.test(navigator.userAgent) ? 'āŒ˜' : 'Ctrl+',
}),
};
</script>
@@ -97,7 +93,6 @@ export default {
:file="file"
:level="0"
:hide-extra-on-tree="true"
- :extra-component="fileRowExtraComponent"
:show-changed-icon="true"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="scrollToFile"
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 992b45c97ac..b920e041135 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -64,6 +64,11 @@ export const fetchDiffFiles = ({ state, commit }) => {
const urlParams = {
w: state.showWhitespace ? '0' : '1',
};
+ let returnData;
+
+ if (state.useSingleDiffStyle) {
+ urlParams.view = state.diffViewType;
+ }
commit(types.SET_LOADING, true);
@@ -83,26 +88,42 @@ export const fetchDiffFiles = ({ state, commit }) => {
worker.postMessage(state.diffFiles);
+ returnData = res.data;
return Vue.nextTick();
})
- .then(handleLocationHash)
+ .then(() => {
+ handleLocationHash();
+ return returnData;
+ })
.catch(() => worker.terminate());
};
export const fetchDiffFilesBatch = ({ commit, state }) => {
+ const urlParams = {
+ per_page: DIFFS_PER_PAGE,
+ w: state.showWhitespace ? '0' : '1',
+ };
+
+ if (state.useSingleDiffStyle) {
+ urlParams.view = state.diffViewType;
+ }
+
commit(types.SET_BATCH_LOADING, true);
+ commit(types.SET_RETRIEVING_BATCHES, true);
const getBatch = page =>
axios
.get(state.endpointBatch, {
- params: { page, per_page: DIFFS_PER_PAGE, w: state.showWhitespace ? '0' : '1' },
+ params: { ...urlParams, page },
})
.then(({ data: { pagination, diff_files } }) => {
commit(types.SET_DIFF_DATA_BATCH, { diff_files });
commit(types.SET_BATCH_LOADING, false);
+ if (!pagination.next_page) commit(types.SET_RETRIEVING_BATCHES, false);
return pagination.next_page;
})
- .then(nextPage => nextPage && getBatch(nextPage));
+ .then(nextPage => nextPage && getBatch(nextPage))
+ .catch(() => commit(types.SET_RETRIEVING_BATCHES, false));
return getBatch()
.then(handleLocationHash)
@@ -131,6 +152,7 @@ export const fetchDiffFilesMeta = ({ commit, state }) => {
prepareDiffData(data);
worker.postMessage(data.diff_files);
+ return data;
})
.catch(() => worker.terminate());
};
@@ -147,7 +169,10 @@ export const assignDiscussionsToDiff = (
{ commit, state, rootState },
discussions = rootState.notes.discussions,
) => {
- const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
+ const diffPositionByLineCode = getDiffPositionByLineCode(
+ state.diffFiles,
+ state.useSingleDiffStyle,
+ );
const hash = getLocationHash();
discussions
@@ -336,24 +361,23 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
- let linesWithDiscussions;
- if (diff.highlighted_diff_lines) {
- linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length);
- }
- if (diff.parallel_diff_lines) {
- linesWithDiscussions = diff.parallel_diff_lines.filter(
- line =>
- (line.left && line.left.discussions.length) ||
- (line.right && line.right.discussions.length),
- );
- }
-
- if (linesWithDiscussions.length) {
- linesWithDiscussions.forEach(line => {
+ const lineCodesWithDiscussions = new Set();
+ const { parallel_diff_lines: parallelLines, highlighted_diff_lines: inlineLines } = diff;
+ const allLines = inlineLines.concat(
+ parallelLines.map(line => line.left),
+ parallelLines.map(line => line.right),
+ );
+ const lineHasDiscussion = line => Boolean(line?.discussions.length);
+ const registerDiscussionLine = line => lineCodesWithDiscussions.add(line.line_code);
+
+ allLines.filter(lineHasDiscussion).forEach(registerDiscussionLine);
+
+ if (lineCodesWithDiscussions.size) {
+ Array.from(lineCodesWithDiscussions).forEach(lineCode => {
commit(types.TOGGLE_LINE_DISCUSSIONS, {
fileHash: diff.file_hash,
- lineCode: line.line_code,
expanded: !discussionWrappersExpanded,
+ lineCode,
});
});
}
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index bc27e263bff..c4737090a70 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -95,8 +95,6 @@ export const allBlobs = (state, getters) =>
return acc;
}, []);
-export const diffFilesLength = state => state.diffFiles.length;
-
export const getCommentFormForDiffFile = state => fileHash =>
state.commentForms.find(form => form.fileHash === fileHash);
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 7366c50752c..011cd24500a 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -9,6 +9,7 @@ const defaultViewType = INLINE_DIFF_VIEW_TYPE;
export default () => ({
isLoading: true,
isBatchLoading: false,
+ retrievingBatches: false,
addedLines: null,
removedLines: null,
endpoint: '',
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 5a90d78b2bc..2097c8d3655 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -1,6 +1,7 @@
export const SET_BASE_CONFIG = 'SET_BASE_CONFIG';
export const SET_LOADING = 'SET_LOADING';
export const SET_BATCH_LOADING = 'SET_BATCH_LOADING';
+export const SET_RETRIEVING_BATCHES = 'SET_RETRIEVING_BATCHES';
export const SET_DIFF_DATA = 'SET_DIFF_DATA';
export const SET_DIFF_DATA_BATCH = 'SET_DIFF_DATA_BATCH';
export const SET_DIFF_VIEW_TYPE = 'SET_DIFF_VIEW_TYPE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 859f43b3b6d..1505be1a0b2 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -40,27 +40,33 @@ export default {
Object.assign(state, { isBatchLoading });
},
+ [types.SET_RETRIEVING_BATCHES](state, retrievingBatches) {
+ Object.assign(state, { retrievingBatches });
+ },
+
[types.SET_DIFF_DATA](state, data) {
+ let files = state.diffFiles;
+
if (
- !(
- gon &&
- gon.features &&
- gon.features.diffsBatchLoad &&
- window.location.search.indexOf('diff_id') === -1
- )
+ !(gon?.features?.diffsBatchLoad && window.location.search.indexOf('diff_id') === -1) &&
+ data.diff_files
) {
- prepareDiffData(data);
+ files = prepareDiffData(data, files);
}
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
+ diffFiles: files,
});
},
[types.SET_DIFF_DATA_BATCH](state, data) {
- prepareDiffData(data);
+ const files = prepareDiffData(data, state.diffFiles);
- state.diffFiles.push(...data.diff_files);
+ Object.assign(state, {
+ ...convertObjectPropsToCamelCase(data),
+ diffFiles: files,
+ });
},
[types.RENDER_FILE](state, file) {
@@ -84,11 +90,11 @@ export default {
if (!diffFile) return;
- if (diffFile.highlighted_diff_lines) {
+ if (diffFile.highlighted_diff_lines.length) {
diffFile.highlighted_diff_lines.find(l => l.line_code === lineCode).hasForm = hasForm;
}
- if (diffFile.parallel_diff_lines) {
+ if (diffFile.parallel_diff_lines.length) {
const line = diffFile.parallel_diff_lines.find(l => {
const { left, right } = l;
@@ -149,13 +155,13 @@ export default {
},
[types.EXPAND_ALL_FILES](state) {
- state.diffFiles = state.diffFiles.map(file => ({
- ...file,
- viewer: {
- ...file.viewer,
- collapsed: false,
- },
- }));
+ state.diffFiles.forEach(file => {
+ Object.assign(file, {
+ viewer: Object.assign(file.viewer, {
+ collapsed: false,
+ }),
+ });
+ });
},
[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
@@ -173,16 +179,19 @@ export default {
const mapDiscussions = (line, extraCheck = () => true) => ({
...line,
discussions: extraCheck()
- ? line.discussions
+ ? line.discussions &&
+ line.discussions
.filter(() => !line.discussions.some(({ id }) => discussion.id === id))
.concat(lineCheck(line) ? discussion : line.discussions)
: [],
});
const setDiscussionsExpanded = line => {
- const isLineNoteTargeted = line.discussions.some(
- disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
- );
+ const isLineNoteTargeted =
+ line.discussions &&
+ line.discussions.some(
+ disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
+ );
return {
...line,
@@ -193,29 +202,29 @@ export default {
};
};
- state.diffFiles = state.diffFiles.map(diffFile => {
- if (diffFile.file_hash === fileHash) {
- const file = { ...diffFile };
-
- if (file.highlighted_diff_lines) {
- file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
- setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
- );
+ state.diffFiles.forEach(file => {
+ if (file.file_hash === fileHash) {
+ if (file.highlighted_diff_lines.length) {
+ file.highlighted_diff_lines.forEach(line => {
+ Object.assign(
+ line,
+ setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
+ );
+ });
}
- if (file.parallel_diff_lines) {
- file.parallel_diff_lines = file.parallel_diff_lines.map(line => {
+ if (file.parallel_diff_lines.length) {
+ file.parallel_diff_lines.forEach(line => {
const left = line.left && lineCheck(line.left);
const right = line.right && lineCheck(line.right);
if (left || right) {
- return {
- ...line,
+ Object.assign(line, {
left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
right: line.right
? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
: null,
- };
+ });
}
return line;
@@ -223,15 +232,15 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = (file.discussions || [])
+ const newDiscussions = (file.discussions || [])
.filter(d => d.id !== discussion.id)
.concat(discussion);
- }
- return file;
+ Object.assign(file, {
+ discussions: newDiscussions,
+ });
+ }
}
-
- return diffFile;
});
},
@@ -255,9 +264,9 @@ export default {
[types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
- updateLineInFile(selectedFile, lineCode, line =>
- Object.assign(line, { discussionsExpanded: expanded }),
- );
+ updateLineInFile(selectedFile, lineCode, line => {
+ Object.assign(line, { discussionsExpanded: expanded });
+ });
},
[types.TOGGLE_FOLDER_OPEN](state, path) {
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 281a0de1fc2..b379f1fabef 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -185,6 +185,7 @@ export function addContextLines(options) {
* Trims the first char of the `richText` property when it's either a space or a diff symbol.
* @param {Object} line
* @returns {Object}
+ * @deprecated
*/
export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
@@ -212,79 +213,171 @@ function getLineCode({ left, right }, index) {
return index;
}
-// This prepares and optimizes the incoming diff data from the server
-// by setting up incremental rendering and removing unneeded data
-export function prepareDiffData(diffData) {
- const filesLength = diffData.diff_files.length;
- let showingLines = 0;
- for (let i = 0; i < filesLength; i += 1) {
- const file = diffData.diff_files[i];
-
- if (file.parallel_diff_lines) {
- const linesLength = file.parallel_diff_lines.length;
- for (let u = 0; u < linesLength; u += 1) {
- const line = file.parallel_diff_lines[u];
-
- line.line_code = getLineCode(line, u);
- if (line.left) {
- line.left = trimFirstCharOfLineContent(line.left);
- line.left.discussions = [];
- line.left.hasForm = false;
- }
- if (line.right) {
- line.right = trimFirstCharOfLineContent(line.right);
- line.right.discussions = [];
- line.right.hasForm = false;
- }
+function diffFileUniqueId(file) {
+ return `${file.content_sha}-${file.file_hash}`;
+}
+
+function combineDiffFilesWithPriorFiles(files, prior = []) {
+ files.forEach(file => {
+ const id = diffFileUniqueId(file);
+ const oldMatch = prior.find(oldFile => diffFileUniqueId(oldFile) === id);
+
+ if (oldMatch) {
+ const missingInline = !file.highlighted_diff_lines;
+ const missingParallel = !file.parallel_diff_lines;
+
+ if (missingInline) {
+ Object.assign(file, {
+ highlighted_diff_lines: oldMatch.highlighted_diff_lines,
+ });
}
- }
- if (file.highlighted_diff_lines) {
- const linesLength = file.highlighted_diff_lines.length;
- for (let u = 0; u < linesLength; u += 1) {
- const line = file.highlighted_diff_lines[u];
- Object.assign(line, {
- ...trimFirstCharOfLineContent(line),
- discussions: [],
- hasForm: false,
+ if (missingParallel) {
+ Object.assign(file, {
+ parallel_diff_lines: oldMatch.parallel_diff_lines,
});
}
- showingLines += file.parallel_diff_lines.length;
+ }
+ });
+
+ return files;
+}
+
+function ensureBasicDiffFileLines(file) {
+ const missingInline = !file.highlighted_diff_lines;
+ const missingParallel = !file.parallel_diff_lines;
+
+ Object.assign(file, {
+ highlighted_diff_lines: missingInline ? [] : file.highlighted_diff_lines,
+ parallel_diff_lines: missingParallel ? [] : file.parallel_diff_lines,
+ });
+
+ return file;
+}
+
+function cleanRichText(text) {
+ return text ? text.replace(/^[+ -]/, '') : undefined;
+}
+
+function prepareLine(line) {
+ return Object.assign(line, {
+ rich_text: cleanRichText(line.rich_text),
+ discussionsExpanded: true,
+ discussions: [],
+ hasForm: false,
+ text: undefined,
+ });
+}
+
+function prepareDiffFileLines(file) {
+ const inlineLines = file.highlighted_diff_lines;
+ const parallelLines = file.parallel_diff_lines;
+ let parallelLinesCount = 0;
+
+ inlineLines.forEach(prepareLine);
+
+ parallelLines.forEach((line, index) => {
+ Object.assign(line, { line_code: getLineCode(line, index) });
+
+ if (line.left) {
+ parallelLinesCount += 1;
+ prepareLine(line.left);
}
- const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
+ if (line.right) {
+ parallelLinesCount += 1;
+ prepareLine(line.right);
+ }
Object.assign(file, {
- renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY,
- collapsed: name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
- isShowingFullFile: false,
- isLoadingFullFile: false,
- discussions: [],
- renderingLines: false,
+ inlineLinesCount: inlineLines.length,
+ parallelLinesCount,
});
- }
+ });
+
+ return file;
}
-export function getDiffPositionByLineCode(diffFiles) {
- return diffFiles.reduce((acc, diffFile) => {
- // We can only use highlightedDiffLines to create the map of diff lines because
- // highlightedDiffLines will also include every parallel diff line in it.
- if (diffFile.highlighted_diff_lines) {
+function getVisibleDiffLines(file) {
+ return Math.max(file.inlineLinesCount, file.parallelLinesCount);
+}
+
+function finalizeDiffFile(file) {
+ const name = (file.viewer && file.viewer.name) || diffViewerModes.text;
+ const lines = getVisibleDiffLines(file);
+
+ Object.assign(file, {
+ renderIt: lines < LINES_TO_BE_RENDERED_DIRECTLY,
+ collapsed: name === diffViewerModes.text && lines > MAX_LINES_TO_BE_RENDERED,
+ isShowingFullFile: false,
+ isLoadingFullFile: false,
+ discussions: [],
+ renderingLines: false,
+ });
+
+ return file;
+}
+
+export function prepareDiffData(diffData, priorFiles) {
+ return combineDiffFilesWithPriorFiles(diffData.diff_files, priorFiles)
+ .map(ensureBasicDiffFileLines)
+ .map(prepareDiffFileLines)
+ .map(finalizeDiffFile);
+}
+
+export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) {
+ let lines = [];
+ const hasInlineDiffs = diffFiles.some(file => file.highlighted_diff_lines.length > 0);
+
+ if (!useSingleDiffStyle || hasInlineDiffs) {
+ // In either of these cases, we can use `highlighted_diff_lines` because
+ // that will include all of the parallel diff lines, too
+
+ lines = diffFiles.reduce((acc, diffFile) => {
diffFile.highlighted_diff_lines.forEach(line => {
- if (line.line_code) {
- acc[line.line_code] = {
- base_sha: diffFile.diff_refs.base_sha,
- head_sha: diffFile.diff_refs.head_sha,
- start_sha: diffFile.diff_refs.start_sha,
- new_path: diffFile.new_path,
- old_path: diffFile.old_path,
- old_line: line.old_line,
- new_line: line.new_line,
- line_code: line.line_code,
- position_type: 'text',
- };
+ acc.push({ file: diffFile, line });
+ });
+
+ return acc;
+ }, []);
+ } else {
+ // If we're in single diff view mode and the inline lines haven't been
+ // loaded yet, we need to parse the parallel lines
+
+ lines = diffFiles.reduce((acc, diffFile) => {
+ diffFile.parallel_diff_lines.forEach(pair => {
+ // It's possible for a parallel line to have an opposite line that doesn't exist
+ // For example: *deleted* lines will have `null` right lines, while
+ // *added* lines will have `null` left lines.
+ // So we have to check each line before we push it onto the array so we're not
+ // pushing null line diffs
+
+ if (pair.left) {
+ acc.push({ file: diffFile, line: pair.left });
+ }
+
+ if (pair.right) {
+ acc.push({ file: diffFile, line: pair.right });
}
});
+
+ return acc;
+ }, []);
+ }
+
+ return lines.reduce((acc, { file, line }) => {
+ if (line.line_code) {
+ acc[line.line_code] = {
+ base_sha: file.diff_refs.base_sha,
+ head_sha: file.diff_refs.head_sha,
+ start_sha: file.diff_refs.start_sha,
+ new_path: file.new_path,
+ old_path: file.old_path,
+ old_line: line.old_line,
+ new_line: line.new_line,
+ line_code: line.line_code,
+ position_type: 'text',
+ };
}
return acc;
@@ -462,47 +555,47 @@ export const convertExpandLines = ({
export const idleCallback = cb => requestIdleCallback(cb);
-export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
- if (selectedFile.parallel_diff_lines) {
- const targetLine = selectedFile.parallel_diff_lines.find(
- line =>
- (line.left && line.left.line_code === lineCode) ||
- (line.right && line.right.line_code === lineCode),
- );
- if (targetLine) {
- const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
+function getLinesFromFileByLineCode(file, lineCode) {
+ const parallelLines = file.parallel_diff_lines;
+ const inlineLines = file.highlighted_diff_lines;
+ const matchesCode = line => line.line_code === lineCode;
- updateFn(targetLine[side]);
- }
- }
- if (selectedFile.highlighted_diff_lines) {
- const targetInlineLine = selectedFile.highlighted_diff_lines.find(
- line => line.line_code === lineCode,
- );
+ return [
+ ...parallelLines.reduce((acc, line) => {
+ if (line.left) {
+ acc.push(line.left);
+ }
- if (targetInlineLine) {
- updateFn(targetInlineLine);
- }
- }
+ if (line.right) {
+ acc.push(line.right);
+ }
+
+ return acc;
+ }, []),
+ ...inlineLines,
+ ].filter(matchesCode);
+}
+
+export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
+ getLinesFromFileByLineCode(selectedFile, lineCode).forEach(updateFn);
};
export const allDiscussionWrappersExpanded = diff => {
- const discussionsExpandedArray = [];
- if (diff.parallel_diff_lines) {
- diff.parallel_diff_lines.forEach(line => {
- if (line.left && line.left.discussions.length) {
- discussionsExpandedArray.push(line.left.discussionsExpanded);
- }
- if (line.right && line.right.discussions.length) {
- discussionsExpandedArray.push(line.right.discussionsExpanded);
- }
- });
- } else if (diff.highlighted_diff_lines) {
- diff.highlighted_diff_lines.forEach(line => {
- if (line.discussions.length) {
- discussionsExpandedArray.push(line.discussionsExpanded);
- }
- });
- }
- return discussionsExpandedArray.every(el => el);
+ let discussionsExpanded = true;
+ const changeExpandedResult = line => {
+ if (line && line.discussions.length) {
+ discussionsExpanded = discussionsExpanded && line.discussionsExpanded;
+ }
+ };
+
+ diff.parallel_diff_lines.forEach(line => {
+ changeExpandedResult(line.left);
+ changeExpandedResult(line.right);
+ });
+
+ diff.highlighted_diff_lines.forEach(line => {
+ changeExpandedResult(line);
+ });
+
+ return discussionsExpanded;
};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
index ccb3d56ed8c..31d32fb5060 100644
--- a/app/assets/javascripts/droplab/drop_down.js
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -101,6 +101,11 @@ class DropDown {
render(data) {
const children = data ? data.map(this.renderChildren.bind(this)) : [];
+
+ if (this.list.querySelector('.filter-dropdown-loading')) {
+ return;
+ }
+
const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
renderableList.innerHTML = children.join('');
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 62b390a46d7..86590865892 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Dropzone from 'dropzone';
import _ from 'underscore';
import './behaviors/preview_markdown';
+import PasteMarkdownTable from './behaviors/markdown/paste_markdown_table';
import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils';
import { n__, __ } from '~/locale';
@@ -173,14 +174,25 @@ export default function dropzoneInput(form) {
// eslint-disable-next-line consistent-return
handlePaste = event => {
const pasteEvent = event.originalEvent;
- if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) {
- const image = isImage(pasteEvent);
- if (image) {
+ const { clipboardData } = pasteEvent;
+ if (clipboardData && clipboardData.items) {
+ const converter = new PasteMarkdownTable(clipboardData);
+ // Apple Numbers copies a table as an image, HTML, and text, so
+ // we need to check for the presence of a table first.
+ if (converter.isTable()) {
event.preventDefault();
- const filename = getFilename(pasteEvent) || 'image.png';
- const text = `{{${filename}}}`;
+ const text = converter.convertToTableMarkdown();
pasteText(text);
- return uploadFile(image.getAsFile(), filename);
+ } else {
+ const image = isImage(pasteEvent);
+
+ if (image) {
+ event.preventDefault();
+ const filename = getFilename(pasteEvent) || 'image.png';
+ const text = `{{${filename}}}`;
+ pasteText(text);
+ return uploadFile(image.getAsFile(), filename);
+ }
}
}
};
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index 3c650397a19..b973316b3b9 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -116,11 +116,13 @@ class DueDateSelect {
}
updateIssueBoardIssue() {
+ // eslint-disable-next-line no-jquery/no-fade
this.$loading.fadeIn();
this.$dropdown.trigger('loading.gl.dropdown');
this.$selectbox.hide();
this.$value.css('display', '');
const fadeOutLoader = () => {
+ // eslint-disable-next-line no-jquery/no-fade
this.$loading.fadeOut();
};
@@ -135,6 +137,7 @@ class DueDateSelect {
const hasDueDate = this.displayedDate !== __('None');
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
+ // eslint-disable-next-line no-jquery/no-fade
this.$loading.removeClass('hidden').fadeIn();
if (isDropdown) {
@@ -158,6 +161,7 @@ class DueDateSelect {
}
this.$sidebarCollapsedValue.attr('data-original-title', tooltipText);
+ // eslint-disable-next-line no-jquery/no-fade
return this.$loading.fadeOut();
});
}
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index 428dfe5fcf7..3096ccad0aa 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,22 +1,23 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
-import { format } from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
-import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
+import { __, sprintf } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+import CommitComponent from '~/vue_shared/components/commit.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
-import { __, sprintf } from '~/locale';
+import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
+import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
-import StopComponent from './environment_stop.vue';
+import MonitoringButtonComponent from './environment_monitoring.vue';
+import PinComponent from './environment_pin.vue';
import RollbackComponent from './environment_rollback.vue';
+import StopComponent from './environment_stop.vue';
import TerminalButtonComponent from './environment_terminal_button.vue';
-import MonitoringButtonComponent from './environment_monitoring.vue';
-import CommitComponent from '../../vue_shared/components/commit.vue';
-import eventHub from '../event_hub';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
/**
* Environment Item Component
@@ -26,21 +27,22 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
components: {
- CommitComponent,
- Icon,
ActionsComponent,
+ CommitComponent,
ExternalUrlComponent,
- StopComponent,
+ Icon,
+ MonitoringButtonComponent,
+ PinComponent,
RollbackComponent,
+ StopComponent,
TerminalButtonComponent,
- MonitoringButtonComponent,
TooltipOnTruncate,
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [environmentItemMixin],
+ mixins: [environmentItemMixin, timeagoMixin],
props: {
canReadEnvironment: {
@@ -52,7 +54,12 @@ export default {
model: {
type: Object,
required: true,
- default: () => ({}),
+ },
+
+ shouldShowAutoStopDate: {
+ type: Boolean,
+ required: false,
+ default: false,
},
tableData: {
@@ -77,6 +84,16 @@ export default {
},
/**
+ * Checkes whether the row displayed is a folder.
+ *
+ * @returns {Boolean}
+ */
+
+ isFolder() {
+ return this.model.isFolder;
+ },
+
+ /**
* Checkes whether the environment is protected.
* (`is_protected` currently only set in EE)
*
@@ -112,24 +129,64 @@ export default {
},
/**
- * Verifies if the date to be shown is present.
+ * Verifies if the autostop date is present.
+ *
+ * @returns {Boolean}
+ */
+ canShowAutoStopDate() {
+ if (!this.model.auto_stop_at) {
+ return false;
+ }
+
+ const autoStopDate = new Date(this.model.auto_stop_at);
+ const now = new Date();
+
+ return now < autoStopDate;
+ },
+
+ /**
+ * Human readable deployment date.
+ *
+ * @returns {String}
+ */
+ autoStopDate() {
+ if (this.canShowAutoStopDate) {
+ return {
+ formatted: this.timeFormatted(this.model.auto_stop_at),
+ tooltip: this.tooltipTitle(this.model.auto_stop_at),
+ };
+ }
+ return {
+ formatted: '',
+ tooltip: '',
+ };
+ },
+
+ /**
+ * Verifies if the deployment date is present.
*
* @returns {Boolean|Undefined}
*/
- canShowDate() {
+ canShowDeploymentDate() {
return this.model && this.model.last_deployment && this.model.last_deployment.deployed_at;
},
/**
- * Human readable date.
+ * Human readable deployment date.
*
* @returns {String}
*/
deployedDate() {
- if (this.canShowDate) {
- return format(this.model.last_deployment.deployed_at);
+ if (this.canShowDeploymentDate) {
+ return {
+ formatted: this.timeFormatted(this.model.last_deployment.deployed_at),
+ tooltip: this.tooltipTitle(this.model.last_deployment.deployed_at),
+ };
}
- return '';
+ return {
+ formatted: '',
+ tooltip: '',
+ };
},
actions() {
@@ -345,6 +402,15 @@ export default {
},
/**
+ * Checkes whether to display no deployment text.
+ *
+ * @returns {Boolean}
+ */
+ showNoDeployments() {
+ return !this.hasLastDeploymentKey && !this.isFolder;
+ },
+
+ /**
* Verifies if the build name column should be rendered by verifing
* if all the information needed is present
* and if the environment is not a folder.
@@ -353,7 +419,7 @@ export default {
*/
shouldRenderBuildName() {
return (
- !this.model.isFolder &&
+ !this.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
!_.isEmpty(this.model.last_deployment.deployable)
);
@@ -383,11 +449,7 @@ export default {
* @return {String}
*/
externalURL() {
- if (this.model && this.model.external_url) {
- return this.model.external_url;
- }
-
- return '';
+ return this.model.external_url || '';
},
/**
@@ -399,26 +461,22 @@ export default {
*/
shouldRenderDeploymentID() {
return (
- !this.model.isFolder &&
+ !this.isFolder &&
!_.isEmpty(this.model.last_deployment) &&
this.model.last_deployment.iid !== undefined
);
},
environmentPath() {
- if (this.model && this.model.environment_path) {
- return this.model.environment_path;
- }
-
- return '';
+ return this.model.environment_path || '';
},
monitoringUrl() {
- if (this.model && this.model.metrics_path) {
- return this.model.metrics_path;
- }
+ return this.model.metrics_path || '';
+ },
- return '';
+ autoStopUrl() {
+ return this.model.cancel_auto_stop_path || '';
},
displayEnvironmentActions() {
@@ -447,7 +505,7 @@ export default {
<div
:class="{
'js-child-row environment-child-row': model.isChildren,
- 'folder-row': model.isFolder,
+ 'folder-row': isFolder,
}"
class="gl-responsive-table-row"
role="row"
@@ -457,7 +515,7 @@ export default {
:class="tableData.name.spacing"
role="gridcell"
>
- <div v-if="!model.isFolder" class="table-mobile-header" role="rowheader">
+ <div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ tableData.name.title }}
</div>
@@ -466,7 +524,7 @@ export default {
</span>
<span
- v-if="!model.isFolder"
+ v-if="!isFolder"
v-gl-tooltip
:title="model.name"
class="environment-name table-mobile-content"
@@ -506,7 +564,7 @@ export default {
{{ deploymentInternalId }}
</span>
- <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word">
+ <span v-if="!isFolder && deploymentHasUser" class="text-break-word">
by
<user-avatar-link
:link-href="deploymentUser.web_url"
@@ -516,6 +574,10 @@ export default {
class="js-deploy-user-container float-none"
/>
</span>
+
+ <div v-if="showNoDeployments" class="commit-title table-mobile-content">
+ {{ s__('Environments|No deployments yet') }}
+ </div>
</div>
<div
@@ -536,14 +598,8 @@ export default {
</a>
</div>
- <div
- v-if="!model.isFolder"
- class="table-section"
- :class="tableData.commit.spacing"
- role="gridcell"
- >
+ <div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
-
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
@@ -554,31 +610,51 @@ export default {
:author="commitAuthor"
/>
</div>
- <div v-if="!hasLastDeploymentKey" class="commit-title table-mobile-content">
- {{ s__('Environments|No deployments yet') }}
- </div>
+ </div>
+
+ <div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
+ <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
+ <span
+ v-if="canShowDeploymentDate"
+ v-gl-tooltip
+ :title="deployedDate.tooltip"
+ class="environment-created-date-timeago table-mobile-content flex-truncate-parent"
+ >
+ <span class="flex-truncate-child">
+ {{ deployedDate.formatted }}
+ </span>
+ </span>
</div>
<div
- v-if="!model.isFolder"
+ v-if="!isFolder && shouldShowAutoStopDate"
class="table-section"
- :class="tableData.date.spacing"
+ :class="tableData.autoStop.spacing"
role="gridcell"
>
- <div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
-
- <span v-if="canShowDate" class="environment-created-date-timeago table-mobile-content">
- {{ deployedDate }}
+ <div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
+ <span
+ v-if="canShowAutoStopDate"
+ v-gl-tooltip
+ :title="autoStopDate.tooltip"
+ class="table-mobile-content flex-truncate-parent"
+ >
+ <span class="flex-truncate-child js-auto-stop">{{ autoStopDate.formatted }}</span>
</span>
</div>
<div
- v-if="!model.isFolder && displayEnvironmentActions"
+ v-if="!isFolder && displayEnvironmentActions"
class="table-section table-button-footer"
:class="tableData.actions.spacing"
role="gridcell"
>
<div class="btn-group table-action-buttons" role="group">
+ <pin-component
+ v-if="canShowAutoStopDate && shouldShowAutoStopDate"
+ :auto-stop-url="autoStopUrl"
+ />
+
<external-url-component
v-if="externalURL && canReadEnvironment"
:external-url="externalURL"
diff --git a/app/assets/javascripts/environments/components/environment_pin.vue b/app/assets/javascripts/environments/components/environment_pin.vue
new file mode 100644
index 00000000000..7908928a7ac
--- /dev/null
+++ b/app/assets/javascripts/environments/components/environment_pin.vue
@@ -0,0 +1,37 @@
+<script>
+/**
+ * Renders a prevent auto-stop button.
+ * Used in environments table.
+ */
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ autoStopUrl: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ onPinClick() {
+ eventHub.$emit('cancelAutoStop', this.autoStopUrl);
+ },
+ },
+ title: __('Prevent environment from auto-stopping'),
+};
+</script>
+<template>
+ <gl-button v-gl-tooltip :title="$options.title" :aria-label="$options.title" @click="onPinClick">
+ <icon name="thumbtack" />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index bafbc00597e..6279bbc83ee 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -8,7 +8,6 @@
import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import eventHub from '../event_hub';
export default {
@@ -16,7 +15,6 @@ export default {
Icon,
GlLoadingIcon,
GlButton,
- ConfirmRollbackModal,
},
directives: {
GlTooltip: GlTooltipDirective,
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 453e7610e21..30299ccc7bc 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -6,6 +6,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import _ from 'underscore';
import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
import { s__ } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import EnvironmentItem from './environment_item.vue';
export default {
@@ -16,7 +17,7 @@ export default {
CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'),
},
- mixins: [environmentTableMixin],
+ mixins: [environmentTableMixin, glFeatureFlagsMixin()],
props: {
environments: {
type: Array,
@@ -42,6 +43,9 @@ export default {
: env,
);
},
+ shouldShowAutoStopDate() {
+ return this.glFeatures.autoStopEnvironments;
+ },
tableData() {
return {
// percent spacing for cols, should add up to 100
@@ -65,8 +69,12 @@ export default {
title: s__('Environments|Updated'),
spacing: 'section-10',
},
+ autoStop: {
+ title: s__('Environments|Auto stop in'),
+ spacing: 'section-5',
+ },
actions: {
- spacing: 'section-30',
+ spacing: this.shouldShowAutoStopDate ? 'section-25' : 'section-30',
},
};
},
@@ -123,6 +131,14 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }}
</div>
+ <div
+ v-if="shouldShowAutoStopDate"
+ class="table-section"
+ :class="tableData.autoStop.spacing"
+ role="columnheader"
+ >
+ {{ tableData.autoStop.title }}
+ </div>
</div>
<template v-for="(model, i) in sortedEnvironments" :model="model">
<div
@@ -130,6 +146,7 @@ export default {
:key="`environment-item-${i}`"
:model="model"
:can-read-environment="canReadEnvironment"
+ :should-show-auto-stop-date="shouldShowAutoStopDate"
:table-data="tableData"
/>
diff --git a/app/assets/javascripts/environments/components/stop_environment_modal.vue b/app/assets/javascripts/environments/components/stop_environment_modal.vue
index 1ea4e30a7c1..43ebd7b2824 100644
--- a/app/assets/javascripts/environments/components/stop_environment_modal.vue
+++ b/app/assets/javascripts/environments/components/stop_environment_modal.vue
@@ -3,7 +3,6 @@
import { GlTooltipDirective } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
import { s__, sprintf } from '~/locale';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
export default {
@@ -12,7 +11,6 @@ export default {
components: {
GlModal: DeprecatedModal2,
- LoadingButton,
},
directives: {
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 31347d95a25..34374e306a4 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -90,16 +90,19 @@ export default {
Flash(s__('Environments|An error occurred while fetching the environments.'));
},
- postAction({ endpoint, errorMessage }) {
+ postAction({
+ endpoint,
+ errorMessage = s__('Environments|An error occurred while making the request.'),
+ }) {
if (!this.isMakingRequest) {
this.isLoading = true;
this.service
.postAction(endpoint)
.then(() => this.fetchEnvironments())
- .catch(() => {
+ .catch(err => {
this.isLoading = false;
- Flash(errorMessage || s__('Environments|An error occurred while making the request.'));
+ Flash(_.isFunction(errorMessage) ? errorMessage(err.response.data) : errorMessage);
});
}
},
@@ -138,6 +141,13 @@ export default {
);
this.postAction({ endpoint: retryUrl, errorMessage });
},
+
+ cancelAutoStop(autoStopPath) {
+ const errorMessage = ({ message }) =>
+ message ||
+ s__('Environments|An error occurred while canceling the auto stop, please try again');
+ this.postAction({ endpoint: autoStopPath, errorMessage });
+ },
},
computed: {
@@ -199,6 +209,8 @@ export default {
eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
+
+ eventHub.$on('cancelAutoStop', this.cancelAutoStop);
},
beforeDestroy() {
@@ -208,5 +220,7 @@ export default {
eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
+
+ eventHub.$off('cancelAutoStop', this.cancelAutoStop);
},
};
diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue
index 14b2e59009a..819d501cba6 100644
--- a/app/assets/javascripts/error_tracking/components/error_details.vue
+++ b/app/assets/javascripts/error_tracking/components/error_details.vue
@@ -1,7 +1,8 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import dateFormat from 'dateformat';
-import { GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { GlButton, GlFormInput, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui';
import { __, sprintf, n__ } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -11,21 +12,41 @@ import TrackEventDirective from '~/vue_shared/directives/track_event';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { trackClickErrorLinkToSentryOptions } from '../utils';
+import query from '../queries/details.query.graphql';
+
export default {
components: {
LoadingButton,
+ GlButton,
GlFormInput,
GlLink,
GlLoadingIcon,
TooltipOnTruncate,
Icon,
Stacktrace,
+ GlBadge,
},
directives: {
TrackEvent: TrackEventDirective,
},
mixins: [timeagoMixin],
props: {
+ listPath: {
+ type: String,
+ required: true,
+ },
+ issueUpdatePath: {
+ type: String,
+ required: true,
+ },
+ issueId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
issueDetailsPath: {
type: String,
required: true,
@@ -43,38 +64,67 @@ export default {
required: true,
},
},
+ apollo: {
+ GQLerror: {
+ query,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ errorId: `gid://gitlab/Gitlab::ErrorTracking::DetailedError/${this.issueId}`,
+ };
+ },
+ pollInterval: 2000,
+ update: data => data.project.sentryDetailedError,
+ error: () => createFlash(__('Failed to load error details from Sentry.')),
+ result(res) {
+ if (res.data.project?.sentryDetailedError) {
+ this.$apollo.queries.GQLerror.stopPolling();
+ }
+ },
+ },
+ },
data() {
return {
+ GQLerror: null,
issueCreationInProgress: false,
};
},
computed: {
- ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']),
+ ...mapState('details', [
+ 'error',
+ 'loading',
+ 'loadingStacktrace',
+ 'stacktraceData',
+ 'updatingResolveStatus',
+ 'updatingIgnoreStatus',
+ ]),
...mapGetters('details', ['stacktrace']),
reported() {
return sprintf(
__('Reported %{timeAgo} by %{reportedBy}'),
{
- reportedBy: `<strong>${this.error.culprit}</strong>`,
+ reportedBy: `<strong>${this.GQLerror.culprit}</strong>`,
timeAgo: this.timeFormatted(this.stacktraceData.date_received),
},
false,
);
},
firstReleaseLink() {
- return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`;
+ return `${this.error.external_base_url}/releases/${this.GQLerror.firstReleaseShortVersion}`;
},
lastReleaseLink() {
- return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`;
+ return `${this.error.external_base_url}releases/${this.GQLerror.lastReleaseShortVersion}`;
},
showDetails() {
- return Boolean(!this.loading && this.error && this.error.id);
+ return Boolean(
+ !this.loading && !this.$apollo.queries.GQLerror.loading && this.error && this.GQLerror,
+ );
},
showStacktrace() {
return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length);
},
issueTitle() {
- return this.error.title;
+ return this.GQLerror.title;
},
issueDescription() {
return sprintf(
@@ -83,29 +133,35 @@ export default {
),
{
description: '# Error Details:\n',
- errorUrl: `${this.error.external_url}\n`,
- firstSeen: `\n${this.error.first_seen}\n`,
- lastSeen: `${this.error.last_seen}\n`,
- countLabel: n__('- Event', '- Events', this.error.count),
- count: `${this.error.count}\n`,
- userCountLabel: n__('- User', '- Users', this.error.user_count),
- userCount: `${this.error.user_count}\n`,
+ errorUrl: `${this.GQLerror.externalUrl}\n`,
+ firstSeen: `\n${this.GQLerror.firstSeen}\n`,
+ lastSeen: `${this.GQLerror.lastSeen}\n`,
+ countLabel: n__('- Event', '- Events', this.GQLerror.count),
+ count: `${this.GQLerror.count}\n`,
+ userCountLabel: n__('- User', '- Users', this.GQLerror.userCount),
+ userCount: `${this.GQLerror.userCount}\n`,
},
false,
);
},
+ errorLevel() {
+ return sprintf(__('level: %{level}'), { level: this.error.tags.level });
+ },
},
mounted() {
this.startPollingDetails(this.issueDetailsPath);
this.startPollingStacktrace(this.issueStackTracePath);
},
methods: {
- ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']),
+ ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace', 'updateStatus']),
trackClickErrorLinkToSentryOptions,
createIssue() {
this.issueCreationInProgress = true;
this.$refs.sentryIssueForm.submit();
},
+ updateIssueStatus(status) {
+ this.updateStatus({ endpoint: this.issueUpdatePath, redirectUrl: this.listPath, status });
+ },
formatDate(date) {
return `${this.timeFormatted(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`;
},
@@ -115,75 +171,118 @@ export default {
<template>
<div>
- <div v-if="loading" class="py-3">
+ <div v-if="$apollo.queries.GQLerror.loading || 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>
- <form ref="sentryIssueForm" :action="projectIssuesPath" method="POST">
- <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
- <input name="issue[description]" :value="issueDescription" type="hidden" />
- <gl-form-input
- :value="error.id"
- class="hidden"
- name="issue[sentry_issue_attributes][sentry_issue_identifier]"
+ <div class="d-inline-flex">
+ <loading-button
+ :label="__('Ignore')"
+ :loading="updatingIgnoreStatus"
+ @click="updateIssueStatus('ignored')"
/>
- <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
<loading-button
- v-if="!error.gitlab_issue"
- class="btn-success"
- :label="__('Create issue')"
- :loading="issueCreationInProgress"
- data-qa-selector="create_issue_button"
- @click="createIssue"
+ class="btn-outline-info ml-2"
+ :label="__('Resolve')"
+ :loading="updatingResolveStatus"
+ @click="updateIssueStatus('resolved')"
/>
- </form>
+ <gl-button
+ v-if="error.gitlab_issue"
+ class="ml-2"
+ data-qa-selector="view_issue_button"
+ :href="error.gitlab_issue"
+ variant="success"
+ >
+ {{ __('View issue') }}
+ </gl-button>
+ <form
+ ref="sentryIssueForm"
+ :action="projectIssuesPath"
+ method="POST"
+ class="d-inline-block ml-2"
+ >
+ <gl-form-input class="hidden" name="issue[title]" :value="issueTitle" />
+ <input name="issue[description]" :value="issueDescription" type="hidden" />
+ <gl-form-input
+ :value="GQLerror.sentryId"
+ class="hidden"
+ name="issue[sentry_issue_attributes][sentry_issue_identifier]"
+ />
+ <gl-form-input :value="csrfToken" class="hidden" name="authenticity_token" />
+ <loading-button
+ v-if="!error.gitlab_issue"
+ class="btn-success"
+ :label="__('Create issue')"
+ :loading="issueCreationInProgress"
+ data-qa-selector="create_issue_button"
+ @click="createIssue"
+ />
+ </form>
+ </div>
</div>
<div>
- <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top">
- <h2 class="text-truncate">{{ error.title }}</h2>
+ <tooltip-on-truncate :title="GQLerror.title" truncate-target="child" placement="top">
+ <h2 class="text-truncate">{{ GQLerror.title }}</h2>
</tooltip-on-truncate>
- <h3>{{ __('Error details') }}</h3>
+ <template v-if="error.tags">
+ <gl-badge v-if="error.tags.level" variant="danger" class="rounded-pill mr-2"
+ >{{ errorLevel }}
+ </gl-badge>
+ <gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
+ >{{ error.tags.logger }}
+ </gl-badge>
+ </template>
<ul>
+ <li v-if="GQLerror.gitlabCommit">
+ <strong class="bold">{{ __('GitLab commit') }}:</strong>
+ <gl-link :href="GQLerror.gitlabCommitPath">
+ <span>{{ GQLerror.gitlabCommit.substr(0, 10) }}</span>
+ </gl-link>
+ </li>
<li v-if="error.gitlab_issue">
- <span class="bold">{{ __('GitLab Issue') }}:</span>
+ <strong class="bold">{{ __('GitLab Issue') }}:</strong>
<gl-link :href="error.gitlab_issue">
<span>{{ error.gitlab_issue }}</span>
</gl-link>
</li>
<li>
- <span class="bold">{{ __('Sentry event') }}:</span>
+ <strong class="bold">{{ __('Sentry event') }}:</strong>
<gl-link
- v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)"
- :href="error.external_url"
+ v-track-event="trackClickErrorLinkToSentryOptions(GQLerror.externalUrl)"
+ class="d-inline-flex align-items-center"
+ :href="GQLerror.externalUrl"
target="_blank"
>
- <span class="text-truncate">{{ error.external_url }}</span>
+ <span class="text-truncate">{{ GQLerror.externalUrl }}</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) }}
+ <li v-if="GQLerror.firstReleaseShortVersion">
+ <strong class="bold">{{ __('First seen') }}:</strong>
+ {{ formatDate(GQLerror.firstSeen) }}
<gl-link :href="firstReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span>
+ <span>
+ {{ __('Release') }}: {{ GQLerror.firstReleaseShortVersion.substr(0, 10) }}
+ </span>
</gl-link>
</li>
- <li v-if="error.last_release_short_version">
- <span class="bold">{{ __('Last seen') }}:</span>
- {{ formatDate(error.last_seen) }}
+ <li v-if="GQLerror.lastReleaseShortVersion">
+ <strong class="bold">{{ __('Last seen') }}:</strong>
+ {{ formatDate(GQLerror.lastSeen) }}
<gl-link :href="lastReleaseLink" target="_blank">
- <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span>
+ <span>{{ __('Release') }}: {{ GQLerror.lastReleaseShortVersion.substr(0, 10) }}</span>
</gl-link>
</li>
<li>
- <span class="bold">{{ __('Events') }}:</span>
- <span>{{ error.count }}</span>
+ <strong class="bold">{{ __('Events') }}:</strong>
+ <span>{{ GQLerror.count }}</span>
</li>
<li>
- <span class="bold">{{ __('Users') }}:</span>
- <span>{{ error.user_count }}</span>
+ <strong class="bold">{{ __('Users') }}:</strong>
+ <span>{{ GQLerror.userCount }}</span>
</li>
</ul>
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 8e2128ac713..3280ff48129 100644
--- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -25,10 +25,47 @@ export default {
PREV_PAGE: 1,
NEXT_PAGE: 2,
fields: [
- { key: 'error', label: __('Open errors'), thClass: 'w-70p' },
- { key: 'events', label: __('Events') },
- { key: 'users', label: __('Users') },
- { key: 'lastSeen', label: __('Last seen'), thClass: 'w-15p' },
+ {
+ key: 'error',
+ label: __('Error'),
+ thClass: 'w-60p',
+ tdClass: 'table-col d-flex d-sm-table-cell px-3',
+ },
+ {
+ key: 'events',
+ label: __('Events'),
+ thClass: 'text-right',
+ tdClass: 'table-col d-flex d-sm-table-cell',
+ },
+ {
+ key: 'users',
+ label: __('Users'),
+ thClass: 'text-right',
+ tdClass: 'table-col d-flex d-sm-table-cell',
+ },
+ {
+ key: 'lastSeen',
+ label: __('Last seen'),
+ thClass: '',
+ tdClass: 'table-col d-flex d-sm-table-cell',
+ },
+ {
+ key: 'ignore',
+ label: '',
+ thClass: 'w-3rem',
+ tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
+ },
+ {
+ key: 'resolved',
+ label: '',
+ thClass: 'w-3rem',
+ tdClass: 'table-col d-flex pl-0 d-sm-table-cell',
+ },
+ {
+ key: 'details',
+ tdClass: 'table-col d-sm-none d-flex align-items-center',
+ thClass: 'invisible w-0',
+ },
],
sortFields: {
last_seen: __('Last Seen'),
@@ -74,6 +111,14 @@ export default {
type: Boolean,
required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ listPath: {
+ type: String,
+ required: true,
+ },
},
hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(),
data() {
@@ -90,6 +135,7 @@ export default {
'sortField',
'recentSearches',
'pagination',
+ 'cursor',
]),
paginationRequired() {
return !_.isEmpty(this.pagination);
@@ -119,6 +165,8 @@ export default {
'clearRecentSearches',
'loadRecentSearches',
'setIndexPath',
+ 'fetchPaginatedResults',
+ 'updateStatus',
]),
setSearchText(text) {
this.errorSearchQuery = text;
@@ -129,10 +177,10 @@ export default {
},
goToNextPage() {
this.pageValue = this.$options.NEXT_PAGE;
- this.startPolling(`${this.indexPath}?cursor=${this.pagination.next.cursor}`);
+ this.fetchPaginatedResults(this.pagination.next.cursor);
},
goToPrevPage() {
- this.startPolling(`${this.indexPath}?cursor=${this.pagination.previous.cursor}`);
+ this.fetchPaginatedResults(this.pagination.previous.cursor);
},
goToPage(page) {
window.scrollTo(0, 0);
@@ -141,6 +189,16 @@ export default {
isCurrentSortField(field) {
return field === this.sortField;
},
+ getIssueUpdatePath(errorId) {
+ return `/${this.projectPath}/-/error_tracking/${errorId}.json`;
+ },
+ updateIssueStatus(errorId, status) {
+ this.updateStatus({
+ endpoint: this.getIssueUpdatePath(errorId),
+ redirectUrl: this.listPath,
+ status,
+ });
+ },
},
};
</script>
@@ -148,62 +206,62 @@ export default {
<template>
<div class="error-list">
<div v-if="errorTrackingEnabled">
- <div
- class="d-flex flex-row justify-content-around align-items-center bg-secondary border mt-2"
- >
- <div class="filtered-search-box flex-grow-1 my-3 ml-3 mr-2">
- <gl-dropdown
- :text="__('Recent searches')"
- class="filtered-search-history-dropdown-wrapper d-none d-md-block"
- toggle-class="filtered-search-history-dropdown-toggle-button"
- :disabled="loading"
- >
- <div v-if="!$options.hasLocalStorage" class="px-3">
- {{ __('This feature requires local storage to be enabled') }}
- </div>
- <template v-else-if="recentSearches.length > 0">
- <gl-dropdown-item
- v-for="searchQuery in recentSearches"
- :key="searchQuery"
- @click="setSearchText(searchQuery)"
- >{{ searchQuery }}</gl-dropdown-item
- >
- <gl-dropdown-divider />
- <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{
- __('Clear recent searches')
- }}</gl-dropdown-item>
- </template>
- <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
- </gl-dropdown>
- <div class="filtered-search-input-container flex-fill">
- <gl-form-input
- v-model="errorSearchQuery"
- class="pl-2 filtered-search"
+ <div class="row flex-column flex-sm-row align-items-sm-center row-top m-0 mt-sm-2 p-0 p-sm-3">
+ <div class="search-box flex-fill mr-sm-2 my-3 m-sm-0 p-3 p-sm-0">
+ <div class="filtered-search-box mb-0">
+ <gl-dropdown
+ :text="__('Recent searches')"
+ class="filtered-search-history-dropdown-wrapper"
+ toggle-class="filtered-search-history-dropdown-toggle-button"
:disabled="loading"
- :placeholder="__('Search or filter resultsā€¦')"
- autofocus
- @keyup.enter.native="searchByQuery(errorSearchQuery)"
- />
- </div>
- <div class="gl-search-box-by-type-right-icons">
- <gl-button
- v-if="errorSearchQuery.length > 0"
- v-gl-tooltip.hover
- :title="__('Clear')"
- class="clear-search text-secondary"
- name="clear"
- @click="errorSearchQuery = ''"
>
- <gl-icon name="close" :size="12" />
- </gl-button>
+ <div v-if="!$options.hasLocalStorage" class="px-3">
+ {{ __('This feature requires local storage to be enabled') }}
+ </div>
+ <template v-else-if="recentSearches.length > 0">
+ <gl-dropdown-item
+ v-for="searchQuery in recentSearches"
+ :key="searchQuery"
+ @click="setSearchText(searchQuery)"
+ >{{ searchQuery }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+ <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches"
+ >{{ __('Clear recent searches') }}
+ </gl-dropdown-item>
+ </template>
+ <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div>
+ </gl-dropdown>
+ <div class="filtered-search-input-container flex-fill">
+ <gl-form-input
+ v-model="errorSearchQuery"
+ class="pl-2 filtered-search"
+ :disabled="loading"
+ :placeholder="__('Search or filter resultsā€¦')"
+ autofocus
+ @keyup.enter.native="searchByQuery(errorSearchQuery)"
+ />
+ </div>
+ <div class="gl-search-box-by-type-right-icons">
+ <gl-button
+ v-if="errorSearchQuery.length > 0"
+ v-gl-tooltip.hover
+ :title="__('Clear')"
+ class="clear-search text-secondary"
+ name="clear"
+ @click="errorSearchQuery = ''"
+ >
+ <gl-icon name="close" :size="12" />
+ </gl-button>
+ </div>
</div>
</div>
<gl-dropdown
+ class="sort-control"
:text="$options.sortFields[sortField]"
left
:disabled="loading"
- class="mr-3"
menu-class="sort-dropdown"
>
<gl-dropdown-item
@@ -227,62 +285,97 @@ export default {
<gl-loading-icon size="md" />
</div>
- <gl-table
- v-else
- class="mt-3"
- :items="errors"
- :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>
- <template slot="HEAD_users" slot-scope="data">
- <div class="text-md-right">{{ data.label }}</div>
- </template>
- <template slot="error" slot-scope="errors">
- <div class="d-flex flex-column">
- <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)">
- <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
- </gl-link>
- <span class="text-secondary text-truncate">
- {{ errors.item.culprit }}
- </span>
- </div>
- </template>
- <template slot="events" slot-scope="errors">
- <div class="text-md-right">{{ errors.item.count }}</div>
- </template>
+ <template v-else>
+ <h4 class="d-block d-sm-none my-3">{{ __('Open errors') }}</h4>
- <template slot="users" slot-scope="errors">
- <div class="text-md-right">{{ errors.item.userCount }}</div>
- </template>
+ <gl-table
+ class="mt-3"
+ :items="errors"
+ :fields="$options.fields"
+ :show-empty="true"
+ fixed
+ stacked="sm"
+ tbody-tr-class="table-row mb-4"
+ >
+ <template v-slot:head(error)>
+ <div class="d-none d-sm-block">{{ __('Open errors') }}</div>
+ </template>
+ <template v-slot:head(events)="data">
+ <div class="text-sm-right">{{ data.label }}</div>
+ </template>
+ <template v-slot:head(users)="data">
+ <div class="text-sm-right">{{ data.label }}</div>
+ </template>
- <template slot="lastSeen" slot-scope="errors">
- <div class="d-flex align-items-center">
- <time-ago :time="errors.item.lastSeen" class="text-secondary" />
- </div>
- </template>
- <template slot="empty">
- <div ref="empty">
+ <template v-slot:error="errors">
+ <div class="d-flex flex-column">
+ <gl-link class="d-flex mw-100 text-dark" :href="getDetailsLink(errors.item.id)">
+ <strong class="text-truncate">{{ errors.item.title.trim() }}</strong>
+ </gl-link>
+ <span class="text-secondary text-truncate mw-100">
+ {{ errors.item.culprit }}
+ </span>
+ </div>
+ </template>
+ <template v-slot:events="errors">
+ <div class="text-right">{{ errors.item.count }}</div>
+ </template>
+
+ <template v-slot:users="errors">
+ <div class="text-right">{{ errors.item.userCount }}</div>
+ </template>
+
+ <template v-slot:lastSeen="errors">
+ <div class="text-md-left text-right">
+ <time-ago :time="errors.item.lastSeen" class="text-secondary" />
+ </div>
+ </template>
+ <template v-slot:ignore="errors">
+ <gl-button
+ ref="ignoreError"
+ v-gl-tooltip.hover
+ :title="__('Ignore')"
+ @click="updateIssueStatus(errors.item.id, 'ignored')"
+ >
+ <gl-icon name="eye-slash" :size="12" />
+ </gl-button>
+ </template>
+ <template v-slot:resolved="errors">
+ <gl-button
+ ref="resolveError"
+ v-gl-tooltip
+ :title="__('Resolve')"
+ @click="updateIssueStatus(errors.item.id, 'resolved')"
+ >
+ <gl-icon name="check-circle" :size="12" />
+ </gl-button>
+ </template>
+ <template v-slot:details="errors">
+ <gl-button
+ :href="getDetailsLink(errors.item.id)"
+ variant="outline-info"
+ class="d-block"
+ >
+ {{ __('More details') }}
+ </gl-button>
+ </template>
+ <template v-slot:empty>
{{ __('No errors to display.') }}
<gl-link class="js-try-again" @click="restartPolling">
{{ __('Check again') }}
</gl-link>
- </div>
- </template>
- </gl-table>
- <gl-pagination
- v-show="!loading"
- v-if="paginationRequired"
- :prev-page="$options.PREV_PAGE"
- :next-page="$options.NEXT_PAGE"
- :value="pageValue"
- align="center"
- @input="goToPage"
- />
+ </template>
+ </gl-table>
+ <gl-pagination
+ v-show="!loading"
+ v-if="paginationRequired"
+ :prev-page="$options.PREV_PAGE"
+ :next-page="$options.NEXT_PAGE"
+ :value="pageValue"
+ align="center"
+ @input="goToPage"
+ />
+ </template>
</div>
<div v-else-if="userCanEnableErrorTracking">
<gl-empty-state
diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
index 62fd379aa4c..4e63e167260 100644
--- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
+++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
@@ -56,17 +57,36 @@ export default {
collapseIcon() {
return this.isExpanded ? 'chevron-down' : 'chevron-right';
},
- noCodeFn() {
- return this.errorFn ? sprintf(__('in %{errorFn} '), { errorFn: this.errorFn }) : '';
+ errorFnText() {
+ return this.errorFn
+ ? sprintf(
+ __(`%{spanStart}in%{spanEnd} %{errorFn}`),
+ {
+ errorFn: `<strong>${_.escape(this.errorFn)}</strong>`,
+ spanStart: `<span class="text-tertiary">`,
+ spanEnd: `</span>`,
+ },
+ false,
+ )
+ : '';
},
- noCodeLine() {
+ errorPositionText() {
return this.errorLine
- ? sprintf(__('at line %{errorLine}%{errorColumn}'), {
- errorLine: this.errorLine,
- errorColumn: this.errorColumn ? `:${this.errorColumn}` : '',
- })
+ ? sprintf(
+ __(`%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}`),
+ {
+ errorLine: `<strong>${this.errorLine}</strong>`,
+ errorColumn: this.errorColumn ? `:<strong>${this.errorColumn}</strong>` : ``,
+ spanStart: `<span class="text-tertiary">`,
+ spanEnd: `</span>`,
+ },
+ false,
+ )
: '';
},
+ errorInfo() {
+ return `${this.errorFnText} ${this.errorPositionText}`;
+ },
},
methods: {
isHighlighted(lineNum) {
@@ -102,8 +122,7 @@ export default {
<strong
v-gl-tooltip
:title="filePath"
- class="file-title-name d-inline-block overflow-hidden text-truncate"
- :class="{ 'limited-width': !hasCode }"
+ class="file-title-name d-inline-block overflow-hidden text-truncate limited-width"
data-container="body"
>
{{ filePath }}
@@ -113,7 +132,7 @@ export default {
:text="filePath"
css-class="btn-default btn-transparent btn-clipboard position-static"
/>
- <span v-if="!hasCode" class="text-tertiary">{{ noCodeFn }}{{ noCodeLine }}</span>
+ <span v-html="errorInfo"></span>
</div>
</div>
diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js
index 872cb8868a2..c18298dec4f 100644
--- a/app/assets/javascripts/error_tracking/details.js
+++ b/app/assets/javascripts/error_tracking/details.js
@@ -1,22 +1,43 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
import store from './store';
import ErrorDetails from './components/error_details.vue';
import csrf from '~/lib/utils/csrf';
+Vue.use(VueApollo);
+
export default () => {
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+
// eslint-disable-next-line no-new
new Vue({
el: '#js-error_details',
+ apolloProvider,
components: {
ErrorDetails,
},
store,
render(createElement) {
const domEl = document.querySelector(this.$options.el);
- const { issueDetailsPath, issueStackTracePath, projectIssuesPath } = domEl.dataset;
+ const {
+ issueId,
+ projectPath,
+ listPath,
+ issueUpdatePath,
+ issueDetailsPath,
+ issueStackTracePath,
+ projectIssuesPath,
+ } = domEl.dataset;
return createElement('error-details', {
props: {
+ issueId,
+ projectPath,
+ listPath,
+ issueUpdatePath,
issueDetailsPath,
issueStackTracePath,
projectIssuesPath,
diff --git a/app/assets/javascripts/error_tracking/list.js b/app/assets/javascripts/error_tracking/list.js
index 073e2c8f1c7..8f3700249da 100644
--- a/app/assets/javascripts/error_tracking/list.js
+++ b/app/assets/javascripts/error_tracking/list.js
@@ -13,7 +13,13 @@ export default () => {
store,
render(createElement) {
const domEl = document.querySelector(this.$options.el);
- const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset;
+ const {
+ indexPath,
+ enableErrorTrackingLink,
+ illustrationPath,
+ projectPath,
+ listPath,
+ } = domEl.dataset;
let { errorTrackingEnabled, userCanEnableErrorTracking } = domEl.dataset;
errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
@@ -26,6 +32,8 @@ export default () => {
errorTrackingEnabled,
illustrationPath,
userCanEnableErrorTracking,
+ projectPath,
+ listPath,
},
});
},
diff --git a/app/assets/javascripts/error_tracking/queries/details.query.graphql b/app/assets/javascripts/error_tracking/queries/details.query.graphql
new file mode 100644
index 00000000000..625ce3030d9
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/queries/details.query.graphql
@@ -0,0 +1,20 @@
+query errorDetails($fullPath: ID!, $errorId: ID!) {
+ project(fullPath: $fullPath) {
+ sentryDetailedError(id: $errorId) {
+ id
+ sentryId
+ title
+ userCount
+ count
+ firstSeen
+ lastSeen
+ message
+ culprit
+ externalUrl
+ firstReleaseShortVersion
+ lastReleaseShortVersion
+ gitlabCommit
+ gitlabCommitPath
+ }
+ }
+}
diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js
index 3b3f8311d67..3fb317c17f5 100644
--- a/app/assets/javascripts/error_tracking/services/index.js
+++ b/app/assets/javascripts/error_tracking/services/index.js
@@ -4,4 +4,7 @@ export default {
getSentryData({ endpoint, params }) {
return axios.get(endpoint, { params });
},
+ updateErrorStatus(endpoint, status) {
+ return axios.put(endpoint, { status });
+ },
};
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
new file mode 100644
index 00000000000..bb8b039b5df
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -0,0 +1,19 @@
+import service from './../services';
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import { visitUrl } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+
+export function updateStatus({ commit }, { endpoint, redirectUrl, status }) {
+ const type =
+ status === 'resolved' ? types.SET_UPDATING_RESOLVE_STATUS : types.SET_UPDATING_IGNORE_STATUS;
+ commit(type, true);
+
+ return service
+ .updateErrorStatus(endpoint, status)
+ .then(() => visitUrl(redirectUrl))
+ .catch(() => createFlash(__('Failed to update issue status')))
+ .finally(() => commit(type, false));
+}
+
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js
index 95fb0ba0558..52b0297607d 100644
--- a/app/assets/javascripts/error_tracking/store/details/state.js
+++ b/app/assets/javascripts/error_tracking/store/details/state.js
@@ -3,4 +3,6 @@ export default () => ({
stacktraceData: {},
loading: true,
loadingStacktrace: true,
+ updatingResolveStatus: false,
+ updatingIgnoreStatus: false,
});
diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js
index ad05eecef6c..d9206bc8d7c 100644
--- a/app/assets/javascripts/error_tracking/store/index.js
+++ b/app/assets/javascripts/error_tracking/store/index.js
@@ -1,6 +1,9 @@
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';
@@ -18,14 +21,14 @@ export const createStore = () =>
list: {
namespaced: true,
state: listState(),
- actions: listActions,
- mutations: listMutations,
+ actions: { ...actions, ...listActions },
+ mutations: { ...mutations, ...listMutations },
},
details: {
namespaced: true,
state: detailsState(),
- actions: detailsActions,
- mutations: detailsMutations,
+ actions: { ...actions, ...detailsActions },
+ mutations: { ...mutations, ...detailsMutations },
getters: detailsGetters,
},
},
diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js
index c9e882c4ed2..d96ac7f524e 100644
--- a/app/assets/javascripts/error_tracking/store/list/actions.js
+++ b/app/assets/javascripts/error_tracking/store/list/actions.js
@@ -17,12 +17,14 @@ export function startPolling({ state, commit, dispatch }) {
params: {
search_term: state.searchQuery,
sort: state.sortField,
+ cursor: state.cursor,
},
},
successCallback: ({ data }) => {
if (!data) {
return;
}
+
commit(types.SET_PAGINATION, data.pagination);
commit(types.SET_ERRORS, data.errors);
commit(types.SET_LOADING, false);
@@ -74,6 +76,7 @@ export function clearRecentSearches({ commit }) {
export const searchByQuery = ({ commit, dispatch }, query) => {
const searchQuery = query.trim();
+ commit(types.SET_CURSOR, null);
commit(types.SET_SEARCH_QUERY, searchQuery);
commit(types.ADD_RECENT_SEARCH, searchQuery);
dispatch('stopPolling');
@@ -81,6 +84,7 @@ export const searchByQuery = ({ commit, dispatch }, query) => {
};
export const sortByField = ({ commit, dispatch }, field) => {
+ commit(types.SET_CURSOR, null);
commit(types.SET_SORT_FIELD, field);
dispatch('stopPolling');
dispatch('startPolling');
@@ -90,4 +94,10 @@ export const setEndpoint = ({ commit }, endpoint) => {
commit(types.SET_ENDPOINT, endpoint);
};
+export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => {
+ commit(types.SET_CURSOR, cursor);
+ dispatch('stopPolling');
+ dispatch('startPolling');
+};
+
export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
index 301984a1ee0..c3468b7eabd 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js
@@ -8,3 +8,4 @@ export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_ENDPOINT = 'SET_ENDPOINT';
export const SET_SORT_FIELD = 'SET_SORT_FIELD';
export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY';
+export const SET_CURSOR = 'SET_CURSOR';
diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js
index 5648013bb89..dd5cde0576a 100644
--- a/app/assets/javascripts/error_tracking/store/list/mutations.js
+++ b/app/assets/javascripts/error_tracking/store/list/mutations.js
@@ -47,6 +47,9 @@ export default {
[types.SET_PAGINATION](state, pagination) {
state.pagination = pagination;
},
+ [types.SET_CURSOR](state, cursor) {
+ state.cursor = cursor;
+ },
[types.SET_SORT_FIELD](state, field) {
state.sortField = field;
},
diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js
index 93dc1040fde..225a805e709 100644
--- a/app/assets/javascripts/error_tracking/store/list/state.js
+++ b/app/assets/javascripts/error_tracking/store/list/state.js
@@ -7,4 +7,5 @@ export default () => ({
indexPath: '',
recentSearches: [],
pagination: {},
+ cursor: null,
});
diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js
new file mode 100644
index 00000000000..30aebacbedd
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/mutation_types.js
@@ -0,0 +1,2 @@
+export const SET_UPDATING_RESOLVE_STATUS = 'SET_UPDATING_RESOLVE_STATUS';
+export const SET_UPDATING_IGNORE_STATUS = 'SET_UPDATING_IGNORE_STATUS';
diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js
new file mode 100644
index 00000000000..c7a7e46df40
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/mutations.js
@@ -0,0 +1,10 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_UPDATING_IGNORE_STATUS](state, updating) {
+ state.updatingIgnoreStatus = updating;
+ },
+ [types.SET_UPDATING_RESOLVE_STATUS](state, updating) {
+ state.updatingResolveStatus = updating;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
index 82df02afafd..11fd06fb40b 100644
--- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -1,14 +1,11 @@
<script>
-import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { getDisplayName } from '../utils';
export default {
components: {
GlDropdown,
- GlDropdownHeader,
GlDropdownItem,
- Icon,
},
props: {
dropdownLabel: {
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
index 6b540ea7dfd..3f1ac426278 100644
--- a/app/assets/javascripts/error_tracking_settings/store/actions.js
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -25,8 +25,8 @@ export const receiveProjectsError = ({ commit }) => {
export const fetchProjects = ({ dispatch, state }) => {
dispatch('requestProjects');
return axios
- .post(state.listProjectsEndpoint, {
- error_tracking_setting: {
+ .get(state.listProjectsEndpoint, {
+ params: {
api_host: state.apiHost,
token: state.token,
},
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_options.js b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
index 212643b1e04..c5553f0243f 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight_options.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight_options.js
@@ -1,8 +1,8 @@
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { highlightFeatures } from './feature_highlight';
-import bp from '../breakpoints';
export default function domContentLoaded() {
- if (bp.getBreakpointSize() === 'lg') {
+ if (bp.getBreakpointSize() === 'xl') {
highlightFeatures();
return true;
}
diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js
index c21fba06d42..be2eee828ff 100644
--- a/app/assets/javascripts/filterable_list.js
+++ b/app/assets/javascripts/filterable_list.js
@@ -64,6 +64,7 @@ export default class FilterableList {
return false;
}
+ // eslint-disable-next-line no-jquery/no-fade
$(this.listHolderElement).fadeTo(250, 0.5);
this.isBusy = true;
@@ -98,6 +99,7 @@ export default class FilterableList {
onFilterComplete() {
this.isBusy = false;
+ // eslint-disable-next-line no-jquery/no-fade
$(this.listHolderElement).fadeTo(250, 1);
}
}
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
index e020628a473..9440015b32e 100644
--- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -2,6 +2,7 @@ import { __ } from '~/locale';
export default IssuableTokenKeys => {
const wipToken = {
+ formattedKey: __('WIP'),
key: 'wip',
type: 'string',
param: '',
@@ -17,6 +18,7 @@ export default IssuableTokenKeys => {
IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
const targetBranchToken = {
+ formattedKey: __('Target-Branch'),
key: 'target-branch',
type: 'string',
param: '',
diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
index 691d165c585..42d0fbacca0 100644
--- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
@@ -1,7 +1,9 @@
+import { __ } from '~/locale';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [
{
+ formattedKey: __('Status'),
key: 'status',
type: 'string',
param: 'status',
@@ -10,6 +12,7 @@ const tokenKeys = [
tag: 'status',
},
{
+ formattedKey: __('Type'),
key: 'type',
type: 'string',
param: 'type',
@@ -18,6 +21,7 @@ const tokenKeys = [
tag: 'type',
},
{
+ formattedKey: __('Tag'),
key: 'tag',
type: 'array',
param: 'name[]',
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 5fa07045d5e..5450abf4cbd 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -4,6 +4,7 @@ import DropdownNonUser from './dropdown_non_user';
import DropdownEmoji from './dropdown_emoji';
import NullDropdown from './null_dropdown';
import DropdownAjaxFilter from './dropdown_ajax_filter';
+import DropdownOperator from './dropdown_operator';
import DropdownUtils from './dropdown_utils';
import { mergeUrlParams } from '../lib/utils/url_utility';
@@ -40,6 +41,11 @@ export default class AvailableDropdownMappings {
gl: DropdownHint,
element: this.container.querySelector('#js-dropdown-hint'),
},
+ operator: {
+ reference: null,
+ gl: DropdownOperator,
+ element: this.container.querySelector('#js-dropdown-operator'),
+ },
};
supportedTokens.forEach(type => {
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 4757c4b1e43..fa2609a3176 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -29,6 +29,7 @@ export default {
const resultantTokens = tokens.map(token => ({
prefix: `${token.key}:`,
+ operator: token.operator,
suffix: `${token.symbol}${token.value}`,
}));
@@ -75,6 +76,7 @@ export default {
class="filtered-search-history-dropdown-token"
>
<span class="name">{{ token.prefix }}</span>
+ <span class="name">{{ token.operator }}</span>
<span class="value">{{ token.suffix }}</span>
</span>
</span>
diff --git a/app/assets/javascripts/filtered_search/constants.js b/app/assets/javascripts/filtered_search/constants.js
index b11111f1081..d7264e96b13 100644
--- a/app/assets/javascripts/filtered_search/constants.js
+++ b/app/assets/javascripts/filtered_search/constants.js
@@ -1,2 +1,6 @@
-/* eslint-disable import/prefer-default-export */
export const USER_TOKEN_TYPES = ['author', 'assignee'];
+
+export const DROPDOWN_TYPE = {
+ hint: 'hint',
+ operator: 'operator',
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
index b27bb63c220..92a64ab60db 100644
--- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -45,7 +45,7 @@ export default class DropdownAjaxFilter extends FilteredSearchDropdown {
getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input);
- const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.getKeys());
let value = lastToken || '';
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 1a1135ae929..4f10b6ba9c3 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -3,6 +3,7 @@ import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { __ } from '~/locale';
export default class DropdownHint extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -30,8 +31,8 @@ export default class DropdownHint extends FilteredSearchDropdown {
this.dismissDropdown();
this.dispatchFormSubmitEvent();
} else {
- const token = selected.querySelector('.js-filter-hint').innerText.trim();
- const tag = selected.querySelector('.js-filter-tag').innerText.trim();
+ const filterItemEl = selected.closest('.filter-dropdown-item');
+ const { hint: token, tag } = filterItemEl.dataset;
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
@@ -55,8 +56,13 @@ export default class DropdownHint extends FilteredSearchDropdown {
const key = token.replace(':', '');
const { uppercaseTokenName } = this.tokenKeys.searchByKey(key);
- FilteredSearchDropdownManager.addWordToInput(key, '', false, {
- uppercaseTokenName,
+
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: key,
+ clicked: false,
+ options: {
+ uppercaseTokenName,
+ },
});
}
this.dismissDropdown();
@@ -66,15 +72,30 @@ export default class DropdownHint extends FilteredSearchDropdown {
}
renderContent() {
- const dropdownData = this.tokenKeys.get().map(tokenKey => ({
- icon: `${gon.sprite_icons}#${tokenKey.icon}`,
- hint: tokenKey.key,
- tag: `:${tokenKey.tag}`,
- type: tokenKey.type,
- }));
+ const searchItem = [
+ {
+ hint: 'search',
+ tag: 'search',
+ formattedKey: __('Search for this text'),
+ icon: `${gon.sprite_icons}#search`,
+ },
+ ];
+
+ const dropdownData = this.tokenKeys
+ .get()
+ .map(tokenKey => ({
+ icon: `${gon.sprite_icons}#${tokenKey.icon}`,
+ hint: tokenKey.key,
+ tag: `:${tokenKey.tag}`,
+ type: tokenKey.type,
+ formattedKey: tokenKey.formattedKey,
+ }))
+ .concat(searchItem);
this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
+
+ super.renderContent();
}
init() {
diff --git a/app/assets/javascripts/filtered_search/dropdown_operator.js b/app/assets/javascripts/filtered_search/dropdown_operator.js
new file mode 100644
index 00000000000..bd4fda29609
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_operator.js
@@ -0,0 +1,65 @@
+import Filter from '~/droplab/plugins/filter';
+import { __ } from '~/locale';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+
+export default class DropdownOperator extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { input, tokenKeys } = options;
+ super(options);
+
+ this.config = {
+ Filter: {
+ filterFunction: DropdownUtils.filterWithSymbol.bind(null, '', input),
+ template: 'title',
+ },
+ };
+ this.tokenKeys = tokenKeys;
+ }
+
+ itemClicked(e) {
+ const { selected } = e.detail;
+
+ if (selected.tagName === 'LI') {
+ if (selected.hasAttribute('data-value')) {
+ const operator = selected.dataset.value;
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: this.filter,
+ tokenOperator: operator,
+ clicked: false,
+ });
+ }
+ }
+ this.dismissDropdown();
+ this.dispatchInputEvent();
+ }
+
+ renderContent(forceShowList = false) {
+ this.filter = FilteredSearchVisualTokens.getLastTokenPartial();
+
+ const dropdownData = [
+ {
+ tag: 'equal',
+ type: 'string',
+ title: '=',
+ help: __('Is'),
+ },
+ {
+ tag: 'not-equal',
+ type: 'string',
+ title: '!=',
+ help: __('Is not'),
+ },
+ ];
+ this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
+ this.droplab.setData(this.hookId, dropdownData);
+ super.renderContent(forceShowList);
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 8d92af2cf7e..274c08e6955 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -62,28 +62,42 @@ export default class DropdownUtils {
const lastKey = lastToken.key || lastToken || '';
const allowMultiple = item.type === 'array';
const itemInExistingTokens = tokens.some(t => t.key === item.hint);
+ const isSearchItem = updatedItem.hint === 'search';
+
+ if (isSearchItem) {
+ updatedItem.droplab_hidden = true;
+ }
if (!allowMultiple && itemInExistingTokens) {
updatedItem.droplab_hidden = true;
- } else if (!lastKey || _.last(searchInput.split('')) === ' ') {
+ } else if (!isSearchItem && (!lastKey || _.last(searchInput.split('')) === ' ')) {
updatedItem.droplab_hidden = false;
} else if (lastKey) {
const split = lastKey.split(':');
const tokenName = _.last(split[0].split(' '));
- const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+ const match = isSearchItem
+ ? allowedKeys.some(key => key.startsWith(tokenName.toLowerCase()))
+ : updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1;
+
updatedItem.droplab_hidden = tokenName ? match : false;
}
return updatedItem;
}
- static setDataValueIfSelected(filter, selected) {
+ static setDataValueIfSelected(filter, operator, selected) {
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
- FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true, {
- capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: filter,
+ tokenOperator: operator,
+ tokenValue: dataValue,
+ clicked: true,
+ options: {
+ capitalizeTokenValue: selected.hasAttribute('data-capitalize'),
+ },
});
}
@@ -101,7 +115,11 @@ export default class DropdownUtils {
// remove leading symbol and wrapping quotes
tokenValue = tokenValue.replace(/^~("|')?(.*)/, '$2').replace(/("|')$/, '');
}
- return { tokenName, tokenValue };
+
+ const operatorEl = visualToken && visualToken.querySelector('.operator');
+ const tokenOperator = operatorEl && operatorEl.textContent.trim();
+
+ return { tokenName, tokenOperator, tokenValue };
}
// Determines the full search query (visual tokens + input)
@@ -119,10 +137,16 @@ export default class DropdownUtils {
tokens.forEach(token => {
if (token.classList.contains('js-visual-token')) {
const name = token.querySelector('.name');
+ const operatorContainer = token.querySelector('.operator');
const value = token.querySelector('.value');
const valueContainer = token.querySelector('.value-container');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
+ let operator = '';
+
+ if (operatorContainer) {
+ operator = operatorContainer.textContent.trim();
+ }
if (valueContainer && valueContainer.dataset.originalValue) {
valueText = valueContainer.dataset.originalValue;
@@ -131,7 +155,7 @@ export default class DropdownUtils {
}
if (token.className.indexOf('filtered-search-token') !== -1) {
- values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
+ values.push(`${name.innerText.toLowerCase()}:${operator}${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index 146d3ba963c..72565c2ca13 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -1,5 +1,6 @@
import DropdownUtils from './dropdown_utils';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
+import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger';
@@ -31,13 +32,26 @@ export default class FilteredSearchDropdown {
itemClicked(e, getValueFunction) {
const { selected } = e.detail;
-
if (selected.tagName === 'LI' && selected.innerHTML) {
- const dataValueSet = DropdownUtils.setDataValueIfSelected(this.filter, selected);
+ const {
+ lastVisualToken: visualToken,
+ } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+ const { tokenOperator } = DropdownUtils.getVisualTokenValues(visualToken);
+
+ const dataValueSet = DropdownUtils.setDataValueIfSelected(
+ this.filter,
+ tokenOperator,
+ selected,
+ );
if (!dataValueSet) {
const value = getValueFunction(selected);
- FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
+ FilteredSearchDropdownManager.addWordToInput({
+ tokenName: this.filter,
+ tokenOperator,
+ tokenValue: value,
+ clicked: true,
+ });
}
this.resetFilters();
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 5ff95f45be4..566fb295588 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -5,6 +5,7 @@ import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
+import { DROPDOWN_TYPE } from './constants';
export default class FilteredSearchDropdownManager {
constructor({
@@ -67,10 +68,16 @@ export default class FilteredSearchDropdownManager {
this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
- static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
+ static addWordToInput({
+ tokenName,
+ tokenOperator = '',
+ tokenValue = '',
+ clicked = false,
+ options = {},
+ }) {
const { uppercaseTokenName = false, capitalizeTokenValue = false } = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue, {
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenOperator, tokenValue, {
uppercaseTokenName,
capitalizeTokenValue,
});
@@ -129,7 +136,10 @@ export default class FilteredSearchDropdownManager {
mappingKey.reference.init();
}
- if (this.currentDropdown === 'hint') {
+ if (
+ this.currentDropdown === DROPDOWN_TYPE.hint ||
+ this.currentDropdown === DROPDOWN_TYPE.operator
+ ) {
// Force the dropdown to show if it was clicked from the hint dropdown
forceShowList = true;
}
@@ -148,13 +158,19 @@ export default class FilteredSearchDropdownManager {
this.droplab = new DropLab();
}
+ if (dropdownName === DROPDOWN_TYPE.operator) {
+ this.load(dropdownName, firstLoad);
+ return;
+ }
+
const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase());
const shouldOpenFilterDropdown =
match && this.currentDropdown !== match.key && this.mapping[match.key];
- const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint';
+ const shouldOpenHintDropdown = !match && this.currentDropdown !== DROPDOWN_TYPE.hint;
if (shouldOpenFilterDropdown || shouldOpenHintDropdown) {
- const key = match && match.key ? match.key : 'hint';
+ const key = match && match.key ? match.key : DROPDOWN_TYPE.hint;
+
this.load(key, firstLoad);
}
}
@@ -169,19 +185,32 @@ export default class FilteredSearchDropdownManager {
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
}
-
if (lastToken === searchToken && lastToken !== null) {
// Token is not fully initialized yet because it has no value
// Eg. token = 'label:'
const split = lastToken.split(':');
const dropdownName = _.last(split[0].split(' '));
- this.loadDropdown(split.length > 1 ? dropdownName : '');
+ const possibleOperatorToken = _.last(split[1]);
+
+ const hasOperator = FilteredSearchVisualTokens.permissibleOperatorValues.includes(
+ possibleOperatorToken && possibleOperatorToken.trim(),
+ );
+
+ let dropdownToOpen = '';
+
+ if (split.length > 1) {
+ const lastOperatorToken = FilteredSearchVisualTokens.getLastTokenOperator();
+ dropdownToOpen = hasOperator && lastOperatorToken ? dropdownName : DROPDOWN_TYPE.operator;
+ }
+
+ this.loadDropdown(dropdownToOpen);
} else if (lastToken) {
+ const lastOperator = FilteredSearchVisualTokens.getLastTokenOperator();
// Token has been initialized into an object because it has a value
- this.loadDropdown(lastToken.key);
+ this.loadDropdown(lastOperator ? lastToken.key : DROPDOWN_TYPE.operator);
} else {
- this.loadDropdown('hint');
+ this.loadDropdown(DROPDOWN_TYPE.hint);
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index a4edc5fd52d..0b4f9457c54 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -14,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
+import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes';
import { __ } from '~/locale';
export default class FilteredSearchManager {
@@ -58,6 +59,8 @@ export default class FilteredSearchManager {
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
+ static notTransformableQueryParams = ['scope', 'utf8', 'state', 'search'];
+
setup() {
// Fetch recent searches from localStorage
this.fetchingRecentSearchesPromise = this.recentSearchesService
@@ -84,6 +87,7 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer;
+
this.dropdownManager = new FilteredSearchDropdownManager({
runnerTagsEndpoint:
this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
@@ -172,7 +176,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper);
- this.filteredSearchInput.addEventListener('input', this.handleInputVisualTokenWrapper);
+ this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange);
@@ -194,7 +198,7 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper);
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
- this.filteredSearchInput.removeEventListener('input', this.handleInputVisualTokenWrapper);
+ this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange);
@@ -228,7 +232,7 @@ export default class FilteredSearchManager {
if (backspaceCount === 2) {
backspaceCount = 0;
- this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial();
+ this.filteredSearchInput.value = FilteredSearchVisualTokens.getLastTokenPartial(true);
FilteredSearchVisualTokens.removeLastTokenPartial();
}
}
@@ -407,7 +411,12 @@ export default class FilteredSearchManager {
}
}
- handleInputVisualToken() {
+ handleInputVisualToken(e) {
+ // If the keyCode was 8 then do not form new tokens
+ if (e.keyCode === BACKSPACE_KEY_CODE) {
+ return;
+ }
+
const input = this.filteredSearchInput;
const { tokens, searchToken } = this.tokenizer.processTokens(
input.value,
@@ -417,14 +426,21 @@ export default class FilteredSearchManager {
if (isLastVisualTokenValid) {
tokens.forEach(t => {
- input.value = input.value.replace(`${t.key}:${t.symbol}${t.value}`, '');
- FilteredSearchVisualTokens.addFilterVisualToken(t.key, `${t.symbol}${t.value}`, {
- uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
- capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
- });
+ input.value = input.value.replace(`${t.key}:${t.operator}${t.symbol}${t.value}`, '');
+
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ t.key,
+ t.operator,
+ `${t.symbol}${t.value}`,
+ {
+ uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(t.key),
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(t.key),
+ },
+ );
});
const fragments = searchToken.split(':');
+
if (fragments.length > 1) {
const inputValues = fragments[0].split(' ');
const tokenKey = _.last(inputValues);
@@ -437,19 +453,58 @@ export default class FilteredSearchManager {
FilteredSearchVisualTokens.addSearchVisualToken(searchTerms);
}
- FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, {
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, null, null, {
uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(tokenKey),
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
input.value = input.value.replace(`${tokenKey}:`, '');
}
+
+ const splitSearchToken = searchToken && searchToken.split(' ');
+ let lastSearchToken = _.last(splitSearchToken);
+ lastSearchToken = lastSearchToken?.toLowerCase();
+
+ /**
+ * If user writes "milestone", a known token, in the input, we should not
+ * wait for leading colon to flush it as a filter token.
+ */
+ if (this.filteredSearchTokenKeys.getKeys().includes(lastSearchToken)) {
+ if (splitSearchToken.length > 1) {
+ splitSearchToken.pop();
+ const searchVisualTokens = splitSearchToken.join(' ');
+
+ input.value = input.value.replace(searchVisualTokens, '');
+ FilteredSearchVisualTokens.addSearchVisualToken(searchVisualTokens);
+ }
+ FilteredSearchVisualTokens.addFilterVisualToken(lastSearchToken, null, null, {
+ uppercaseTokenName: this.filteredSearchTokenKeys.shouldUppercaseTokenName(
+ lastSearchToken,
+ ),
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(
+ lastSearchToken,
+ ),
+ });
+ input.value = input.value.replace(lastSearchToken, '');
+ }
+ } else if (!isLastVisualTokenValid && !FilteredSearchVisualTokens.getLastTokenOperator()) {
+ const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
+ const tokenOperator = searchToken && searchToken.trim();
+
+ // Tokenize operator only if the operator token is valid
+ if (FilteredSearchVisualTokens.permissibleOperatorValues.includes(tokenOperator)) {
+ FilteredSearchVisualTokens.removeLastTokenPartial();
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenKey, tokenOperator, null, {
+ capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
+ });
+ input.value = input.value.replace(searchToken, '').trim();
+ }
} else {
// Keep listening to token until we determine that the user is done typing the token value
const valueCompletedRegex = /([~%@]{0,1}".+")|([~%@]{0,1}'.+')|^((?![~%@]')(?![~%@]")(?!')(?!")).*/g;
if (searchToken.match(valueCompletedRegex) && input.value[input.value.length - 1] === ' ') {
const tokenKey = FilteredSearchVisualTokens.getLastTokenPartial();
- FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, {
+ FilteredSearchVisualTokens.addFilterVisualToken(searchToken, null, null, {
capitalizeTokenValue: this.filteredSearchTokenKeys.shouldCapitalizeTokenValue(tokenKey),
});
@@ -484,9 +539,52 @@ export default class FilteredSearchManager {
return this.modifyUrlParams ? this.modifyUrlParams(urlParams) : urlParams;
}
+ transformParams(params) {
+ /**
+ * Extract key, value pair from the `not` query param:
+ * Query param looks like not[key]=value
+ *
+ * Eg. not[foo]=%bar
+ * key = foo; value = %bar
+ */
+ const notKeyValueRegex = new RegExp(/not\[(\w+)\]\[?\]?=(.*)/);
+
+ return params.map(query => {
+ // Check if there are matches for `not` operator
+ const matches = query.match(notKeyValueRegex);
+ if (matches && matches.length === 3) {
+ const keyParam = matches[1];
+ if (
+ FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
+ this.filteredSearchTokenKeys.searchByConditionUrl(query)
+ ) {
+ return query;
+ }
+
+ const valueParam = matches[2];
+ // Not operator
+ const operator = encodeURIComponent('!=');
+ return `${keyParam}=${operator}${valueParam}`;
+ }
+
+ const [keyParam, valueParam] = query.split('=');
+
+ if (
+ FilteredSearchManager.notTransformableQueryParams.includes(keyParam) ||
+ this.filteredSearchTokenKeys.searchByConditionUrl(query)
+ ) {
+ return query;
+ }
+
+ const operator = encodeURIComponent('=');
+ return `${keyParam}=${operator}${valueParam}`;
+ });
+ }
+
loadSearchParamsFromURL() {
const urlParams = getUrlParamsArray();
- const params = this.getAllParams(urlParams);
+ const withOperatorParams = this.transformParams(urlParams);
+ const params = this.getAllParams(withOperatorParams);
const usernameParams = this.getUsernameParams();
let hasFilteredSearch = false;
@@ -501,9 +599,14 @@ export default class FilteredSearchManager {
if (condition) {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(condition.tokenKey);
- FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value, {
- canEdit,
- });
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ condition.tokenKey,
+ condition.operator,
+ condition.value,
+ {
+ canEdit,
+ },
+ );
} else {
// Sanitize value since URL converts spaces into +
// Replace before decode so that we know what was originally + versus the encoded +
@@ -522,9 +625,12 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
+ const operator = FilteredSearchVisualTokens.getOperatorToken(sanitizedValue);
+ const sanitizedToken = FilteredSearchVisualTokens.getValueToken(sanitizedValue);
FilteredSearchVisualTokens.addFilterVisualToken(
key,
- `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
+ operator,
+ `${symbol}${quotationsToUse}${sanitizedToken}${quotationsToUse}`,
{
canEdit,
uppercaseTokenName,
@@ -537,7 +643,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'assignee';
const canEdit = this.canEdit && this.canEdit(tokenName);
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
+ const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
+ const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
+
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
@@ -547,7 +656,10 @@ export default class FilteredSearchManager {
hasFilteredSearch = true;
const tokenName = 'author';
const canEdit = this.canEdit && this.canEdit(tokenName);
- FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, {
+ const operator = FilteredSearchVisualTokens.getOperatorToken(usernameParams[id]);
+ const valueToken = FilteredSearchVisualTokens.getValueToken(usernameParams[id]);
+
+ FilteredSearchVisualTokens.addFilterVisualToken(tokenName, operator, `@${valueToken}`, {
canEdit,
});
}
@@ -582,7 +694,6 @@ export default class FilteredSearchManager {
search(state = null) {
const paths = [];
const searchQuery = DropdownUtils.getSearchQuery();
-
this.saveCurrentSearchQuery();
const tokenKeys = this.filteredSearchTokenKeys.getKeys();
@@ -593,6 +704,7 @@ export default class FilteredSearchManager {
tokens.forEach(token => {
const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue(
token.key,
+ token.operator,
token.value,
);
const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
@@ -620,7 +732,16 @@ export default class FilteredSearchManager {
tokenValue = tokenValue.slice(1, tokenValue.length - 1);
}
- tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
+ if (token.operator === '!=') {
+ const isArrayParam = keyParam.endsWith('[]');
+
+ tokenPath = `not[${isArrayParam ? keyParam.slice(0, -2) : keyParam}]${
+ isArrayParam ? '[]' : ''
+ }=${encodeURIComponent(tokenValue)}`;
+ } else {
+ // Default operator is `=`
+ tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`;
+ }
}
paths.push(tokenPath);
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index 0a9579bf491..89fc8047b65 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -65,17 +65,20 @@ export default class FilteredSearchTokenKeys {
return this.conditions.find(condition => condition.url === url) || null;
}
- searchByConditionKeyValue(key, value) {
+ searchByConditionKeyValue(key, operator, value) {
return (
this.conditions.find(
condition =>
- condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(),
+ condition.tokenKey === key &&
+ condition.operator === operator &&
+ condition.value.toLowerCase() === value.toLowerCase(),
) || null
);
}
addExtraTokensForIssues() {
const confidentialToken = {
+ formattedKey: __('Confidential'),
key: 'confidential',
type: 'string',
param: '',
diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
index b5c4cb15aac..963e8fe5df5 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js
@@ -2,10 +2,11 @@ import './filtered_search_token_keys';
export default class FilteredSearchTokenizer {
static processTokens(input, allowedKeys) {
- // Regex extracts `(token):(symbol)(value)`
+ // Regex extracts `(token):(operator)(symbol)(value)`
// Values that start with a double quote must end in a double quote (same for single)
+
const tokenRegex = new RegExp(
- `(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
+ `(${allowedKeys.join('|')}):(=|!=)?([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`,
'g',
);
const tokens = [];
@@ -13,16 +14,22 @@ export default class FilteredSearchTokenizer {
let lastToken = null;
const searchToken =
input
- .replace(tokenRegex, (match, key, symbol, v1, v2, v3) => {
+ .replace(tokenRegex, (match, key, operator, symbol, v1, v2, v3) => {
let tokenValue = v1 || v2 || v3;
let tokenSymbol = symbol;
let tokenIndex = '';
+ let tokenOperator = operator;
if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') {
tokenSymbol = tokenValue;
tokenValue = '';
}
+ if (tokenValue === '!=' || tokenValue === '=') {
+ tokenOperator = tokenValue;
+ tokenValue = '';
+ }
+
tokenIndex = `${key}:${tokenValue}`;
// Prevent adding duplicates
@@ -33,6 +40,7 @@ export default class FilteredSearchTokenizer {
key,
value: tokenValue || '',
symbol: tokenSymbol || '',
+ operator: tokenOperator || '',
});
}
@@ -43,13 +51,12 @@ export default class FilteredSearchTokenizer {
if (tokens.length > 0) {
const last = tokens[tokens.length - 1];
- const lastString = `${last.key}:${last.symbol}${last.value}`;
+ const lastString = `${last.key}:${last.operator}${last.symbol}${last.value}`;
lastToken =
input.lastIndexOf(lastString) === input.length - lastString.length ? last : searchToken;
} else {
lastToken = searchToken;
}
-
return {
tokens,
lastToken,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index 7f6457242ef..d41d5a543b0 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -3,6 +3,32 @@ import { objectToQueryString } from '~/lib/utils/common_utils';
import FilteredSearchContainer from './container';
export default class FilteredSearchVisualTokens {
+ static permissibleOperatorValues = ['=', '!='];
+
+ static getOperatorToken(value) {
+ let token = null;
+
+ FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
+ if (value.startsWith(operatorToken)) {
+ token = operatorToken;
+ }
+ });
+
+ return token;
+ }
+
+ static getValueToken(value) {
+ let newValue = value;
+
+ FilteredSearchVisualTokens.permissibleOperatorValues.forEach(operatorToken => {
+ if (value.startsWith(operatorToken)) {
+ newValue = value.slice(operatorToken.length);
+ }
+ });
+
+ return newValue;
+ }
+
static getLastVisualTokenBeforeInput() {
const inputLi = FilteredSearchContainer.container.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
@@ -12,7 +38,9 @@ export default class FilteredSearchVisualTokens {
isLastVisualTokenValid:
lastVisualToken === null ||
lastVisualToken.className.indexOf('filtered-search-term') !== -1 ||
- (lastVisualToken && lastVisualToken.querySelector('.value') !== null),
+ (lastVisualToken &&
+ lastVisualToken.querySelector('.operator') !== null &&
+ lastVisualToken.querySelector('.value') !== null),
};
}
@@ -42,11 +70,17 @@ export default class FilteredSearchVisualTokens {
}
static createVisualTokenElementHTML(options = {}) {
- const { canEdit = true, uppercaseTokenName = false, capitalizeTokenValue = false } = options;
+ const {
+ canEdit = true,
+ hasOperator = false,
+ uppercaseTokenName = false,
+ capitalizeTokenValue = false,
+ } = options;
return `
<div class="${canEdit ? 'selectable' : 'hidden'}" role="button">
<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>
+ ${hasOperator ? '<div class="operator"></div>' : ''}
<div class="value-container">
<div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div>
<div class="remove-token" role="button">
@@ -57,18 +91,18 @@ export default class FilteredSearchVisualTokens {
`;
}
- static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ static renderVisualTokenValue(parentElement, tokenName, tokenValue, tokenOperator) {
const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
- const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
+ const visualTokenValue = new VisualTokenValue(tokenValue, tokenType, tokenOperator);
visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
- static addVisualTokenElement(name, value, options = {}) {
+ static addVisualTokenElement({ name, operator, value, options = {} }) {
const {
isSearchTerm = false,
canEdit,
@@ -84,17 +118,32 @@ export default class FilteredSearchVisualTokens {
li.classList.add(tokenClass);
}
+ const hasOperator = Boolean(operator);
+
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
uppercaseTokenName,
+ operator,
+ hasOperator,
capitalizeTokenValue,
});
- FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value);
+ FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value, operator);
} else {
- li.innerHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
+ const nameHTML = `<div class="${uppercaseTokenName ? 'text-uppercase' : ''} name"></div>`;
+ let operatorHTML = '';
+
+ if (hasOperator) {
+ operatorHTML = '<div class="operator"></div>';
+ }
+
+ li.innerHTML = nameHTML + operatorHTML;
}
+
li.querySelector('.name').innerText = name;
+ if (hasOperator) {
+ li.querySelector('.operator').innerText = operator;
+ }
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
@@ -109,14 +158,19 @@ export default class FilteredSearchVisualTokens {
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
- lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
+ const operator = FilteredSearchVisualTokens.getLastTokenOperator();
+ lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
+ hasOperator: Boolean(operator),
+ });
lastVisualToken.querySelector('.name').innerText = name;
- FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value);
+ lastVisualToken.querySelector('.operator').innerText = operator;
+ FilteredSearchVisualTokens.renderVisualTokenValue(lastVisualToken, name, value, operator);
}
}
static addFilterVisualToken(
tokenName,
+ tokenOperator,
tokenValue,
{ canEdit, uppercaseTokenName = false, capitalizeTokenValue = false } = {},
) {
@@ -127,21 +181,51 @@ export default class FilteredSearchVisualTokens {
const { addVisualTokenElement } = FilteredSearchVisualTokens;
if (isLastVisualTokenValid) {
- addVisualTokenElement(tokenName, tokenValue, {
- canEdit,
- uppercaseTokenName,
- capitalizeTokenValue,
+ addVisualTokenElement({
+ name: tokenName,
+ operator: tokenOperator,
+ value: tokenValue,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
+ });
+ } else if (
+ !isLastVisualTokenValid &&
+ (lastVisualToken && !lastVisualToken.querySelector('.operator'))
+ ) {
+ const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
+ tokensContainer.removeChild(lastVisualToken);
+ addVisualTokenElement({
+ name: tokenName,
+ operator: tokenOperator,
+ value: tokenValue,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
});
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
+ const previousTokenOperator = lastVisualToken.querySelector('.operator').innerText;
const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
- const value = tokenValue || tokenName;
- addVisualTokenElement(previousTokenName, value, {
- canEdit,
- uppercaseTokenName,
- capitalizeTokenValue,
+ let value = tokenValue;
+ if (!value && !tokenOperator) {
+ value = tokenName;
+ }
+ addVisualTokenElement({
+ name: previousTokenName,
+ operator: previousTokenOperator,
+ value,
+ options: {
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ },
});
}
}
@@ -152,13 +236,18 @@ export default class FilteredSearchVisualTokens {
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
- FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, {
- isSearchTerm: true,
+ FilteredSearchVisualTokens.addVisualTokenElement({
+ name: searchTerm,
+ operator: null,
+ value: null,
+ options: {
+ isSearchTerm: true,
+ },
});
}
}
- static getLastTokenPartial() {
+ static getLastTokenPartial(includeOperator = false) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!lastVisualToken) return '';
@@ -175,20 +264,36 @@ export default class FilteredSearchVisualTokens {
const valueText = value ? value.innerText : '';
const nameText = name ? name.innerText : '';
+ if (includeOperator) {
+ const operator = lastVisualToken.querySelector('.operator');
+ const operatorText = operator ? operator.innerText : '';
+ return valueText || operatorText || nameText;
+ }
+
return valueText || nameText;
}
+ static getLastTokenOperator() {
+ const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
+
+ const operator = lastVisualToken && lastVisualToken.querySelector('.operator');
+
+ return operator?.innerText;
+ }
+
static removeLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken) {
const value = lastVisualToken.querySelector('.value');
-
+ const operator = lastVisualToken.querySelector('.operator');
if (value) {
const button = lastVisualToken.querySelector('.selectable');
const valueContainer = lastVisualToken.querySelector('.value-container');
button.removeChild(valueContainer);
lastVisualToken.innerHTML = button.innerHTML;
+ } else if (operator) {
+ lastVisualToken.removeChild(operator);
} else {
lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken);
}
@@ -236,12 +341,18 @@ export default class FilteredSearchVisualTokens {
tokenContainer.replaceChild(inputLi, token);
const nameElement = token.querySelector('.name');
+ const operatorElement = token.querySelector('.operator');
let value;
if (token.classList.contains('filtered-search-token')) {
- FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText, null, {
- uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
- });
+ FilteredSearchVisualTokens.addFilterVisualToken(
+ nameElement.innerText,
+ operatorElement.innerText,
+ null,
+ {
+ uppercaseTokenName: nameElement.classList.contains('text-uppercase'),
+ },
+ );
const valueContainerElement = token.querySelector('.value-container');
value = valueContainerElement.dataset.originalValue;
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 414bcf186a3..8722fc64b62 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,10 +1,10 @@
+import { flatten } from 'underscore';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import { __ } from '~/locale';
-export const tokenKeys = [];
-
-tokenKeys.push(
+export const tokenKeys = [
{
+ formattedKey: __('Author'),
key: 'author',
type: 'string',
param: 'username',
@@ -13,6 +13,7 @@ tokenKeys.push(
tag: '@author',
},
{
+ formattedKey: __('Assignee'),
key: 'assignee',
type: 'string',
param: 'username',
@@ -21,6 +22,7 @@ tokenKeys.push(
tag: '@assignee',
},
{
+ formattedKey: __('Milestone'),
key: 'milestone',
type: 'string',
param: 'title',
@@ -28,31 +30,30 @@ tokenKeys.push(
icon: 'clock',
tag: '%milestone',
},
-);
-
-if (gon && gon.features && gon.features.releaseSearchFilter) {
- tokenKeys.push({
+ {
+ formattedKey: __('Release'),
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',
-});
+ },
+ {
+ formattedKey: __('Label'),
+ key: 'label',
+ type: 'array',
+ param: 'name[]',
+ symbol: '~',
+ icon: 'labels',
+ tag: '~label',
+ },
+];
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
+ formattedKey: __('My-Reaction'),
key: 'my-reaction',
type: 'string',
param: 'emoji',
@@ -64,6 +65,7 @@ if (gon.current_user_id) {
export const alternativeTokenKeys = [
{
+ formattedKey: __('Label'),
key: 'label',
type: 'string',
param: 'name',
@@ -71,68 +73,88 @@ export const alternativeTokenKeys = [
},
];
-export const conditions = [
- {
- url: 'assignee_id=None',
- tokenKey: 'assignee',
- value: __('None'),
- },
- {
- url: 'assignee_id=Any',
- tokenKey: 'assignee',
- value: __('Any'),
- },
- {
- url: 'milestone_title=None',
- tokenKey: 'milestone',
- value: __('None'),
- },
- {
- url: 'milestone_title=Any',
- tokenKey: 'milestone',
- value: __('Any'),
- },
- {
- url: 'milestone_title=%23upcoming',
- tokenKey: 'milestone',
- value: __('Upcoming'),
- },
- {
- url: 'milestone_title=%23started',
- tokenKey: 'milestone',
- 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'),
- },
- {
- url: 'label_name[]=Any',
- tokenKey: 'label',
- value: __('Any'),
- },
- {
- url: 'my_reaction_emoji=None',
- tokenKey: 'my-reaction',
- value: __('None'),
- },
- {
- url: 'my_reaction_emoji=Any',
- tokenKey: 'my-reaction',
- value: __('Any'),
- },
-];
+export const conditions = flatten(
+ [
+ {
+ url: 'assignee_id=None',
+ tokenKey: 'assignee',
+ value: __('None'),
+ },
+ {
+ url: 'assignee_id=Any',
+ tokenKey: 'assignee',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=None',
+ tokenKey: 'milestone',
+ value: __('None'),
+ },
+ {
+ url: 'milestone_title=Any',
+ tokenKey: 'milestone',
+ value: __('Any'),
+ },
+ {
+ url: 'milestone_title=%23upcoming',
+ tokenKey: 'milestone',
+ value: __('Upcoming'),
+ },
+ {
+ url: 'milestone_title=%23started',
+ tokenKey: 'milestone',
+ 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'),
+ },
+ {
+ url: 'label_name[]=Any',
+ tokenKey: 'label',
+ value: __('Any'),
+ },
+ {
+ url: 'my_reaction_emoji=None',
+ tokenKey: 'my-reaction',
+ value: __('None'),
+ },
+ {
+ url: 'my_reaction_emoji=Any',
+ tokenKey: 'my-reaction',
+ value: __('Any'),
+ },
+ ].map(condition => {
+ const [keyPart, valuePart] = condition.url.split('=');
+ const hasBrackets = keyPart.includes('[]');
+
+ const notEqualUrl = `not[${hasBrackets ? keyPart.slice(0, -2) : keyPart}]${
+ hasBrackets ? '[]' : ''
+ }=${valuePart}`;
+ return [
+ {
+ ...condition,
+ operator: '=',
+ },
+ {
+ ...condition,
+ operator: '!=',
+ url: notEqualUrl,
+ },
+ ];
+ }),
+);
const IssuableFilteredSearchTokenKeys = new FilteredSearchTokenKeys(
tokenKeys,
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
index 1343ccd6468..9f3cf881af4 100644
--- a/app/assets/javascripts/filtered_search/visual_token_value.js
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -9,9 +9,10 @@ import UsersCache from '~/lib/utils/users_cache';
import { __ } from '~/locale';
export default class VisualTokenValue {
- constructor(tokenValue, tokenType) {
+ constructor(tokenValue, tokenType, tokenOperator) {
this.tokenValue = tokenValue;
this.tokenType = tokenType;
+ this.tokenOperator = tokenOperator;
}
render(tokenValueContainer, tokenValueElement) {
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index 2566ed6b47c..b9ce0851585 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -1,4 +1,4 @@
-import bp from './breakpoints';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar';
const HIDE_INTERVAL_TIMEOUT = 300;
@@ -40,10 +40,7 @@ export const canShowActiveSubItems = el => {
return true;
};
-export const canShowSubItems = () =>
- bp.getBreakpointSize() === 'sm' ||
- bp.getBreakpointSize() === 'md' ||
- bp.getBreakpointSize() === 'lg';
+export const canShowSubItems = () => ['md', 'lg', 'xl'].includes(bp.getBreakpointSize());
export const getHideSubItemsInterval = () => {
if (!currentOpenMenu || !mousePos.length) return 0;
diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js
index aba692e4b99..cc1668b1a0d 100644
--- a/app/assets/javascripts/frequent_items/utils.js
+++ b/app/assets/javascripts/frequent_items/utils.js
@@ -1,12 +1,8 @@
import _ from 'underscore';
-import bp from '~/breakpoints';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants';
-export const isMobile = () => {
- const screenSize = bp.getBreakpointSize();
-
- return screenSize === 'sm' || screenSize === 'xs';
-};
+export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize());
export const getTopFrequentItems = items => {
if (!items) {
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index e25c9d90f60..de69daf5c22 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -107,8 +107,13 @@ class GfmAutoComplete {
if (value.params.length > 0) {
tpl += ' <small class="params"><%- params.join(" ") %></small>';
}
- if (value.description !== '') {
- tpl += '<small class="description"><i><%- description %> <%- warningText %></i></small>';
+ if (value.warning && value.icon && value.icon === 'confidential') {
+ tpl +=
+ '<small class="description"><em><i class="fa fa-eye-slash" aria-hidden="true"/><%- warning %></em></small>';
+ } else if (value.warning) {
+ tpl += '<small class="description"><em><%- warning %></em></small>';
+ } else if (value.description !== '') {
+ tpl += '<small class="description"><em><%- description %></em></small>';
}
tpl += '</li>';
@@ -119,7 +124,6 @@ class GfmAutoComplete {
return _.template(tpl)({
...value,
className: cssClasses.join(' '),
- warningText: value.warning ? `(${value.warning})` : '',
});
},
insertTpl(value) {
@@ -150,6 +154,7 @@ class GfmAutoComplete {
params: c.params,
description: c.description,
warning: c.warning,
+ icon: c.icon,
search,
};
});
diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
index 6258ee7f153..41d83e45c52 100644
--- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
+++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui';
+import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
@@ -9,7 +9,6 @@ export default {
GlFormCheckbox,
GlFormGroup,
GlFormInput,
- GlLink,
Icon,
},
data() {
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index 675552e6c2b..53da3f7b2ee 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -1,5 +1,4 @@
<script>
-import icon from '~/vue_shared/components/icon.vue';
import { GlBadge } from '@gitlab/ui';
import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import {
@@ -13,7 +12,6 @@ import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending
export default {
components: {
- icon,
timeAgoTooltip,
itemStatsValue,
GlBadge,
diff --git a/app/assets/javascripts/helpers/diffs_helper.js b/app/assets/javascripts/helpers/diffs_helper.js
new file mode 100644
index 00000000000..9695d01ad3d
--- /dev/null
+++ b/app/assets/javascripts/helpers/diffs_helper.js
@@ -0,0 +1,19 @@
+export function hasInlineLines(diffFile) {
+ return diffFile?.highlighted_diff_lines?.length > 0; /* eslint-disable-line camelcase */
+}
+
+export function hasParallelLines(diffFile) {
+ return diffFile?.parallel_diff_lines?.length > 0; /* eslint-disable-line camelcase */
+}
+
+export function isSingleViewStyle(diffFile) {
+ return !hasParallelLines(diffFile) || !hasInlineLines(diffFile);
+}
+
+export function hasDiff(diffFile) {
+ return (
+ hasInlineLines(diffFile) ||
+ hasParallelLines(diffFile) ||
+ !diffFile?.blob?.readable_text /* eslint-disable-line camelcase */
+ );
+}
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 6b2ef34c960..3398cd091ba 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -1,12 +1,13 @@
<script>
-import $ from 'jquery';
import { mapActions } from 'vuex';
-import { __ } from '~/locale';
+import { sprintf, __ } from '~/locale';
+import { GlModal } from '@gitlab/ui';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
export default {
components: {
+ GlModal,
FileIcon,
ChangedFileIcon,
},
@@ -17,7 +18,13 @@ export default {
},
},
computed: {
- activeButtonText() {
+ discardModalId() {
+ return `discard-file-${this.activeFile.path}`;
+ },
+ discardModalTitle() {
+ return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
+ },
+ actionButtonText() {
return this.activeFile.staged ? __('Unstage') : __('Stage');
},
isStaged() {
@@ -25,7 +32,7 @@ export default {
},
},
methods: {
- ...mapActions(['stageChange', 'unstageChange']),
+ ...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']),
actionButtonClicked() {
if (this.activeFile.staged) {
this.unstageChange(this.activeFile.path);
@@ -34,7 +41,7 @@ export default {
}
},
showDiscardModal() {
- $(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show');
+ this.$refs.discardModal.show();
},
},
};
@@ -53,6 +60,7 @@ export default {
<div class="ml-auto">
<button
v-if="!isStaged"
+ ref="discardButton"
type="button"
class="btn btn-remove btn-inverted append-right-8"
@click="showDiscardModal"
@@ -60,6 +68,7 @@ export default {
{{ __('Discard') }}
</button>
<button
+ ref="actionButton"
:class="{
'btn-success': !isStaged,
'btn-warning': isStaged,
@@ -68,8 +77,19 @@ export default {
class="btn btn-inverted"
@click="actionButtonClicked"
>
- {{ activeButtonText }}
+ {{ actionButtonText }}
</button>
</div>
+ <gl-modal
+ ref="discardModal"
+ ok-variant="danger"
+ cancel-variant="light"
+ :ok-title="__('Discard changes')"
+ :modal-id="discardModalId"
+ :title="discardModalTitle"
+ @ok="discardFileChanges(activeFile.path)"
+ >
+ {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
index f7ed7006874..9d5473a1201 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue
@@ -6,6 +6,7 @@ import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
import { activityBarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
@@ -14,6 +15,7 @@ export default {
CommitMessageField,
SuccessMessage,
},
+ mixins: [glFeatureFlagsMixin()],
data() {
return {
isCompact: true,
@@ -27,9 +29,13 @@ export default {
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
overviewText() {
return sprintf(
- __(
- '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes',
- ),
+ this.glFeatures.stageAllByDefault
+ ? __(
+ '<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes',
+ )
+ : __(
+ '<strong>%{changedFilesLength} unstaged</strong> and <strong>%{stagedFilesLength} staged</strong> changes',
+ ),
{
stagedFilesLength: this.stagedFiles.length,
changedFilesLength: this.changedFiles.length,
@@ -39,6 +45,10 @@ export default {
commitButtonText() {
return this.stagedFiles.length ? __('Commit') : __('Stage & Commit');
},
+
+ currentViewIsCommitView() {
+ return this.currentActivityView === activityBarViews.commit;
+ },
},
watch: {
currentActivityView() {
@@ -46,11 +56,11 @@ export default {
this.isCompact = false;
} else {
this.isCompact = !(
- this.currentActivityView === activityBarViews.commit &&
- window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT
+ this.currentViewIsCommitView && window.innerHeight >= MAX_WINDOW_HEIGHT_COMPACT
);
}
},
+
lastCommitMsg() {
this.isCompact =
this.currentActivityView !== activityBarViews.commit && this.lastCommitMsg === '';
@@ -59,14 +69,18 @@ export default {
methods: {
...mapActions(['updateActivityBarView']),
...mapActions('commit', ['updateCommitMessage', 'discardDraft', 'commitChanges']),
- toggleIsSmall() {
- this.updateActivityBarView(activityBarViews.commit)
- .then(() => {
- this.isCompact = !this.isCompact;
- })
- .catch(e => {
- throw e;
- });
+ toggleIsCompact() {
+ if (this.currentViewIsCommitView) {
+ this.isCompact = !this.isCompact;
+ } else {
+ this.updateActivityBarView(activityBarViews.commit)
+ .then(() => {
+ this.isCompact = false;
+ })
+ .catch(e => {
+ throw e;
+ });
+ }
},
beforeEnterTransition() {
const elHeight = this.isCompact
@@ -114,7 +128,7 @@ export default {
:disabled="!hasChanges"
type="button"
class="btn btn-primary btn-sm btn-block qa-begin-commit-button"
- @click="toggleIsSmall"
+ @click="toggleIsCompact"
>
{{ __('Commitā€¦') }}
</button>
@@ -148,7 +162,7 @@ export default {
v-else
type="button"
class="btn btn-default btn-sm float-right"
- @click="toggleIsSmall"
+ @click="toggleIsCompact"
>
{{ __('Collapse') }}
</button>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index e16918ae025..d9a385a9d31 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -41,10 +41,6 @@ export default {
type: String,
required: true,
},
- itemActionComponent: {
- type: String,
- required: true,
- },
stagedList: {
type: Boolean,
required: false,
@@ -142,7 +138,6 @@ export default {
<li v-for="file in fileList" :key="file.key">
<list-item
:file="file"
- :action-component="itemActionComponent"
:key-prefix="keyPrefix"
:staged-list="stagedList"
:active-file-key="activeFileKey"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 230dfaf047b..726e2b7e1fc 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -3,16 +3,12 @@ import { mapActions } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
-import StageButton from './stage_button.vue';
-import UnstageButton from './unstage_button.vue';
import { viewerTypes } from '../../constants';
import { getCommitIconMap } from '../../utils';
export default {
components: {
Icon,
- StageButton,
- UnstageButton,
FileIcon,
},
directives: {
@@ -23,10 +19,6 @@ export default {
type: Object,
required: true,
},
- actionComponent: {
- type: String,
- required: true,
- },
keyPrefix: {
type: String,
required: false,
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
deleted file mode 100644
index c14b8a47841..00000000000
--- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
+++ /dev/null
@@ -1,78 +0,0 @@
-<script>
-import $ from 'jquery';
-import { mapActions } from 'vuex';
-import { sprintf, __ } from '~/locale';
-import Icon from '~/vue_shared/components/icon.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
-import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-
-export default {
- components: {
- Icon,
- GlModal: DeprecatedModal2,
- },
- directives: {
- tooltip,
- },
- props: {
- path: {
- type: String,
- required: true,
- },
- },
- computed: {
- modalId() {
- return `discard-file-${this.path}`;
- },
- modalTitle() {
- return sprintf(__('Discard changes to %{path}?'), { path: this.path });
- },
- },
- methods: {
- ...mapActions(['stageChange', 'discardFileChanges']),
- showDiscardModal() {
- $(document.getElementById(this.modalId)).modal('show');
- },
- },
-};
-</script>
-
-<template>
- <div v-once class="multi-file-discard-btn d-flex">
- <button
- v-tooltip
- :aria-label="__('Stage changes')"
- :title="__('Stage changes')"
- type="button"
- class="btn btn-blank align-items-center"
- data-container="body"
- data-boundary="viewport"
- data-placement="bottom"
- @click.stop.prevent="stageChange(path)"
- >
- <icon :size="16" name="mobile-issue-close" class="ml-auto mr-auto" />
- </button>
- <button
- v-tooltip
- :aria-label="__('Discard changes')"
- :title="__('Discard changes')"
- type="button"
- class="btn btn-blank align-items-center"
- data-container="body"
- data-boundary="viewport"
- data-placement="bottom"
- @click.stop.prevent="showDiscardModal"
- >
- <icon :size="16" name="remove" class="ml-auto mr-auto" />
- </button>
- <gl-modal
- :id="modalId"
- :header-title-text="modalTitle"
- :footer-primary-button-text="__('Discard changes')"
- footer-primary-button-variant="danger"
- @submit="discardFileChanges(path)"
- >
- {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
- </gl-modal>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
deleted file mode 100644
index 0567ef54ff3..00000000000
--- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-<script>
-import { mapActions } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
-import tooltip from '~/vue_shared/directives/tooltip';
-
-export default {
- components: {
- Icon,
- },
- directives: {
- tooltip,
- },
- props: {
- path: {
- type: String,
- required: true,
- },
- },
- methods: {
- ...mapActions(['unstageChange']),
- },
-};
-</script>
-
-<template>
- <div v-once class="multi-file-discard-btn d-flex">
- <button
- v-tooltip
- :aria-label="__('Unstage changes')"
- :title="__('Unstage changes')"
- type="button"
- class="btn btn-blank align-items-center"
- data-container="body"
- data-boundary="viewport"
- data-placement="bottom"
- @click.stop.prevent="unstageChange(path)"
- >
- <icon :size="16" name="redo" class="ml-auto mr-auto" />
- </button>
- </div>
-</template>
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index f0bedcfbd6b..33098eb1af0 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -6,6 +6,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import NewDropdown from './new_dropdown/index.vue';
import MrFileIcon from './mr_file_icon.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'FileRowExtra',
@@ -18,6 +19,7 @@ export default {
ChangedFileIcon,
MrFileIcon,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
file: {
type: Object,
@@ -55,10 +57,15 @@ export default {
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
}
- return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), {
- unstaged: this.folderUnstagedCount,
- staged: this.folderStagedCount,
- });
+ return sprintf(
+ this.glFeatures.stageAllByDefault
+ ? __('%{staged} staged and %{unstaged} unstaged changes')
+ : __('%{unstaged} unstaged and %{staged} staged changes'),
+ {
+ unstaged: this.folderUnstagedCount,
+ staged: this.folderStagedCount,
+ },
+ );
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index 363a8f43033..6ed863c9c2e 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,6 +1,6 @@
<script>
import Vue from 'vue';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import FindFile from '~/vue_shared/components/file_finder/index.vue';
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index f93496132a4..598f3a1dac6 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -1,13 +1,11 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
-import Icon from '~/vue_shared/components/icon.vue';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
export default {
components: {
- Icon,
Upload,
IdeTreeList,
NewEntryButton,
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 3a0dd60f0e0..bacdfc7c05e 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -1,14 +1,12 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import NavDropdown from './nav_dropdown.vue';
import FileRowExtra from './file_row_extra.vue';
export default {
components: {
- Icon,
GlSkeletonLoading,
NavDropdown,
FileRow,
diff --git a/app/assets/javascripts/ide/components/nav_dropdown.vue b/app/assets/javascripts/ide/components/nav_dropdown.vue
index e45d2a62dae..2e290de0943 100644
--- a/app/assets/javascripts/ide/components/nav_dropdown.vue
+++ b/app/assets/javascripts/ide/components/nav_dropdown.vue
@@ -1,12 +1,10 @@
<script>
import $ from 'jquery';
-import Icon from '~/vue_shared/components/icon.vue';
import NavForm from './nav_form.vue';
import NavDropdownButton from './nav_dropdown_button.vue';
export default {
components: {
- Icon,
NavDropdownButton,
NavForm,
},
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index ecafb4e81c4..bf3d736ddf3 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -67,8 +67,8 @@ export default {
if (this.entryModal.type === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
flash(
- sprintf(s__('The name %{entryName} is already taken in this directory.'), {
- entryName: this.entryName,
+ sprintf(s__('The name "%{name}" is already taken in this directory.'), {
+ name: this.entryName,
}),
'alert',
document,
@@ -81,22 +81,11 @@ export default {
const entryName = parentPath.pop();
parentPath = parentPath.join('/');
- const createPromise =
- parentPath && !this.entries[parentPath]
- ? this.createTempEntry({ name: parentPath, type: 'tree' })
- : Promise.resolve();
-
- createPromise
- .then(() =>
- this.renameEntry({
- path: this.entryModal.entry.path,
- name: entryName,
- parentPath,
- }),
- )
- .catch(() =>
- flash(__('Error creating a new path'), 'alert', document, null, false, true),
- );
+ this.renameEntry({
+ path: this.entryModal.entry.path,
+ name: entryName,
+ parentPath,
+ });
}
} else {
this.createTempEntry({
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index 188518dd419..e52613086a4 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -1,10 +1,8 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import ItemButton from './button.vue';
export default {
components: {
- Icon,
ItemButton,
},
props: {
diff --git a/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
new file mode 100644
index 00000000000..d5a123edb80
--- /dev/null
+++ b/app/assets/javascripts/ide/components/panes/collapsible_sidebar.vue
@@ -0,0 +1,151 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import _ from 'underscore';
+import tooltip from '~/vue_shared/directives/tooltip';
+import Icon from '~/vue_shared/components/icon.vue';
+import ResizablePanel from '../resizable_panel.vue';
+import { GlSkeletonLoading } from '@gitlab/ui';
+
+export default {
+ name: 'CollapsibleSidebar',
+ directives: {
+ tooltip,
+ },
+ components: {
+ Icon,
+ ResizablePanel,
+ GlSkeletonLoading,
+ },
+ props: {
+ extensionTabs: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ side: {
+ type: String,
+ required: true,
+ },
+ width: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['loading']),
+ ...mapState({
+ isOpen(state) {
+ return state[this.namespace].isOpen;
+ },
+ currentView(state) {
+ return state[this.namespace].currentView;
+ },
+ isActiveView(state, getters) {
+ return getters[`${this.namespace}/isActiveView`];
+ },
+ isAliveView(_state, getters) {
+ return getters[`${this.namespace}/isAliveView`];
+ },
+ }),
+ namespace() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `${this.side}Pane`;
+ },
+ tabs() {
+ return this.extensionTabs.filter(tab => tab.show);
+ },
+ tabViews() {
+ return _.flatten(this.tabs.map(tab => tab.views));
+ },
+ aliveTabViews() {
+ return this.tabViews.filter(view => this.isAliveView(view.name));
+ },
+ otherSide() {
+ return this.side === 'right' ? 'left' : 'right';
+ },
+ },
+ methods: {
+ ...mapActions({
+ toggleOpen(dispatch) {
+ return dispatch(`${this.namespace}/toggleOpen`);
+ },
+ open(dispatch, view) {
+ return dispatch(`${this.namespace}/open`, view);
+ },
+ }),
+ clickTab(e, tab) {
+ e.target.blur();
+
+ if (this.isActiveTab(tab)) {
+ this.toggleOpen();
+ } else {
+ this.open(tab.views[0]);
+ }
+ },
+ isActiveTab(tab) {
+ return tab.views.some(view => this.isActiveView(view.name));
+ },
+ buttonClasses(tab) {
+ return [
+ this.side === 'right' ? 'is-right' : '',
+ this.isActiveTab(tab) && this.isOpen ? 'active' : '',
+ ...(tab.buttonClasses || []),
+ ];
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="`ide-${side}-sidebar`"
+ :data-qa-selector="`ide_${side}_sidebar`"
+ class="multi-file-commit-panel ide-sidebar"
+ >
+ <resizable-panel
+ v-show="isOpen"
+ :collapsible="false"
+ :initial-width="width"
+ :min-size="width"
+ :class="`ide-${side}-sidebar-${currentView}`"
+ :side="side"
+ class="multi-file-commit-panel-inner"
+ >
+ <div class="h-100 d-flex flex-column align-items-stretch">
+ <slot v-if="isOpen" name="header"></slot>
+ <div
+ v-for="tabView in aliveTabViews"
+ v-show="isActiveView(tabView.name)"
+ :key="tabView.name"
+ class="flex-fill js-tab-view"
+ >
+ <component :is="tabView.component" />
+ </div>
+ <slot name="footer"></slot>
+ </div>
+ </resizable-panel>
+ <nav class="ide-activity-bar">
+ <ul class="list-unstyled">
+ <li>
+ <slot name="header-icon"></slot>
+ </li>
+ <li v-for="tab of tabs" :key="tab.title">
+ <button
+ v-tooltip
+ :title="tab.title"
+ :aria-label="tab.title"
+ :class="buttonClasses(tab)"
+ data-container="body"
+ :data-placement="otherSide"
+ :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
+ class="ide-sidebar-link"
+ type="button"
+ @click="clickTab($event, tab)"
+ >
+ <icon :size="16" :name="tab.icon" />
+ </button>
+ </li>
+ </ul>
+ </nav>
+ </div>
+</template>
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 200391282e7..40ed7d9c422 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -1,27 +1,17 @@
<script>
-import { mapActions, mapState, mapGetters } from 'vuex';
-import _ from 'underscore';
+import { mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
-import tooltip from '../../../vue_shared/directives/tooltip';
-import Icon from '../../../vue_shared/components/icon.vue';
+import CollapsibleSidebar from './collapsible_sidebar.vue';
import { rightSidebarViews } from '../../constants';
+import MergeRequestInfo from '../merge_requests/info.vue';
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
-import MergeRequestInfo from '../merge_requests/info.vue';
-import ResizablePanel from '../resizable_panel.vue';
import Clientside from '../preview/clientside.vue';
export default {
- directives: {
- tooltip,
- },
+ name: 'RightPane',
components: {
- Icon,
- PipelinesList,
- JobsDetail,
- ResizablePanel,
- MergeRequestInfo,
- Clientside,
+ CollapsibleSidebar,
},
props: {
extensionTabs: {
@@ -32,103 +22,40 @@ export default {
},
computed: {
...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
- ...mapState('rightPane', ['isOpen', 'currentView']),
...mapGetters(['packageJson']),
- ...mapGetters('rightPane', ['isActiveView', 'isAliveView']),
showLivePreview() {
return this.packageJson && this.clientsidePreviewEnabled;
},
- defaultTabs() {
+ rightExtensionTabs() {
return [
{
- show: this.currentMergeRequestId,
+ show: Boolean(this.currentMergeRequestId),
title: __('Merge Request'),
- views: [rightSidebarViews.mergeRequestInfo],
+ views: [{ component: MergeRequestInfo, ...rightSidebarViews.mergeRequestInfo }],
icon: 'text-description',
},
{
show: true,
title: __('Pipelines'),
- views: [rightSidebarViews.pipelines, rightSidebarViews.jobsDetail],
+ views: [
+ { component: PipelinesList, ...rightSidebarViews.pipelines },
+ { component: JobsDetail, ...rightSidebarViews.jobsDetail },
+ ],
icon: 'rocket',
},
{
show: this.showLivePreview,
title: __('Live preview'),
- views: [rightSidebarViews.clientSidePreview],
+ views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }],
icon: 'live-preview',
},
+ ...this.extensionTabs,
];
},
- tabs() {
- return this.defaultTabs.concat(this.extensionTabs).filter(tab => tab.show);
- },
- tabViews() {
- return _.flatten(this.tabs.map(tab => tab.views));
- },
- aliveTabViews() {
- return this.tabViews.filter(view => this.isAliveView(view.name));
- },
- },
- methods: {
- ...mapActions('rightPane', ['toggleOpen', 'open']),
- clickTab(e, tab) {
- e.target.blur();
-
- if (this.isActiveTab(tab)) {
- this.toggleOpen();
- } else {
- this.open(tab.views[0]);
- }
- },
- isActiveTab(tab) {
- return tab.views.some(view => this.isActiveView(view.name));
- },
},
};
</script>
<template>
- <div class="multi-file-commit-panel ide-right-sidebar" data-qa-selector="ide_right_sidebar">
- <resizable-panel
- v-show="isOpen"
- :collapsible="false"
- :initial-width="350"
- :min-size="350"
- :class="`ide-right-sidebar-${currentView}`"
- side="right"
- class="multi-file-commit-panel-inner"
- >
- <div
- v-for="tabView in aliveTabViews"
- v-show="isActiveView(tabView.name)"
- :key="tabView.name"
- class="h-100"
- >
- <component :is="tabView.component || tabView.name" />
- </div>
- </resizable-panel>
- <nav class="ide-activity-bar">
- <ul class="list-unstyled">
- <li v-for="tab of tabs" :key="tab.title">
- <button
- v-tooltip
- :title="tab.title"
- :aria-label="tab.title"
- :class="{
- active: isActiveTab(tab) && isOpen,
- }"
- data-container="body"
- data-placement="left"
- :data-qa-selector="`${tab.title.toLowerCase()}_tab_button`"
- class="ide-sidebar-link is-right"
- type="button"
- @click="clickTab($event, tab)"
- >
- <icon :size="16" :name="tab.icon" />
- </button>
- </li>
- </ul>
- </nav>
- </div>
+ <collapsible-sidebar :extension-tabs="rightExtensionTabs" side="right" :width="350" />
</template>
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 5201c33b1b4..b3a7597e7bb 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -1,7 +1,6 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import tooltip from '~/vue_shared/directives/tooltip';
-import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
@@ -11,7 +10,6 @@ import { activityBarViews, stageKeys } from '../constants';
export default {
components: {
DeprecatedModal,
- Icon,
CommitFilesList,
EmptyState,
},
@@ -96,7 +94,6 @@ export default {
:empty-state-text="__('There are no unstaged changes')"
action="stageAllChanges"
action-btn-icon="stage-all"
- item-action-component="stage-button"
class="is-first"
icon-name="unstaged"
/>
@@ -110,7 +107,6 @@ export default {
:empty-state-text="__('There are no staged changes')"
action="unstageAllChanges"
action-btn-icon="unstage-all"
- item-action-component="unstage-button"
icon-name="staged"
/>
</template>
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index 08b3e8a34d6..7e2ab96d1de 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -32,7 +32,13 @@ export default {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
- ...mapState(['rightPanelCollapsed', 'viewer', 'panelResizing', 'currentActivityView']),
+ ...mapState([
+ 'rightPanelCollapsed',
+ 'viewer',
+ 'panelResizing',
+ 'currentActivityView',
+ 'renderWhitespaceInCode',
+ ]),
...mapGetters([
'currentMergeRequest',
'getStagedFile',
@@ -76,6 +82,11 @@ export default {
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
+ editorOptions() {
+ return {
+ renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none',
+ };
+ },
},
watch: {
file(newVal, oldVal) {
@@ -131,7 +142,7 @@ export default {
},
mounted() {
if (!this.editor) {
- this.editor = Editor.create();
+ this.editor = Editor.create(this.editorOptions);
}
this.initEditor();
},
diff --git a/app/assets/javascripts/ide/components/repo_tabs.vue b/app/assets/javascripts/ide/components/repo_tabs.vue
index 4dbc4383894..1b7f149097b 100644
--- a/app/assets/javascripts/ide/components/repo_tabs.vue
+++ b/app/assets/javascripts/ide/components/repo_tabs.vue
@@ -1,13 +1,11 @@
<script>
import { mapActions } from 'vuex';
import RepoTab from './repo_tab.vue';
-import EditorMode from './editor_mode_dropdown.vue';
import router from '../ide_router';
export default {
components: {
RepoTab,
- EditorMode,
},
props: {
activeFile: {
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index cdfebd19fa4..4c4166e11f5 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -50,6 +50,7 @@ export function initIde(el, options = {}) {
});
this.setInitialData({
clientsidePreviewEnabled: parseBoolean(el.dataset.clientsidePreviewEnabled),
+ renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
});
},
methods: {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 02038fcb534..d1056ea6b98 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -23,20 +23,24 @@ export const clearDomElement = el => {
};
export default class Editor {
- static create() {
+ static create(options = {}) {
if (!this.editorInstance) {
- this.editorInstance = new Editor();
+ this.editorInstance = new Editor(options);
}
return this.editorInstance;
}
- constructor() {
+ constructor(options = {}) {
this.currentModel = null;
this.instance = null;
this.dirtyDiffController = null;
this.disposable = new Disposable();
this.modelManager = new ModelManager();
this.decorationsController = new DecorationsController(this);
+ this.options = {
+ ...defaultEditorOptions,
+ ...options,
+ };
setupMonacoTheme();
@@ -51,7 +55,7 @@ export default class Editor {
this.disposable.add(
(this.instance = monacoEditor.create(domElement, {
- ...defaultEditorOptions,
+ ...this.options,
})),
(this.dirtyDiffController = new DirtyDiffController(
this.modelManager,
@@ -71,7 +75,7 @@ export default class Editor {
this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, {
- ...defaultEditorOptions,
+ ...this.options,
quickSuggestions: false,
occurrencesHighlight: false,
renderSideBySide: Editor.renderSideBySide(domElement),
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index dd69e2d6f1f..34e7cc304dd 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -16,21 +16,7 @@ export const redirectToUrl = (self, url) => visitUrl(url);
export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
export const discardAllChanges = ({ state, commit, dispatch }) => {
- state.changedFiles.forEach(file => {
- if (file.tempFile || file.prevPath) dispatch('closeFile', file);
-
- if (file.tempFile) {
- dispatch('deleteEntry', file.path);
- } else if (file.prevPath) {
- dispatch('renameEntry', {
- path: file.path,
- name: file.prevName,
- parentPath: file.prevParentPath,
- });
- } else {
- commit(types.DISCARD_FILE_CHANGES, file.path);
- }
- });
+ state.changedFiles.forEach(file => dispatch('restoreOriginalFile', file.path));
commit(types.REMOVE_ALL_CHANGES_FILES);
};
@@ -47,79 +33,66 @@ export const setPanelCollapsedStatus = ({ commit }, { side, collapsed }) => {
}
};
-export const toggleRightPanelCollapsed = ({ dispatch, state }, e = undefined) => {
- if (e) {
- $(e.currentTarget)
- .tooltip('hide')
- .blur();
- }
-
- dispatch('setPanelCollapsedStatus', {
- side: 'right',
- collapsed: !state.rightPanelCollapsed,
- });
-};
-
export const setResizingStatus = ({ commit }, resizing) => {
commit(types.SET_RESIZING_STATUS, resizing);
};
export const createTempEntry = (
- { state, commit, dispatch },
+ { state, commit, dispatch, getters },
{ name, type, content = '', base64 = false, binary = false, rawPath = '' },
-) =>
- new Promise(resolve => {
- const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
-
- if (state.entries[name] && !state.entries[name].deleted) {
- flash(
- `The name "${name.split('/').pop()}" is already taken in this directory.`,
- 'alert',
- document,
- null,
- false,
- true,
- );
-
- resolve();
-
- return null;
- }
+) => {
+ const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
+
+ if (state.entries[name] && !state.entries[name].deleted) {
+ flash(
+ sprintf(__('The name "%{name}" is already taken in this directory.'), {
+ name: name.split('/').pop(),
+ }),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
- const data = decorateFiles({
- data: [fullName],
- projectId: state.currentProjectId,
- branchId: state.currentBranchId,
- type,
- tempFile: true,
- content,
- base64,
- binary,
- rawPath,
- });
- const { file, parentPath } = data;
+ return;
+ }
- commit(types.CREATE_TMP_ENTRY, {
- data,
- projectId: state.currentProjectId,
- branchId: state.currentBranchId,
- });
+ const data = decorateFiles({
+ data: [fullName],
+ projectId: state.currentProjectId,
+ branchId: state.currentBranchId,
+ type,
+ tempFile: true,
+ content,
+ base64,
+ binary,
+ rawPath,
+ });
+ const { file, parentPath } = data;
- if (type === 'blob') {
- commit(types.TOGGLE_FILE_OPEN, file.path);
- commit(types.ADD_FILE_TO_CHANGED, file.path);
- dispatch('setFileActive', file.path);
- dispatch('triggerFilesChange');
- }
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId: state.currentBranchId,
+ });
- if (parentPath && !state.entries[parentPath].opened) {
- commit(types.TOGGLE_TREE_OPEN, parentPath);
- }
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
- resolve(file);
+ if (gon.features?.stageAllByDefault)
+ commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) });
+ else commit(types.ADD_FILE_TO_CHANGED, file.path);
- return null;
- });
+ dispatch('setFileActive', file.path);
+ dispatch('triggerFilesChange');
+ dispatch('burstUnusedSeal');
+ }
+
+ if (parentPath && !state.entries[parentPath].opened) {
+ commit(types.TOGGLE_TREE_OPEN, parentPath);
+ }
+};
export const scrollToTab = () => {
Vue.nextTick(() => {
@@ -133,28 +106,40 @@ export const scrollToTab = () => {
});
};
-export const stageAllChanges = ({ state, commit, dispatch }) => {
+export const stageAllChanges = ({ state, commit, dispatch, getters }) => {
const openFile = state.openFiles[0];
commit(types.SET_LAST_COMMIT_MSG, '');
- state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path));
+ state.changedFiles.forEach(file =>
+ commit(types.STAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }),
+ );
- dispatch('openPendingTab', {
- file: state.stagedFiles.find(f => f.path === openFile.path),
- keyPrefix: stageKeys.staged,
- });
+ const file = getters.getStagedFile(openFile.path);
+
+ if (file) {
+ dispatch('openPendingTab', {
+ file,
+ keyPrefix: stageKeys.staged,
+ });
+ }
};
-export const unstageAllChanges = ({ state, commit, dispatch }) => {
+export const unstageAllChanges = ({ state, commit, dispatch, getters }) => {
const openFile = state.openFiles[0];
- state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path));
+ state.stagedFiles.forEach(file =>
+ commit(types.UNSTAGE_CHANGE, { path: file.path, diffInfo: getters.getDiffInfo(file.path) }),
+ );
- dispatch('openPendingTab', {
- file: state.changedFiles.find(f => f.path === openFile.path),
- keyPrefix: stageKeys.unstaged,
- });
+ const file = getters.getChangedFile(openFile.path);
+
+ if (file) {
+ dispatch('openPendingTab', {
+ file,
+ keyPrefix: stageKeys.unstaged,
+ });
+ }
};
export const updateViewer = ({ commit }, viewer) => {
@@ -212,8 +197,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
const entry = state.entries[path];
const { prevPath, prevName, prevParentPath } = entry;
const isTree = entry.type === 'tree';
+ const prevEntry = prevPath && state.entries[prevPath];
- if (prevPath) {
+ if (prevPath && (!prevEntry || prevEntry.deleted)) {
dispatch('renameEntry', {
path,
name: prevName,
@@ -222,7 +208,9 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
dispatch('deleteEntry', prevPath);
return;
}
- if (state.unusedSeal) dispatch('burstUnusedSeal');
+
+ dispatch('burstUnusedSeal');
+
if (entry.opened) dispatch('closeFile', entry);
if (isTree) {
@@ -241,9 +229,14 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
-export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => {
+export const renameEntry = ({ dispatch, commit, state, getters }, { path, name, parentPath }) => {
const entry = state.entries[path];
const newPath = parentPath ? `${parentPath}/${name}` : name;
+ const existingParent = parentPath && state.entries[parentPath];
+
+ if (parentPath && (!existingParent || existingParent.deleted)) {
+ dispatch('createTempEntry', { name: parentPath, type: 'tree' });
+ }
commit(types.RENAME_ENTRY, { path, name, parentPath });
@@ -266,7 +259,11 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPat
if (isReset) {
commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry);
} else if (!isInChanges) {
- commit(types.ADD_FILE_TO_CHANGED, newPath);
+ if (gon.features?.stageAllByDefault)
+ commit(types.STAGE_CHANGE, { path: newPath, diffInfo: getters.getDiffInfo(newPath) });
+ else commit(types.ADD_FILE_TO_CHANGED, newPath);
+
+ dispatch('burstUnusedSeal');
}
if (!newEntry.tempFile) {
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index 8864224c19e..70a966afa66 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -61,8 +61,10 @@ export const getFileData = (
{ path, makeFileActive = true, openFile = makeFileActive },
) => {
const file = state.entries[path];
+ const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
- if (file.raw || (file.tempFile && !file.prevPath)) return Promise.resolve();
+ if (file.raw || (file.tempFile && !file.prevPath && !fileDeletedAndReadded))
+ return Promise.resolve();
commit(types.TOGGLE_LOADING, { entry: file });
@@ -102,11 +104,16 @@ export const setFileMrChange = ({ commit }, { file, mrChange }) => {
export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) => {
const file = state.entries[path];
+ const stagedFile = state.stagedFiles.find(f => f.path === path);
+
return new Promise((resolve, reject) => {
+ const fileDeletedAndReadded = getters.isFileDeletedAndReadded(path);
service
- .getRawFileData(file)
+ .getRawFileData(fileDeletedAndReadded ? stagedFile : file)
.then(raw => {
- if (!(file.tempFile && !file.prevPath)) commit(types.SET_FILE_RAW_DATA, { file, raw });
+ if (!(file.tempFile && !file.prevPath && !fileDeletedAndReadded))
+ commit(types.SET_FILE_RAW_DATA, { file, raw, fileDeletedAndReadded });
+
if (file.mrChange && file.mrChange.new_file === false) {
const baseSha =
(getters.currentMergeRequest && getters.currentMergeRequest.baseCommitSha) || '';
@@ -140,7 +147,7 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) =
});
};
-export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => {
+export const changeFileContent = ({ commit, dispatch, state, getters }, { path, content }) => {
const file = state.entries[path];
commit(types.UPDATE_FILE_CONTENT, {
path,
@@ -150,8 +157,10 @@ export const changeFileContent = ({ commit, dispatch, state }, { path, content }
const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path);
if (file.changed && indexOfChangedFile === -1) {
- commit(types.ADD_FILE_TO_CHANGED, path);
- } else if (!file.changed && indexOfChangedFile !== -1) {
+ if (gon.features?.stageAllByDefault)
+ commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) });
+ else commit(types.ADD_FILE_TO_CHANGED, path);
+ } else if (!file.changed && !file.tempFile && indexOfChangedFile !== -1) {
commit(types.REMOVE_FILE_FROM_CHANGED, path);
}
@@ -184,23 +193,40 @@ export const setFileViewMode = ({ commit }, { file, viewMode }) => {
commit(types.SET_FILE_VIEWMODE, { file, viewMode });
};
-export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
+export const restoreOriginalFile = ({ dispatch, state, commit }, path) => {
const file = state.entries[path];
+ const isDestructiveDiscard = file.tempFile || file.prevPath;
if (file.deleted && file.parentPath) {
dispatch('restoreTree', file.parentPath);
}
- commit(types.DISCARD_FILE_CHANGES, path);
- commit(types.REMOVE_FILE_FROM_CHANGED, path);
+ if (isDestructiveDiscard) {
+ dispatch('closeFile', file);
+ }
+
+ if (file.tempFile) {
+ dispatch('deleteEntry', file.path);
+ } else {
+ commit(types.DISCARD_FILE_CHANGES, file.path);
+ }
if (file.prevPath) {
- dispatch('discardFileChanges', file.prevPath);
+ dispatch('renameEntry', {
+ path: file.path,
+ name: file.prevName,
+ parentPath: file.prevParentPath,
+ });
}
+};
- if (file.tempFile && file.opened) {
- commit(types.TOGGLE_FILE_OPEN, path);
- } else if (getters.activeFile && file.path === getters.activeFile.path) {
+export const discardFileChanges = ({ dispatch, state, commit, getters }, path) => {
+ const file = state.entries[path];
+ const isDestructiveDiscard = file.tempFile || file.prevPath;
+
+ dispatch('restoreOriginalFile', path);
+
+ if (!isDestructiveDiscard && file.path === getters.activeFile?.path) {
dispatch('updateDelayViewerUpdated', true)
.then(() => {
router.push(`/project${file.url}`);
@@ -210,24 +236,26 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) =
});
}
+ commit(types.REMOVE_FILE_FROM_CHANGED, path);
+
eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.content);
eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content);
};
-export const stageChange = ({ commit, state, dispatch }, path) => {
- const stagedFile = state.stagedFiles.find(f => f.path === path);
- const openFile = state.openFiles.find(f => f.path === path);
+export const stageChange = ({ commit, dispatch, getters }, path) => {
+ const stagedFile = getters.getStagedFile(path);
+ const openFile = getters.getOpenFile(path);
- commit(types.STAGE_CHANGE, path);
+ commit(types.STAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) });
commit(types.SET_LAST_COMMIT_MSG, '');
if (stagedFile) {
eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content);
}
- if (openFile && openFile.active) {
- const file = state.stagedFiles.find(f => f.path === path);
+ const file = getters.getStagedFile(path);
+ if (openFile && openFile.active && file) {
dispatch('openPendingTab', {
file,
keyPrefix: stageKeys.staged,
@@ -235,14 +263,14 @@ export const stageChange = ({ commit, state, dispatch }, path) => {
}
};
-export const unstageChange = ({ commit, dispatch, state }, path) => {
- const openFile = state.openFiles.find(f => f.path === path);
+export const unstageChange = ({ commit, dispatch, getters }, path) => {
+ const openFile = getters.getOpenFile(path);
- commit(types.UNSTAGE_CHANGE, path);
+ commit(types.UNSTAGE_CHANGE, { path, diffInfo: getters.getDiffInfo(path) });
- if (openFile && openFile.active) {
- const file = state.changedFiles.find(f => f.path === path);
+ const file = getters.getChangedFile(path);
+ if (openFile && openFile.active && file) {
dispatch('openPendingTab', {
file,
keyPrefix: stageKeys.unstaged,
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 6790c0fbdaa..806ec38430c 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -141,7 +141,7 @@ export const getMergeRequestVersions = (
});
export const openMergeRequest = (
- { dispatch, state },
+ { dispatch, state, getters },
{ projectId, targetProjectId, mergeRequestId } = {},
) =>
dispatch('getMergeRequestData', {
@@ -152,17 +152,18 @@ export const openMergeRequest = (
.then(mr => {
dispatch('setCurrentBranchId', mr.source_branch);
- // 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,
- }).then(() =>
- dispatch('getFiles', {
+ }).then(() => {
+ const branch = getters.findBranch(projectId, mr.source_branch);
+
+ return dispatch('getFiles', {
projectId,
branchId: mr.source_branch,
- }),
- );
+ ref: branch.commit.id,
+ });
+ });
})
.then(() =>
dispatch('getMergeRequestVersions', {
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index 20887e7d0ac..e206f9bee9e 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -83,8 +83,11 @@ export const showBranchNotFoundError = ({ dispatch }, branchId) => {
});
};
-export const showEmptyState = ({ commit, state }, { projectId, branchId }) => {
+export const showEmptyState = ({ commit, state, dispatch }, { projectId, branchId }) => {
const treePath = `${projectId}/${branchId}`;
+
+ dispatch('setCurrentBranchId', branchId);
+
commit(types.CREATE_TREE, { treePath });
commit(types.TOGGLE_LOADING, {
entry: state.trees[treePath],
@@ -111,7 +114,7 @@ export const loadFile = ({ dispatch, state }, { basePath }) => {
}
};
-export const loadBranch = ({ dispatch }, { projectId, branchId }) =>
+export const loadBranch = ({ dispatch, getters }, { projectId, branchId }) =>
dispatch('getBranchData', {
projectId,
branchId,
@@ -121,9 +124,13 @@ export const loadBranch = ({ dispatch }, { projectId, branchId }) =>
projectId,
branchId,
});
+
+ const branch = getters.findBranch(projectId, branchId);
+
return dispatch('getFiles', {
projectId,
branchId,
+ ref: branch.commit.id,
});
})
.catch(() => {
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index 72cd099c5a5..ba85194b910 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -46,19 +46,20 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL
});
};
-export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) =>
+export const getFiles = ({ state, commit, dispatch }, payload = {}) =>
new Promise((resolve, reject) => {
+ const { projectId, branchId, ref = branchId } = payload;
+
if (
!state.trees[`${projectId}/${branchId}`] ||
(state.trees[`${projectId}/${branchId}`].tree &&
state.trees[`${projectId}/${branchId}`].tree.length === 0)
) {
const selectedProject = state.projects[projectId];
- const selectedBranch = getters.findBranch(projectId, branchId);
commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` });
service
- .getFiles(selectedProject.web_url, selectedBranch.commit.id)
+ .getFiles(selectedProject.web_url, ref)
.then(({ data }) => {
const { entries, treeList } = decorateFiles({
data,
@@ -77,8 +78,8 @@ export const getFiles = ({ state, commit, dispatch, getters }, { projectId, bran
.catch(e => {
dispatch('setErrorMessage', {
text: __('An error occurred whilst loading all the files.'),
- action: payload =>
- dispatch('getFiles', payload).then(() => dispatch('setErrorMessage', null)),
+ action: actionPayload =>
+ dispatch('getFiles', actionPayload).then(() => dispatch('setErrorMessage', null)),
actionText: __('Please try again'),
actionPayload: { projectId, branchId },
});
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index bb8374b4e78..2fc574cd343 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -64,6 +64,7 @@ export const allBlobs = state =>
export const getChangedFile = state => path => state.changedFiles.find(f => f.path === path);
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
+export const getOpenFile = state => path => state.openFiles.find(f => f.path === path);
export const lastOpenedFile = state =>
[...state.changedFiles, ...state.stagedFiles].sort((a, b) => b.lastOpenedAt - a.lastOpenedAt)[0];
diff --git a/app/assets/javascripts/ide/stores/modules/pane/actions.js b/app/assets/javascripts/ide/stores/modules/pane/actions.js
index 7f5d167a14f..a8fcdf539ec 100644
--- a/app/assets/javascripts/ide/stores/modules/pane/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/pane/actions.js
@@ -1,17 +1,17 @@
import * as types from './mutation_types';
-export const toggleOpen = ({ dispatch, state }, view) => {
+export const toggleOpen = ({ dispatch, state }) => {
if (state.isOpen) {
dispatch('close');
} else {
- dispatch('open', view);
+ dispatch('open');
}
};
-export const open = ({ commit }, view) => {
+export const open = ({ state, commit }, view) => {
commit(types.SET_OPEN, true);
- if (view) {
+ if (view && view.name !== state.currentView) {
const { name, keepAlive } = view;
commit(types.SET_CURRENT_VIEW, name);
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index f0b4718d025..4dde53a9fdf 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -11,7 +11,6 @@ export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT';
export const SET_CURRENT_PROJECT = 'SET_CURRENT_PROJECT';
-export const TOGGLE_PROJECT_OPEN = 'TOGGLE_PROJECT_OPEN';
export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
// Merge Request Mutation Types
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 8caeb2d73b2..313fa1fe029 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -54,27 +54,29 @@ export default {
}
});
},
- [types.SET_FILE_RAW_DATA](state, { file, raw }) {
+ [types.SET_FILE_RAW_DATA](state, { file, raw, fileDeletedAndReadded = false }) {
const openPendingFile = state.openFiles.find(
- f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath),
+ f =>
+ f.path === file.path && f.pending && !(f.tempFile && !f.prevPath && !fileDeletedAndReadded),
);
+ const stagedFile = state.stagedFiles.find(f => f.path === file.path);
- if (file.tempFile && file.content === '') {
- Object.assign(state.entries[file.path], {
- content: raw,
- });
+ if (file.tempFile && file.content === '' && !fileDeletedAndReadded) {
+ Object.assign(state.entries[file.path], { content: raw });
+ } else if (fileDeletedAndReadded) {
+ Object.assign(stagedFile, { raw });
} else {
- Object.assign(state.entries[file.path], {
- raw,
- });
+ Object.assign(state.entries[file.path], { raw });
}
if (!openPendingFile) return;
if (!openPendingFile.tempFile) {
openPendingFile.raw = raw;
- } else if (openPendingFile.tempFile) {
+ } else if (openPendingFile.tempFile && !fileDeletedAndReadded) {
openPendingFile.content = raw;
+ } else if (fileDeletedAndReadded) {
+ Object.assign(stagedFile, { raw });
}
},
[types.SET_FILE_BASE_RAW_DATA](state, { file, baseRaw }) {
@@ -132,7 +134,7 @@ export default {
[types.DISCARD_FILE_CHANGES](state, path) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
const entry = state.entries[path];
- const { deleted, prevPath } = entry;
+ const { deleted } = entry;
Object.assign(state.entries[path], {
content: stagedFile ? stagedFile.content : state.entries[path].raw,
@@ -146,12 +148,6 @@ export default {
: state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
parent.tree = sortTree(parent.tree.concat(entry));
- } else if (prevPath) {
- const parent = entry.parentPath
- ? state.entries[entry.parentPath]
- : state.trees[`${state.currentProjectId}/${state.currentBranchId}`];
-
- parent.tree = parent.tree.filter(f => f.path !== path);
}
},
[types.ADD_FILE_TO_CHANGED](state, path) {
@@ -164,31 +160,32 @@ export default {
changedFiles: state.changedFiles.filter(f => f.path !== path),
});
},
- [types.STAGE_CHANGE](state, path) {
+ [types.STAGE_CHANGE](state, { path, diffInfo }) {
const stagedFile = state.stagedFiles.find(f => f.path === path);
Object.assign(state, {
changedFiles: state.changedFiles.filter(f => f.path !== path),
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
- staged: true,
+ staged: diffInfo.exists,
+ changed: diffInfo.changed,
+ tempFile: diffInfo.tempFile,
+ deleted: diffInfo.deleted,
}),
}),
});
if (stagedFile) {
- Object.assign(stagedFile, {
- ...state.entries[path],
- });
+ Object.assign(stagedFile, { ...state.entries[path] });
} else {
- Object.assign(state, {
- stagedFiles: state.stagedFiles.concat({
- ...state.entries[path],
- }),
- });
+ state.stagedFiles = [...state.stagedFiles, { ...state.entries[path] }];
+ }
+
+ if (!diffInfo.exists) {
+ state.stagedFiles = state.stagedFiles.filter(f => f.path !== path);
}
},
- [types.UNSTAGE_CHANGE](state, path) {
+ [types.UNSTAGE_CHANGE](state, { path, diffInfo }) {
const changedFile = state.changedFiles.find(f => f.path === path);
const stagedFile = state.stagedFiles.find(f => f.path === path);
@@ -201,9 +198,11 @@ export default {
changed: true,
});
- Object.assign(state, {
- changedFiles: state.changedFiles.concat(state.entries[path]),
- });
+ state.changedFiles = state.changedFiles.concat(state.entries[path]);
+ }
+
+ if (!diffInfo.exists) {
+ state.changedFiles = state.changedFiles.filter(f => f.path !== path);
}
Object.assign(state, {
@@ -211,6 +210,9 @@ export default {
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: false,
+ changed: diffInfo.changed,
+ tempFile: diffInfo.tempFile,
+ deleted: diffInfo.deleted,
}),
}),
});
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index d400b9831a9..6488389977c 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -31,4 +31,5 @@ export default () => ({
entry: {},
},
clientsidePreviewEnabled: false,
+ renderWhitespaceInCode: false,
});
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
index 3c6c9c71b8c..6e227ab3d82 100644
--- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -2,7 +2,6 @@
import { mapState, mapGetters, mapActions } from 'vuex';
import Select2Select from '~/vue_shared/components/select2_select.vue';
import { __ } from '~/locale';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '../event_hub';
import { STATUSES } from '../constants';
import ImportStatus from './import_status.vue';
@@ -11,7 +10,6 @@ export default {
name: 'ProviderRepoTableRow',
components: {
Select2Select,
- LoadingButton,
ImportStatus,
},
props: {
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index 48e7ed1318d..566efa0d7d6 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import Cookies from 'js-cookie';
-import bp from './breakpoints';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import UsersSelect from './users_select';
export default class IssuableContext {
@@ -48,7 +48,9 @@ export default class IssuableContext {
window.addEventListener('beforeunload', () => {
// collapsed_gutter cookie hides the sidebar
const bpBreakpoint = bp.getBreakpointSize();
- if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
+ const supportedSizes = ['xs', 'sm', 'md'];
+
+ if (supportedSizes.includes(bpBreakpoint)) {
Cookies.set('collapsed_gutter', true);
}
});
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index 1d0807dc15d..cf780556c8d 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -8,19 +8,23 @@ import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
import { queryToObject, objectToQuery } from './lib/utils/url_utility';
+const MR_SOURCE_BRANCH = 'merge_request[source_branch]';
+const MR_TARGET_BRANCH = 'merge_request[target_branch]';
+
function organizeQuery(obj, isFallbackKey = false) {
- const sourceBranch = 'merge_request[source_branch]';
- const targetBranch = 'merge_request[target_branch]';
+ if (!obj[MR_SOURCE_BRANCH] && !obj[MR_TARGET_BRANCH]) {
+ return obj;
+ }
if (isFallbackKey) {
return {
- [sourceBranch]: obj[sourceBranch],
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
};
}
return {
- [sourceBranch]: obj[sourceBranch],
- [targetBranch]: obj[targetBranch],
+ [MR_SOURCE_BRANCH]: obj[MR_SOURCE_BRANCH],
+ [MR_TARGET_BRANCH]: obj[MR_TARGET_BRANCH],
};
}
@@ -87,7 +91,8 @@ export default class IssuableForm {
}
initAutosave() {
- const searchTerm = format(document.location.search);
+ const { search } = document.location;
+ const searchTerm = format(search);
const fallbackKey = getFallbackKey();
this.autosave = new Autosave(
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 859f839741f..809b3d5f57e 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -2,9 +2,9 @@
import _ from 'underscore';
import { mapGetters, mapState, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
import { polyfillSticky } from '~/lib/utils/sticky';
-import bp from '~/breakpoints';
import CiHeader from '~/vue_shared/components/header_ci_component.vue';
import Callout from '~/vue_shared/components/callout.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -200,7 +200,8 @@ export default {
this.updateScroll();
},
updateSidebar() {
- if (bp.getBreakpointSize() === 'xs') {
+ const breakpoint = bp.getBreakpointSize();
+ if (breakpoint === 'xs' || breakpoint === 'sm') {
this.hideSidebar();
} else if (!this.isSidebarOpen) {
this.showSidebar();
diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue
index eb0de53f36a..f0bdbde0602 100644
--- a/app/assets/javascripts/jobs/components/log/log.vue
+++ b/app/assets/javascripts/jobs/components/log/log.vue
@@ -49,7 +49,7 @@ export default {
};
</script>
<template>
- <code class="job-log d-block">
+ <code class="job-log d-block" data-qa-selector="job_log_content">
<template v-for="(section, index) in trace">
<collpasible-log-section
v-if="section.isHeader"
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 6e92b599b0a..09f9647a680 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -2,12 +2,10 @@
import _ from 'underscore';
import { GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
-import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
CiIcon,
- Icon,
GlLink,
},
props: {
diff --git a/app/assets/javascripts/jobs/svg/scroll_down.svg b/app/assets/javascripts/jobs/svg/scroll_down.svg
index 1d22870ec09..fb934f68704 100644
--- a/app/assets/javascripts/jobs/svg/scroll_down.svg
+++ b/app/assets/javascripts/jobs/svg/scroll_down.svg
@@ -1,5 +1,4 @@
-<svg width="12" height="16" viewBox="0 0 12 16" xmlns="http://www.w3.org/2000/svg">
- <path class="first-triangle" d="M1.048 14.155a.508.508 0 0 0-.32.105c-.091.07-.136.154-.136.25v.71c0 .095.045.178.135.249.09.07.197.105.321.105h10.043c.124 0 .23-.035.321-.105.09-.07.136-.154.136-.25v-.71c0-.095-.045-.178-.136-.249a.508.508 0 0 0-.32-.105"/>
- <path class="second-triangle" d="M.687 8.027c-.09-.087-.122-.16-.093-.22.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 12.91a.458.458 0 0 1-.136.089h-.37a.626.626 0 0 1-.136-.09"/>
- <path class="third-triangle" d="M.687 1.027C.597.94.565.867.594.807c.028-.06.104-.09.228-.09h10.5c.123 0 .2.03.228.09.029.06-.002.133-.093.22L6.393 5.91A.458.458 0 0 1 6.257 6h-.37a.626.626 0 0 1-.136-.09"/>
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path class="scroll-arrow" d="M8 10.4142L4.29289 6.70711C3.90237 6.31658 3.90237 5.68342 4.29289 5.2929C4.68342 4.90237 5.31658 4.90237 5.70711 5.2929L7 6.58579L7 1C7 0.447715 7.44772 0 8 0C8.55229 0 9 0.447715 9 1L9 6.58579L10.2929 5.2929C10.6834 4.90237 11.3166 4.90237 11.7071 5.2929C12.0976 5.68342 12.0976 6.31658 11.7071 6.70711L8 10.4142Z"/>
+<path class="scroll-dot" d="M8 16C9.10457 16 10 15.1046 10 14C10 12.8954 9.10457 12 8 12C6.89543 12 6 12.8954 6 14C6 15.1046 6.89543 16 8 16Z"/>
</svg>
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index 6abf723be9a..f57febbda37 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -45,6 +45,7 @@ export default class LabelsSelect {
const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip');
const $value = $block.find('.value');
const $dropdownMenu = $dropdown.parent().find('.dropdown-menu');
+ // eslint-disable-next-line no-jquery/no-fade
const $loading = $block.find('.block-loading').fadeOut();
const fieldName = $dropdown.data('fieldName');
let initialSelected = $selectbox
@@ -84,6 +85,7 @@ export default class LabelsSelect {
if (!selected.length) {
data[abilityName].label_ids = [''];
}
+ // eslint-disable-next-line no-jquery/no-fade
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
axios
@@ -91,6 +93,7 @@ export default class LabelsSelect {
.then(({ data }) => {
let labelTooltipTitle;
let template;
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
$dropdown.trigger('loaded.gl.dropdown');
$selectbox.hide();
@@ -361,6 +364,7 @@ export default class LabelsSelect {
const label = clickEvent.selectedObj;
const fadeOutLoader = () => {
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
};
@@ -422,6 +426,7 @@ export default class LabelsSelect {
boardsStore.detail.issue.labels = labels;
}
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeIn();
const oldLabels = boardsStore.detail.issue.labels;
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index e4001e94478..a2591180039 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -2,12 +2,12 @@
* @module common-utils
*/
+import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils';
import $ from 'jquery';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase, convertToSnakeCase } from './text_utility';
import { isObject } from './type_utility';
-import breakpointInstance from '../../breakpoints';
export const getPagePath = (index = 0) => {
const page = $('body').attr('data-page') || '';
@@ -135,7 +135,9 @@ export const handleLocationHash = () => {
adjustment -= topPadding;
}
- window.scrollBy(0, adjustment);
+ setTimeout(() => {
+ window.scrollBy(0, adjustment);
+ });
};
// Check if element scrolled into viewport from above or below
@@ -247,6 +249,7 @@ export const scrollToElement = element => {
}
const { top } = $el.offset();
+ // eslint-disable-next-line no-jquery/no-animate
return $('body, html').animate(
{
scrollTop: top - contentTop(),
@@ -481,6 +484,16 @@ export const historyPushState = newUrl => {
};
/**
+ * Based on the current location and the string parameters provided
+ * overwrites the current entry in the history without reloading the page.
+ *
+ * @param {String} param
+ */
+export const historyReplaceState = newUrl => {
+ window.history.replaceState({}, document.title, newUrl);
+};
+
+/**
* Returns true for a String value of "true" and false otherwise.
* This is the opposite of Boolean(...).toString().
* `parseBoolean` is idempotent.
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 996692bacb3..fd9a13be18b 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -392,15 +392,21 @@ export const getTimeframeWindowFrom = (initialStartDate, length) => {
* @param {Date} date
* @param {Array} quarter
*/
-export const dayInQuarter = (date, quarter) =>
- quarter.reduce((acc, month) => {
- if (date.getMonth() > month.getMonth()) {
+export const dayInQuarter = (date, quarter) => {
+ const dateValues = {
+ date: date.getDate(),
+ month: date.getMonth(),
+ };
+
+ return quarter.reduce((acc, month) => {
+ if (dateValues.month > month.getMonth()) {
return acc + totalDaysInMonth(month);
- } else if (date.getMonth() === month.getMonth()) {
- return acc + date.getDate();
+ } else if (dateValues.month === month.getMonth()) {
+ return acc + dateValues.date;
}
return acc + 0;
}, 0);
+};
window.gl = window.gl || {};
window.gl.utils = {
@@ -464,7 +470,7 @@ export const pikadayToString = date => {
*/
export const parseSeconds = (
seconds,
- { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {},
+ { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false, limitToDays = false } = {},
) => {
const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = hoursPerDay;
@@ -480,8 +486,11 @@ export const parseSeconds = (
minutes: 1,
};
- if (limitToHours) {
+ if (limitToDays || limitToHours) {
timePeriodConstraints.weeks = 0;
+ }
+
+ if (limitToHours) {
timePeriodConstraints.days = 0;
}
@@ -546,6 +555,16 @@ export const calculateRemainingMilliseconds = endDate => {
export const getDateInPast = (date, daysInPast) =>
new Date(newDate(date).setDate(date.getDate() - daysInPast));
+/**
+ * Adds a given number of days to a given date and returns the new date.
+ *
+ * @param {Date} date the date that we will add days to
+ * @param {Number} daysInFuture number of days that are added to a given date
+ * @returns {Date} Date in future as Date object
+ */
+export const getDateInFuture = (date, daysInFuture) =>
+ new Date(newDate(date).setDate(date.getDate() + daysInFuture));
+
/*
* 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
@@ -606,3 +625,44 @@ export const secondsToDays = seconds => Math.round(seconds / 86400);
* @return {Date} the date following the date provided
*/
export const dayAfter = date => new Date(newDate(date).setDate(date.getDate() + 1));
+
+/**
+ * Mimics the behaviour of the rails distance_of_time_in_words function
+ * https://api.rubyonrails.org/v6.0.1/classes/ActionView/Helpers/DateHelper.html#method-i-distance_of_time_in_words
+ * 0 < -> 29 secs => less than a minute
+ * 30 secs < -> 1 min, 29 secs => 1 minute
+ * 1 min, 30 secs < -> 44 mins, 29 secs => [2..44] minutes
+ * 44 mins, 30 secs < -> 89 mins, 29 secs => about 1 hour
+ * 89 mins, 30 secs < -> 23 hrs, 59 mins, 29 secs => about[2..24]hours
+ * 23 hrs, 59 mins, 30 secs < -> 41 hrs, 59 mins, 29 secs => 1 day
+ * 41 hrs, 59 mins, 30 secs => x days
+ *
+ * @param {Number} seconds
+ * @return {String} approximated time
+ */
+export const approximateDuration = (seconds = 0) => {
+ if (!_.isNumber(seconds) || seconds < 0) {
+ return '';
+ }
+
+ const ONE_MINUTE_LIMIT = 90; // 1 minute 30s
+ const MINUTES_LIMIT = 2670; // 44 minutes 30s
+ const ONE_HOUR_LIMIT = 5370; // 89 minutes 30s
+ const HOURS_LIMIT = 86370; // 23 hours 59 minutes 30s
+ const ONE_DAY_LIMIT = 151170; // 41 hours 59 minutes 30s
+
+ const { days = 0, hours = 0, minutes = 0 } = parseSeconds(seconds, {
+ daysPerWeek: 7,
+ hoursPerDay: 24,
+ limitToDays: true,
+ });
+
+ if (seconds < 30) {
+ return __('less than a minute');
+ } else if (seconds < MINUTES_LIMIT) {
+ return n__('1 minute', '%d minutes', seconds < ONE_MINUTE_LIMIT ? 1 : minutes);
+ } else if (seconds < HOURS_LIMIT) {
+ return n__('about 1 hour', 'about %d hours', seconds < ONE_HOUR_LIMIT ? 1 : hours);
+ }
+ return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days);
+};
diff --git a/app/assets/javascripts/lib/utils/keycodes.js b/app/assets/javascripts/lib/utils/keycodes.js
index 5e0f9b612a2..2270d329c24 100644
--- a/app/assets/javascripts/lib/utils/keycodes.js
+++ b/app/assets/javascripts/lib/utils/keycodes.js
@@ -2,3 +2,4 @@ export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
+export const BACKSPACE_KEY_CODE = 8;
diff --git a/app/assets/javascripts/lib/utils/poll_until_complete.js b/app/assets/javascripts/lib/utils/poll_until_complete.js
new file mode 100644
index 00000000000..199d0e6f0f7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/poll_until_complete.js
@@ -0,0 +1,42 @@
+import axios from '~/lib/utils/axios_utils';
+import Poll from './poll';
+import httpStatusCodes from './http_status';
+
+/**
+ * Polls an endpoint until it returns either a 200 OK or a error status.
+ * The Poll-Interval header in the responses are used to determine how
+ * frequently to poll.
+ *
+ * Once a 200 OK is received, the promise resolves with that response. If an
+ * error status is received, the promise rejects with the error.
+ *
+ * @param {string} url - The URL to poll.
+ * @param {Object} [config] - The config to provide to axios.get().
+ * @returns {Promise}
+ */
+export default (url, config = {}) =>
+ new Promise((resolve, reject) => {
+ const eTagPoll = new Poll({
+ resource: {
+ axiosGet(data) {
+ return axios.get(data.url, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ ...data.config,
+ });
+ },
+ },
+ data: { url, config },
+ method: 'axiosGet',
+ successCallback: response => {
+ if (response.status === httpStatusCodes.OK) {
+ resolve(response);
+ eTagPoll.stop();
+ }
+ },
+ errorCallback: reject,
+ });
+
+ eTagPoll.makeRequest();
+ });
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 6bbf118d7d1..a03fedcd7e7 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -21,12 +21,17 @@ export const addDelimiter = text =>
export const highCountTrim = count => (count > 99 ? '99+' : count);
/**
- * Converts first char to uppercase and replaces undercores with spaces
- * @param {String} string
+ * Converts first char to uppercase and replaces the given separator with spaces
+ * @param {String} string - The string to humanize
+ * @param {String} separator - The separator used to separate words (defaults to "_")
* @requires {String}
+ * @returns {String}
*/
-export const humanize = string =>
- string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
+export const humanize = (string, separator = '_') => {
+ const replaceRegex = new RegExp(separator, 'g');
+
+ return string.charAt(0).toUpperCase() + string.replace(replaceRegex, ' ').slice(1);
+};
/**
* Replaces underscores with dashes
@@ -45,7 +50,11 @@ export const slugify = (str, separator = '-') => {
const slug = str
.trim()
.toLowerCase()
- .replace(/[^a-zA-Z0-9_.-]+/g, separator);
+ .replace(/[^a-zA-Z0-9_.-]+/g, separator)
+ // Remove any duplicate separators or separator prefixes/suffixes
+ .split(separator)
+ .filter(Boolean)
+ .join(separator);
return slug === separator ? '' : slug;
};
@@ -160,6 +169,15 @@ export const convertToSentenceCase = string => {
};
/**
+ * Converts a sentence to title case
+ * e.g. Hello world => Hello World
+ *
+ * @param {String} string
+ * @returns {String}
+ */
+export const convertToTitleCase = string => string.replace(/\b[a-z]/g, s => s.toUpperCase());
+
+/**
* Splits camelCase or PascalCase words
* e.g. HelloWorld => Hello World
*
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 674415c9d01..d755e7e8cdb 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -19,7 +19,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility';
// everything else
import loadAwardsHandler from './awards_handler';
-import bp from './breakpoints';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import initTodoToggle from './header';
@@ -55,9 +55,18 @@ jQuery.ajaxSetup({
},
});
+function disableJQueryAnimations() {
+ $.fx.off = true;
+}
+
+// Disable jQuery animations
+if (gon && gon.disable_animations) {
+ disableJQueryAnimations();
+}
+
// inject test utilities if necessary
if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) {
- $.fx.off = true;
+ disableJQueryAnimations();
import(/* webpackMode: "eager" */ './test_utils/');
}
@@ -113,6 +122,7 @@ function deferredInitialisation() {
});
$('.js-remove-tr').on('ajax:success', function removeTRAjaxSuccessCallback() {
+ // eslint-disable-next-line no-jquery/no-fade
$(this)
.closest('tr')
.fadeOut();
@@ -184,7 +194,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
- if (bootstrapBreakpoint === 'xs') {
+ if (bootstrapBreakpoint === 'sm' || bootstrapBreakpoint === 'xs') {
const $rightSidebar = $('aside.right-sidebar, .layout-page');
$rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
@@ -212,7 +222,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Disable form buttons while a form is submitting
$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) {
- const $buttons = $('[type="submit"], .js-disable-on-submit', this);
+ const $buttons = $('[type="submit"], .js-disable-on-submit', this).not('.js-no-auto-disable');
switch (e.type) {
case 'ajax:beforeSend':
case 'submit':
@@ -269,7 +279,8 @@ document.addEventListener('DOMContentLoaded', () => {
});
$document.on('breakpoint:change', (e, breakpoint) => {
- if (breakpoint === 'sm' || breakpoint === 'xs') {
+ const breakpointSizes = ['md', 'sm', 'xs'];
+ if (breakpointSizes.includes(breakpoint)) {
const $gutterIcon = $sidebarGutterToggle.find('i');
if ($gutterIcon.hasClass('fa-angle-double-right')) {
$sidebarGutterToggle.trigger('click');
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 52674107df2..96c4741fc2e 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -2,12 +2,12 @@
import $ from 'jquery';
import Vue from 'vue';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import Cookies from 'js-cookie';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import BlobForkSuggestion from './blob/blob_fork_suggestion';
import initChangesDropdown from './init_changes_dropdown';
-import bp from './breakpoints';
import {
parseUrlPathname,
handleLocationHash,
@@ -194,7 +194,7 @@ export default class MergeRequestTabs {
if (!isInVueNoteablePage()) {
this.loadDiff(href);
}
- if (bp.getBreakpointSize() !== 'lg') {
+ if (bp.getBreakpointSize() !== 'xl') {
this.shrinkView();
}
this.expandViewContainer();
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 1738dbe439c..d15e4ecb537 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -52,6 +52,7 @@ export default class MilestoneSelect {
const $block = $selectBox.closest('.block');
const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon');
const $value = $block.find('.value');
+ // eslint-disable-next-line no-jquery/no-fade
const $loading = $block.find('.block-loading').fadeOut();
selectedMilestoneDefault = showAny ? '' : null;
selectedMilestoneDefault =
@@ -202,15 +203,18 @@ export default class MilestoneSelect {
}
$dropdown.trigger('loading.gl.dropdown');
+ // eslint-disable-next-line no-jquery/no-fade
$loading.removeClass('hidden').fadeIn();
boardsStore.detail.issue
.update($dropdown.attr('data-issue-update'))
.then(() => {
$dropdown.trigger('loaded.gl.dropdown');
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
})
.catch(() => {
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
});
} else {
@@ -218,12 +222,14 @@ export default class MilestoneSelect {
data = {};
data[abilityName] = {};
data[abilityName].milestone_id = selected != null ? selected : null;
+ // eslint-disable-next-line no-jquery/no-fade
$loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown');
return axios
.put(issueUpdateURL, data)
.then(({ data }) => {
$dropdown.trigger('loaded.gl.dropdown');
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
$selectBox.hide();
$value.css('display', '');
@@ -247,6 +253,7 @@ export default class MilestoneSelect {
}
})
.catch(() => {
+ // eslint-disable-next-line no-jquery/no-fade
$loading.fadeOut();
});
}
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 1df7ca37a98..64704701d1a 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -1,6 +1,6 @@
<script>
import { flatten, isNumber } from 'underscore';
-import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { hexToRgb } from '~/lib/utils/color_utils';
import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
@@ -48,7 +48,6 @@ const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})
*/
export default {
components: {
- GlLineChart,
GlChartSeriesLabel,
MonitorTimeSeriesChart,
},
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index c1ca5449ba3..b03ee12aef3 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -17,28 +17,35 @@ import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
+
import DateTimePicker from './date_time_picker/date_time_picker.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
+import DashboardsDropdown from './dashboards_dropdown.vue';
+
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils';
+import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
import { metricStates } from '../constants';
+const defaultTimeDiff = getTimeDiff();
+
export default {
components: {
VueDraggable,
PanelType,
- GraphGroup,
- EmptyState,
- GroupEmptyState,
Icon,
GlButton,
GlDropdown,
GlDropdownItem,
GlFormGroup,
GlModal,
+
DateTimePicker,
+ GraphGroup,
+ EmptyState,
+ GroupEmptyState,
+ DashboardsDropdown,
},
directives: {
GlModal: GlModalDirective,
@@ -81,6 +88,10 @@ export default {
type: String,
required: true,
},
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
metricsEndpoint: {
type: String,
required: true,
@@ -138,6 +149,11 @@ export default {
required: false,
default: invalidUrl,
},
+ dashboardsEndpoint: {
+ type: String,
+ required: false,
+ default: invalidUrl,
+ },
currentDashboard: {
type: String,
required: false,
@@ -168,9 +184,10 @@ export default {
return {
state: 'gettingStarted',
formIsValid: null,
- selectedTimeWindow: {},
- isRearrangingPanels: false,
+ startDate: getParameterValues('start')[0] || defaultTimeDiff.start,
+ endDate: getParameterValues('end')[0] || defaultTimeDiff.end,
hasValidDates: true,
+ isRearrangingPanels: false,
};
},
computed: {
@@ -196,9 +213,6 @@ export default {
selectedDashboard() {
return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard;
},
- selectedDashboardText() {
- return this.selectedDashboard.display_name;
- },
showRearrangePanelsBtn() {
return !this.showEmptyState && this.rearrangePanelsAvailable;
},
@@ -220,6 +234,7 @@ export default {
environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentsEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
+ dashboardsEndpoint: this.dashboardsEndpoint,
currentDashboard: this.currentDashboard,
projectPath: this.projectPath,
});
@@ -228,24 +243,10 @@ export default {
if (!this.hasMetrics) {
this.setGettingStartedEmptyState();
} else {
- const defaultRange = getTimeDiff();
- const start = getParameterValues('start')[0] || defaultRange.start;
- const end = getParameterValues('end')[0] || defaultRange.end;
-
- const range = {
- start,
- end,
- };
-
- this.selectedTimeWindow = range;
-
- if (!isValidDate(start) || !isValidDate(end)) {
- this.hasValidDates = false;
- this.showInvalidDateError();
- } else {
- this.hasValidDates = true;
- this.fetchData(range);
- }
+ this.fetchData({
+ start: this.startDate,
+ end: this.endDate,
+ });
}
},
methods: {
@@ -267,9 +268,20 @@ export default {
key,
});
},
- showInvalidDateError() {
- createFlash(s__('Metrics|Link contains an invalid time window.'));
+
+ onDateTimePickerApply(params) {
+ redirectTo(mergeUrlParams(params, window.location.href));
+ },
+ onDateTimePickerInvalid() {
+ createFlash(
+ s__(
+ 'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
+ ),
+ );
+ this.startDate = defaultTimeDiff.start;
+ this.endDate = defaultTimeDiff.end;
},
+
generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
@@ -287,9 +299,6 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
- onDateTimePickerApply(timeWindowUrlParams) {
- return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
- },
/**
* Return a single empty state for a group.
*
@@ -317,6 +326,13 @@ export default {
return !this.getMetricStates(groupKey).includes(metricStates.OK);
},
getAddMetricTrackingOptions,
+
+ selectDashboard(dashboard) {
+ const params = {
+ dashboard: dashboard.path,
+ };
+ redirectTo(mergeUrlParams(params, window.location.href));
+ },
},
addMetric: {
title: s__('Metrics|Add metric'),
@@ -336,21 +352,14 @@ export default {
label-for="monitor-dashboards-dropdown"
class="col-sm-12 col-md-6 col-lg-2"
>
- <gl-dropdown
+ <dashboards-dropdown
id="monitor-dashboards-dropdown"
- class="mb-0 d-flex js-dashboards-dropdown"
+ class="mb-0 d-flex"
toggle-class="dropdown-menu-toggle"
- :text="selectedDashboardText"
- >
- <gl-dropdown-item
- v-for="dashboard in allDashboards"
- :key="dashboard.path"
- :active="dashboard.path === currentDashboard"
- active-class="is-active"
- :href="`?dashboard=${dashboard.path}`"
- >{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item
- >
- </gl-dropdown>
+ :default-branch="defaultBranch"
+ :selected-dashboard="selectedDashboard"
+ @selectDashboard="selectDashboard($event)"
+ />
</gl-form-group>
<gl-form-group
@@ -378,15 +387,16 @@ export default {
</gl-form-group>
<gl-form-group
- v-if="hasValidDates"
:label="s__('Metrics|Show last')"
label-size="sm"
label-for="monitor-time-window-dropdown"
class="col-sm-6 col-md-6 col-lg-4"
>
<date-time-picker
- :selected-time-window="selectedTimeWindow"
- @onApply="onDateTimePickerApply"
+ :start="startDate"
+ :end="endDate"
+ @apply="onDateTimePickerApply"
+ @invalid="onDateTimePickerInvalid"
/>
</gl-form-group>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
new file mode 100644
index 00000000000..6d93eee0b4f
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -0,0 +1,139 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlModal,
+ GlLoadingIcon,
+ GlModalDirective,
+} from '@gitlab/ui';
+import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
+
+const events = {
+ selectDashboard: 'selectDashboard',
+};
+
+export default {
+ components: {
+ GlAlert,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlModal,
+ GlLoadingIcon,
+ DuplicateDashboardForm,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ selectedDashboard: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ alert: null,
+ loading: false,
+ form: {},
+ };
+ },
+ computed: {
+ ...mapState('monitoringDashboard', ['allDashboards']),
+ isSystemDashboard() {
+ return this.selectedDashboard.system_dashboard;
+ },
+ selectedDashboardText() {
+ return this.selectedDashboard.display_name;
+ },
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
+ selectDashboard(dashboard) {
+ this.$emit(events.selectDashboard, dashboard);
+ },
+ ok(bvModalEvt) {
+ // Prevent modal from hiding in case submit fails
+ bvModalEvt.preventDefault();
+
+ this.loading = true;
+ this.alert = null;
+ this.duplicateSystemDashboard(this.form)
+ .then(createdDashboard => {
+ this.loading = false;
+ this.alert = null;
+
+ // Trigger hide modal as submit is successful
+ this.$refs.duplicateDashboardModal.hide();
+
+ // Dashboards in the default branch become available immediately.
+ // Not so in other branches, so we refresh the current dashboard
+ const dashboard =
+ this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
+ this.$emit(events.selectDashboard, dashboard);
+ })
+ .catch(error => {
+ this.loading = false;
+ this.alert = error;
+ });
+ },
+ hide() {
+ this.alert = null;
+ },
+ formChange(form) {
+ this.form = form;
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText">
+ <gl-dropdown-item
+ v-for="dashboard in allDashboards"
+ :key="dashboard.path"
+ :active="dashboard.path === selectedDashboard.path"
+ active-class="is-active"
+ @click="selectDashboard(dashboard)"
+ >
+ {{ dashboard.display_name || dashboard.path }}
+ </gl-dropdown-item>
+
+ <template v-if="isSystemDashboard">
+ <gl-dropdown-divider />
+
+ <gl-modal
+ ref="duplicateDashboardModal"
+ modal-id="duplicateDashboardModal"
+ :title="s__('Metrics|Duplicate dashboard')"
+ ok-variant="success"
+ @ok="ok"
+ @hide="hide"
+ >
+ <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
+ {{ alert }}
+ </gl-alert>
+ <duplicate-dashboard-form
+ :dashboard="selectedDashboard"
+ :default-branch="defaultBranch"
+ @change="formChange"
+ />
+ <template #modal-ok>
+ <gl-loading-icon v-if="loading" inline color="light" />
+ {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }}
+ </template>
+ </gl-modal>
+
+ <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
+ {{ s__('Metrics|Duplicate dashboard') }}
+ </gl-dropdown-item>
+ </template>
+ </gl-dropdown>
+</template>
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 8749019c5cd..0aa710b1b3a 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
@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue';
import {
getTimeDiff,
+ isValidDate,
getTimeWindow,
stringToISODate,
ISODateToString,
truncateZerosInDateTime,
isDateTimePickerInputValid,
} from '~/monitoring/utils';
+
import { timeWindows } from '~/monitoring/constants';
+const events = {
+ apply: 'apply',
+ invalid: 'invalid',
+};
+
export default {
components: {
Icon,
@@ -23,77 +30,94 @@ export default {
GlDropdownItem,
},
props: {
+ start: {
+ type: String,
+ required: true,
+ },
+ end: {
+ type: String,
+ required: true,
+ },
timeWindows: {
type: Object,
required: false,
default: () => timeWindows,
},
- selectedTimeWindow: {
- type: Object,
- required: false,
- default: () => {},
- },
},
data() {
return {
- selectedTimeWindowText: '',
- customTime: {
- from: null,
- to: null,
- },
+ startDate: this.start,
+ endDate: this.end,
};
},
computed: {
- applyEnabled() {
- return Boolean(this.inputState.from && this.inputState.to);
+ startInputValid() {
+ return isValidDate(this.startDate);
},
- inputState() {
- const { from, to } = this.customTime;
- return {
- from: from && isDateTimePickerInputValid(from),
- to: to && isDateTimePickerInputValid(to),
- };
+ endInputValid() {
+ return isValidDate(this.endDate);
},
- },
- watch: {
- selectedTimeWindow() {
- this.verifyTimeRange();
+ isValid() {
+ return this.startInputValid && this.endInputValid;
+ },
+
+ startInput: {
+ get() {
+ return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
+ },
+ set(val) {
+ // Attempt to set a formatted date if possible
+ this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ },
+ },
+ endInput: {
+ get() {
+ return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
+ },
+ set(val) {
+ // Attempt to set a formatted date if possible
+ this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
+ },
+ },
+
+ timeWindowText() {
+ const timeWindow = getTimeWindow({ start: this.start, end: this.end });
+ if (timeWindow) {
+ return this.timeWindows[timeWindow];
+ } else if (isValidDate(this.start) && isValidDate(this.end)) {
+ return sprintf(s__('%{start} to %{end}'), {
+ start: this.formatDate(this.start),
+ end: this.formatDate(this.end),
+ });
+ }
+ return '';
},
},
mounted() {
- this.verifyTimeRange();
+ // Validate on mounted, and trigger an update if needed
+ if (!this.isValid) {
+ this.$emit(events.invalid);
+ }
},
methods: {
- activeTimeWindow(key) {
- return this.timeWindows[key] === this.selectedTimeWindowText;
+ formatDate(date) {
+ return truncateZerosInDateTime(ISODateToString(date));
},
- setCustomTimeWindowParameter() {
- this.$emit('onApply', {
- start: stringToISODate(this.customTime.from),
- end: stringToISODate(this.customTime.to),
- });
- },
- setTimeWindowParameter(key) {
+ setTimeWindow(key) {
const { start, end } = getTimeDiff(key);
- this.$emit('onApply', {
- start,
- end,
- });
+ this.startDate = start;
+ this.endDate = end;
+
+ this.apply();
},
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);
- }
+ apply() {
+ this.$emit(events.apply, {
+ start: this.startDate,
+ end: this.endDate,
+ });
},
},
};
@@ -101,7 +125,7 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- :text="selectedTimeWindowText"
+ :text="timeWindowText"
menu-class="time-window-dropdown-menu"
class="js-time-window-dropdown"
>
@@ -113,24 +137,21 @@ export default {
>
<date-time-picker-input
id="custom-time-from"
- v-model="customTime.from"
+ v-model="startInput"
:label="__('From')"
- :state="inputState.from"
+ :state="startInputValid"
/>
<date-time-picker-input
id="custom-time-to"
- v-model="customTime.to"
+ v-model="endInput"
:label="__('To')"
- :state="inputState.to"
+ :state="endInputValid"
/>
<gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
- <gl-button
- variant="success"
- :disabled="!applyEnabled"
- @click="setCustomTimeWindowParameter"
- >{{ __('Apply') }}</gl-button
- >
+ <gl-button variant="success" :disabled="!isValid" @click="apply()">
+ {{ __('Apply') }}
+ </gl-button>
</gl-form-group>
</gl-form-group>
<gl-form-group
@@ -142,14 +163,14 @@ export default {
<gl-dropdown-item
v-for="(value, key) in timeWindows"
:key="key"
- :active="activeTimeWindow(key)"
+ :active="value === timeWindowText"
active-class="active"
- @click="setTimeWindowParameter(key)"
+ @click="setTimeWindow(key)"
>
<icon
name="mobile-issue-close"
class="align-bottom"
- :class="{ invisible: !activeTimeWindow(key) }"
+ :class="{ invisible: value !== timeWindowText }"
/>
{{ value }}
</gl-dropdown-item>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
new file mode 100644
index 00000000000..e678957c1e5
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue
@@ -0,0 +1,138 @@
+<script>
+import { __, s__, sprintf } from '~/locale';
+import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui';
+
+const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0];
+
+export default {
+ components: {
+ GlFormGroup,
+ GlFormInput,
+ GlFormRadioGroup,
+ GlFormTextarea,
+ },
+ props: {
+ dashboard: {
+ type: Object,
+ required: true,
+ },
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ radioVals: {
+ /* Use the default branch (e.g. master) */
+ DEFAULT: 'DEFAULT',
+ /* Create a new branch */
+ NEW: 'NEW',
+ },
+ data() {
+ return {
+ form: {
+ dashboard: this.dashboard.path,
+ fileName: defaultFileName(this.dashboard),
+ commitMessage: '',
+ },
+ branchName: '',
+ branchOption: this.$options.radioVals.NEW,
+ branchOptions: [
+ {
+ value: this.$options.radioVals.DEFAULT,
+ html: sprintf(
+ __('Commit to %{branchName} branch'),
+ {
+ branchName: `<strong>${this.defaultBranch}</strong>`,
+ },
+ false,
+ ),
+ },
+ { value: this.$options.radioVals.NEW, text: __('Create new branch') },
+ ],
+ };
+ },
+ computed: {
+ defaultCommitMsg() {
+ return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), {
+ fileName: this.form.fileName,
+ });
+ },
+ fileNameState() {
+ // valid if empty or *.yml
+ return !(this.form.fileName && !this.form.fileName.endsWith('.yml'));
+ },
+ fileNameFeedback() {
+ return !this.fileNameState ? s__('The file name should have a .yml extension') : '';
+ },
+ },
+ mounted() {
+ this.change();
+ },
+ methods: {
+ change() {
+ this.$emit('change', {
+ ...this.form,
+ commitMessage: this.form.commitMessage || this.defaultCommitMsg,
+ branch:
+ this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch,
+ });
+ },
+ focus(option) {
+ if (option === this.$options.radioVals.NEW) {
+ this.$nextTick(() => {
+ this.$refs.branchName.$el.focus();
+ });
+ }
+ },
+ },
+};
+</script>
+<template>
+ <form @change="change">
+ <p class="text-muted">
+ {{
+ s__(`Metrics|You can save a copy of this dashboard to your repository
+ so it can be customized. Select a file name and branch to
+ save it.`)
+ }}
+ </p>
+ <gl-form-group
+ ref="fileNameFormGroup"
+ :label="__('File name')"
+ :state="fileNameState"
+ :invalid-feedback="fileNameFeedback"
+ label-size="sm"
+ label-for="fileName"
+ >
+ <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" />
+ </gl-form-group>
+ <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch">
+ <gl-form-radio-group
+ ref="branchOption"
+ v-model="branchOption"
+ :checked="$options.radioVals.NEW"
+ :stacked="true"
+ :options="branchOptions"
+ @change="focus"
+ />
+ <gl-form-input
+ v-show="branchOption === $options.radioVals.NEW"
+ id="branchName"
+ ref="branchName"
+ v-model="branchName"
+ />
+ </gl-form-group>
+ <gl-form-group
+ :label="__('Commit message (optional)')"
+ label-size="sm"
+ label-for="commitMessage"
+ >
+ <gl-form-textarea
+ id="commitMessage"
+ ref="commitMessage"
+ v-model="form.commitMessage"
+ :placeholder="defaultCommitMsg"
+ />
+ </gl-form-group>
+ </form>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue
index eb8945c1a57..2f562071764 100644
--- a/app/assets/javascripts/monitoring/components/embed.vue
+++ b/app/assets/javascripts/monitoring/components/embed.vue
@@ -2,7 +2,6 @@
import { mapActions, mapState, mapGetters } from 'vuex';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { getParameterValues, removeParams } from '~/lib/utils/url_utility';
-import GraphGroup from './graph_group.vue';
import { sidebarAnimationDuration } from '../constants';
import { getTimeDiff } from '../utils';
@@ -10,7 +9,6 @@ let sidebarMutationObserver;
export default {
components: {
- GraphGroup,
PanelType,
},
props: {
diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue
index 153c8f389db..ceeec51ee65 100644
--- a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue
+++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue
@@ -10,6 +10,6 @@ export default {
</script>
<template>
<div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5>
+ <h5 ref="title" class="prometheus-graph-title">{{ graphTitle }}</h5>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 1cb82ce0083..61cd8621902 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -39,7 +39,7 @@ export const requestMetricsDashboard = ({ commit }) => {
};
export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
- commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
+ commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard);
return dispatch('fetchPrometheusMetrics', params);
};
export const receiveMetricsDashboardFailure = ({ commit }, error) => {
@@ -214,5 +214,29 @@ export const setPanelGroupMetrics = ({ commit }, data) => {
commit(types.SET_PANEL_GROUP_METRICS, data);
};
+export const duplicateSystemDashboard = ({ state }, payload) => {
+ const params = {
+ dashboard: payload.dashboard,
+ file_name: payload.fileName,
+ branch: payload.branch,
+ commit_message: payload.commitMessage,
+ };
+
+ return axios
+ .post(state.dashboardsEndpoint, params)
+ .then(response => response.data)
+ .then(data => data.dashboard)
+ .catch(error => {
+ const { response } = error;
+ if (response && response.data && response.data.error) {
+ throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), {
+ error: response.data.error,
+ });
+ } else {
+ throw s__('Metrics|There was an error creating the dashboard.');
+ }
+ });
+};
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 16a34a6c026..506a30ae619 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -84,23 +84,26 @@ export default {
state.emptyState = 'loading';
state.showEmptyState = true;
},
- [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
- state.dashboard.panel_groups = groupData.map((group, i) => {
- const key = `${slugify(group.group || 'default')}-${i}`;
- let { panels = [] } = group;
-
- // each panel has metric information that needs to be normalized
- panels = panels.map(panel => ({
- ...panel,
- metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
- }));
-
- return {
- ...group,
- panels,
- key,
- };
- });
+ [types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) {
+ state.dashboard = {
+ ...dashboard,
+ panel_groups: dashboard.panel_groups.map((group, i) => {
+ const key = `${slugify(group.group || 'default')}-${i}`;
+ let { panels = [] } = group;
+
+ // each panel has metric information that needs to be normalized
+ panels = panels.map(panel => ({
+ ...panel,
+ metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
+ }));
+
+ return {
+ ...group,
+ panels,
+ key,
+ };
+ }),
+ };
if (!state.dashboard.panel_groups.length) {
state.emptyState = 'noData';
@@ -172,6 +175,7 @@ export default {
state.environmentsEndpoint = endpoints.environmentsEndpoint;
state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
state.dashboardEndpoint = endpoints.dashboardEndpoint;
+ state.dashboardsEndpoint = endpoints.dashboardsEndpoint;
state.currentDashboard = endpoints.currentDashboard;
state.projectPath = endpoints.projectPath;
},
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
index ce08b0964a1..bbc2feae812 100644
--- a/app/assets/javascripts/mr_popover/components/mr_popover.vue
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -1,7 +1,6 @@
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
-import Icon from '../../vue_shared/components/icon.vue';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import timeagoMixin from '../../vue_shared/mixins/timeago';
import query from '../queries/merge_request.query.graphql';
@@ -13,7 +12,6 @@ export default {
components: {
GlPopover,
GlSkeletonLoading,
- Icon,
CiIcon,
},
mixins: [timeagoMixin],
diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue
index da1e1e70993..f8293d2a473 100644
--- a/app/assets/javascripts/mr_tabs_popover/components/popover.vue
+++ b/app/assets/javascripts/mr_tabs_popover/components/popover.vue
@@ -57,7 +57,12 @@ export default {
<icon name="external-link" :size="10" />
</gl-link>
</p>
- <gl-button variant="primary" size="sm" @click="onDismiss">
+ <gl-button
+ variant="primary"
+ size="sm"
+ data-qa-selector="dismiss_popover_button"
+ @click="onDismiss"
+ >
{{ __('Got it') }}
</gl-button>
</gl-popover>
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 1a8f1c659a4..4195ea6425f 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -1359,7 +1359,8 @@ export default class Notes {
const $systemNote = $(systemNote);
const headerMessage = $systemNote
.find('.note-text')
- .find('p:first')
+ .find('p')
+ .first()
.text()
.replace(':', '');
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 492d8de3802..4ca32b9b005 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -336,6 +336,7 @@ export default {
<markdown-field
ref="markdownField"
+ :is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 1f31720ff40..3462ee72dd3 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -89,6 +89,9 @@ export default {
currentUser() {
return this.getUserData;
},
+ isLoggedIn() {
+ return Boolean(gon.current_user_id);
+ },
autosaveKey() {
return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
},
@@ -314,7 +317,7 @@ export default {
@cancelForm="cancelReplyForm"
/>
</div>
- <note-signed-out-widget v-if="!userCanReply" />
+ <note-signed-out-widget v-if="!isLoggedIn" />
</div>
</template>
</discussion-notes>
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index 08545dcea46..ab87b0d973c 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -11,7 +11,9 @@ export default function notificationsDropdown() {
}
const notificationLevel = $(this).data('notificationLevel');
- const form = $(this).parents('.notification-form:first');
+ const form = $(this)
+ .parents('.notification-form')
+ .first();
form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
if (form.hasClass('no-label')) {
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 45f033f2822..dcd226795a6 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -31,7 +31,7 @@ export default class NotificationsForm {
}
saveEvent($checkbox, $parent) {
- const form = $parent.parents('form:first');
+ const form = $parent.parents('form').first();
this.showCheckboxLoadingSpinner($parent);
diff --git a/app/assets/javascripts/pages/admin/admin.js b/app/assets/javascripts/pages/admin/admin.js
index 4616a075729..88967d82b2f 100644
--- a/app/assets/javascripts/pages/admin/admin.js
+++ b/app/assets/javascripts/pages/admin/admin.js
@@ -36,6 +36,8 @@ export default function adminInit() {
$('.log-bottom').on('click', e => {
e.preventDefault();
const $visibleLog = $('.file-content:visible');
+
+ // eslint-disable-next-line no-jquery/no-animate
$visibleLog.animate(
{
scrollTop: $visibleLog.find('ol').height(),
diff --git a/app/assets/javascripts/pages/admin/application_settings/index.js b/app/assets/javascripts/pages/admin/application_settings/index.js
index 47bd70537f1..089dedd14cb 100644
--- a/app/assets/javascripts/pages/admin/application_settings/index.js
+++ b/app/assets/javascripts/pages/admin/application_settings/index.js
@@ -1,7 +1,11 @@
import initSettingsPanels from '~/settings_panels';
import projectSelect from '~/project_select';
+import selfMonitor from '~/self_monitor';
document.addEventListener('DOMContentLoaded', () => {
+ if (gon.features && gon.features.selfMonitoringProject) {
+ selfMonitor();
+ }
// Initialize expandable settings panels
initSettingsPanels();
projectSelect();
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index 7a6a486f551..7c2008d9edc 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -6,21 +6,31 @@ import { __ } from '~/locale';
import { textColorForBackground } from '~/lib/utils/color_utils';
export default () => {
- const $broadcastMessageColor = $('input#broadcast_message_color');
- const $broadcastMessagePreview = $('div.broadcast-message-preview');
+ const $broadcastMessageColor = $('.js-broadcast-message-color');
+ const $broadcastMessageType = $('.js-broadcast-message-type');
+ const $broadcastBannerMessagePreview = $('.js-broadcast-banner-message-preview');
+ const $broadcastMessage = $('.js-broadcast-message-message');
+ const previewPath = $broadcastMessage.data('previewPath');
+ const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview');
+
$broadcastMessageColor.on('input', function onMessageColorInput() {
const previewColor = $(this).val();
- $broadcastMessagePreview.css('background-color', previewColor);
+ $broadcastBannerMessagePreview.css('background-color', previewColor);
});
$('input#broadcast_message_font').on('input', function onMessageFontInput() {
const previewColor = $(this).val();
- $broadcastMessagePreview.css('color', previewColor);
+ $broadcastBannerMessagePreview.css('color', previewColor);
});
- const $broadcastMessage = $('textarea#broadcast_message_message');
- const previewPath = $broadcastMessage.data('previewPath');
- const $jsBroadcastMessagePreview = $('.js-broadcast-message-preview');
+ $broadcastMessageType.on('change', () => {
+ const $broadcastMessageColorFormGroup = $('.js-broadcast-message-background-color-form-group');
+ const $broadcastNotificationMessagePreview = $('.js-broadcast-notification-message-preview');
+
+ $broadcastMessageColorFormGroup.toggleClass('hidden');
+ $broadcastBannerMessagePreview.toggleClass('hidden');
+ $broadcastNotificationMessagePreview.toggleClass('hidden');
+ });
$broadcastMessage.on(
'input',
@@ -58,7 +68,7 @@ export default () => {
$('.label-color-preview').css(selectedColorStyle);
- return $broadcastMessagePreview.css(selectedColorStyle);
+ return $jsBroadcastMessagePreview.css(selectedColorStyle);
};
const setSuggestedColor = e => {
@@ -67,7 +77,10 @@ export default () => {
.val(color)
// Notify the form, that color has changed
.trigger('input');
- updateColorPreview();
+ // Only banner supports colors
+ if ($broadcastMessageType === 'banner') {
+ updateColorPreview();
+ }
return e.preventDefault();
};
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
index e77a7cf8e0a..0c732922e81 100644
--- a/app/assets/javascripts/pages/groups/group_members/index/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index/index.js
@@ -3,9 +3,12 @@
import Members from 'ee_else_ce/members';
import memberExpirationDate from '~/member_expiration_date';
import UsersSelect from '~/users_select';
+import groupsSelect from '~/groups_select';
document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate();
+ memberExpirationDate('.js-access-expiration-date-groups');
new Members();
+ groupsSelect();
new UsersSelect();
});
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 28a136a5fa5..75df80a0f6c 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -4,11 +4,13 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
+import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
export default function() {
initIssueableApp();
+ initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/pipelines/index/index.js b/app/assets/javascripts/pages/projects/pipelines/index/index.js
index fd72d2ddbe0..4b4a274794d 100644
--- a/app/assets/javascripts/pages/projects/pipelines/index/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/index/index.js
@@ -1,10 +1,18 @@
import Vue from 'vue';
+import { GlToast } from '@gitlab/ui';
+import { doesHashExistInUrl } from '~/lib/utils/url_utility';
+import {
+ parseBoolean,
+ historyReplaceState,
+ buildUrlWithCurrentLocation,
+} from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import PipelinesStore from '../../../../pipelines/stores/pipelines_store';
import pipelinesComponent from '../../../../pipelines/components/pipelines.vue';
import Translate from '../../../../vue_shared/translate';
-import { parseBoolean } from '../../../../lib/utils/common_utils';
Vue.use(Translate);
+Vue.use(GlToast);
document.addEventListener(
'DOMContentLoaded',
@@ -21,6 +29,11 @@ document.addEventListener(
},
created() {
this.dataset = document.querySelector(this.$options.el).dataset;
+
+ if (doesHashExistInUrl('delete_success')) {
+ this.$toast.show(__('The pipeline has been deleted'));
+ historyReplaceState(buildUrlWithCurrentLocation());
+ }
},
render(createElement) {
return createElement('pipelines-component', {
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 4802cc2ad25..6994f83bce0 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
@@ -82,6 +82,11 @@ export default {
required: false,
default: false,
},
+ pagesAccessControlForced: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
pagesHelpPath: {
type: String,
required: false,
@@ -99,6 +104,7 @@ export default {
visibilityLevel: visibilityOptions.PUBLIC,
issuesAccessLevel: 20,
repositoryAccessLevel: 20,
+ forkingAccessLevel: 20,
mergeRequestsAccessLevel: 20,
buildsAccessLevel: 20,
wikiAccessLevel: 20,
@@ -130,10 +136,22 @@ export default {
},
pagesFeatureAccessLevelOptions() {
- if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
- return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]);
+ const options = [featureAccessLevelMembers];
+
+ if (this.pagesAccessControlForced) {
+ if (this.visibilityLevel === visibilityOptions.INTERNAL) {
+ options.push(featureAccessLevelEveryone);
+ }
+ } else {
+ if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
+ options.push(featureAccessLevelEveryone);
+ }
+
+ if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
+ options.push([30, PAGE_FEATURE_ACCESS_LEVEL]);
+ }
}
- return this.featureAccessLevelOptions;
+ return options;
},
repositoryEnabled() {
@@ -284,6 +302,19 @@ export default {
/>
</project-setting-row>
<project-setting-row
+ :label="s__('ProjectSettings|Forks')"
+ :help-text="
+ s__('ProjectSettings|Allow users to make copies of your repository to a new project')
+ "
+ >
+ <project-feature-setting
+ v-model="forkingAccessLevel"
+ :options="featureAccessLevelOptions"
+ :disabled-input="!repositoryEnabled"
+ name="project[project_feature_attributes][forking_access_level]"
+ />
+ </project-setting-row>
+ <project-setting-row
:label="s__('ProjectSettings|Pipelines')"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
>
diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js
index d41199f6374..80b62859134 100644
--- a/app/assets/javascripts/pages/projects/wikis/wikis.js
+++ b/app/assets/javascripts/pages/projects/wikis/wikis.js
@@ -1,4 +1,4 @@
-import bp from '../../../breakpoints';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { s__, sprintf } from '~/locale';
export default class Wikis {
@@ -52,7 +52,7 @@ export default class Wikis {
static sidebarCanCollapse() {
const bootstrapBreakpoint = bp.getBreakpointSize();
- return bootstrapBreakpoint === 'xs';
+ return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
}
renderSidebar() {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 7ce32032ed3..24ae900b445 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -59,7 +59,8 @@ export default {
<div
v-if="currentRequest.details && metricDetails"
:id="`peek-view-${metric}`"
- class="view qa-performance-bar-detailed-metric"
+ class="view"
+ data-qa-selector="detailed_metric_content"
>
<button
:data-target="`#modal-peek-${metric}-details`"
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 d17c2f33adc..1df5562e1b6 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -107,7 +107,11 @@ export default {
</script>
<template>
<div id="js-peek" :class="env">
- <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar">
+ <div
+ v-if="currentRequest"
+ class="d-flex container-fluid container-limited"
+ data-qa-selector="performance_bar"
+ >
<div id="peek-view-host" class="view">
<span
v-if="hasHost"
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index 1610534ae0d..115b2ff08ac 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -45,13 +45,13 @@ export default {
};
</script>
<template>
- <div id="peek-request-selector">
+ <div id="peek-request-selector" data-qa-selector="request_dropdown">
<select v-model="currentRequestId">
<option
v-for="request in requests"
:key="request.id"
:value="request.id"
- class="qa-performance-bar-request"
+ data-qa-selector="request_dropdown_option"
>
{{ request.truncatedUrl }}
<span v-if="request.hasWarnings">(!)</span>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 429122c8083..4dc6e51d2fc 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -43,7 +43,7 @@ export default {
downstream: 'downstream',
data() {
return {
- triggeredTopIndex: 1,
+ downstreamMarginTop: null,
};
},
computed: {
@@ -77,26 +77,34 @@ export default {
expandedTriggered() {
return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded);
},
-
- /**
- * Calculates the margin top of the clicked downstream pipeline by
- * adding the height of each linked pipeline and the margin
- */
- marginTop() {
- return `${this.triggeredTopIndex * 52}px`;
- },
pipelineTypeUpstream() {
return this.type !== this.$options.downstream && this.expandedTriggeredBy;
},
pipelineTypeDownstream() {
return this.type !== this.$options.upstream && this.expandedTriggered;
},
+ pipelineProjectId() {
+ return this.pipeline.project.id;
+ },
},
methods: {
- handleClickedDownstream(pipeline, clickedIndex) {
- this.triggeredTopIndex = clickedIndex;
+ handleClickedDownstream(pipeline, clickedIndex, downstreamNode) {
+ /**
+ * Calculates the margin top of the clicked downstream pipeline by
+ * subtracting the clicked downstream pipelines offsetTop by it's parent's
+ * offsetTop and then subtracting either 15 (if child) or 30 (if not a child)
+ * due to the height of node and stage name margin bottom.
+ */
+ this.downstreamMarginTop = this.calculateMarginTop(
+ downstreamNode,
+ downstreamNode.classList.contains('child-pipeline') ? 15 : 30,
+ );
+
this.$emit('onClickTriggered', this.pipeline, pipeline);
},
+ calculateMarginTop(downstreamNode, pixelDiff) {
+ return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`;
+ },
hasOnlyOneJob(stage) {
return stage.groups.length === 1;
},
@@ -139,6 +147,7 @@ export default {
v-if="hasTriggeredBy"
:linked-pipelines="triggeredByPipelines"
:column-title="__('Upstream')"
+ :project-id="pipelineProjectId"
graph-position="left"
@linkedPipelineClick="
linkedPipeline => $emit('onClickTriggeredBy', pipeline, linkedPipeline)
@@ -174,6 +183,7 @@ export default {
v-if="hasTriggered"
:linked-pipelines="triggeredPipelines"
:column-title="__('Downstream')"
+ :project-id="pipelineProjectId"
graph-position="right"
@linkedPipelineClick="handleClickedDownstream"
/>
@@ -186,7 +196,7 @@ export default {
:is-loading="false"
:pipeline="expandedTriggered"
:is-linked-pipeline="true"
- :style="{ 'margin-top': marginTop }"
+ :style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
@onClickTriggered="
(parentPipeline, pipeline) => clickTriggeredPipeline(parentPipeline, pipeline)
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index 82335e71403..d929398b6dc 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -1,6 +1,7 @@
<script>
import { GlLoadingIcon, GlTooltipDirective, GlButton } from '@gitlab/ui';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
+import { __ } from '~/locale';
export default {
directives: {
@@ -16,6 +17,14 @@ export default {
type: Object,
required: true,
},
+ projectId: {
+ type: Number,
+ required: true,
+ },
+ columnTitle: {
+ type: String,
+ required: true,
+ },
},
computed: {
tooltipText() {
@@ -30,18 +39,45 @@ export default {
projectName() {
return this.pipeline.project.name;
},
+ parentPipeline() {
+ // Refactor string match when BE returns Upstream/Downstream indicators
+ return this.projectId === this.pipeline.project.id && this.columnTitle === __('Upstream');
+ },
+ childPipeline() {
+ // Refactor string match when BE returns Upstream/Downstream indicators
+ return this.projectId === this.pipeline.project.id && this.columnTitle === __('Downstream');
+ },
+ label() {
+ return this.parentPipeline ? __('Parent') : __('Child');
+ },
+ childTooltipText() {
+ return __('This pipeline was triggered by a parent pipeline');
+ },
+ parentTooltipText() {
+ return __('This pipeline triggered a child pipeline');
+ },
+ labelToolTipText() {
+ return this.label === __('Parent') ? this.parentTooltipText : this.childTooltipText;
+ },
},
methods: {
onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId);
- this.$emit('pipelineClicked');
+ this.$emit('pipelineClicked', this.$refs.linkedPipeline);
+ },
+ hideTooltips() {
+ this.$root.$emit('bv::hide::tooltip');
},
},
};
</script>
<template>
- <li class="linked-pipeline build">
+ <li
+ ref="linkedPipeline"
+ class="linked-pipeline build"
+ :class="{ 'child-pipeline': childPipeline }"
+ >
<gl-button
:id="buttonId"
v-gl-tooltip
@@ -59,6 +95,15 @@ export default {
class="js-linked-pipeline-status"
/>
<span class="str-truncated align-bottom"> {{ projectName }} &#8226; #{{ pipeline.id }} </span>
+ <div v-if="parentPipeline || childPipeline" class="parent-child-label-container">
+ <span
+ v-gl-tooltip.bottom
+ :title="labelToolTipText"
+ class="badge badge-primary"
+ @mouseover="hideTooltips"
+ >{{ label }}</span
+ >
+ </div>
</gl-button>
</li>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index 998519f9df1..e3429184c05 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -19,6 +19,10 @@ export default {
type: String,
required: true,
},
+ projectId: {
+ type: Number,
+ required: true,
+ },
},
computed: {
columnClass() {
@@ -28,10 +32,16 @@ export default {
};
return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`;
},
+ // Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() {
return this.columnTitle === __('Upstream');
},
},
+ methods: {
+ onPipelineClick(downstreamNode, pipeline, index) {
+ this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
+ },
+ },
};
</script>
@@ -48,7 +58,9 @@ export default {
'left-connector': pipeline.isExpanded && graphPosition === 'left',
}"
:pipeline="pipeline"
- @pipelineClicked="$emit('linkedPipelineClick', pipeline, index)"
+ :column-title="columnTitle"
+ :project-id="projectId"
+ @pipelineClicked="onPipelineClick($event, pipeline, index)"
/>
</ul>
</div>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 39afa87afc3..726bba7f9f4 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -1,14 +1,17 @@
<script>
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
import { __ } from '~/locale';
+const DELETE_MODAL_ID = 'pipeline-delete-modal';
+
export default {
name: 'PipelineHeaderSection',
components: {
ciHeader,
GlLoadingIcon,
+ GlModal,
},
props: {
pipeline: {
@@ -33,6 +36,11 @@ export default {
shouldRenderContent() {
return !this.isLoading && Object.keys(this.pipeline).length;
},
+ deleteModalConfirmationText() {
+ return __(
+ 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.',
+ );
+ },
},
watch: {
@@ -42,6 +50,13 @@ export default {
},
methods: {
+ onActionClicked(action) {
+ if (action.modal) {
+ this.$root.$emit('bv::show::modal', action.modal);
+ } else {
+ this.postAction(action);
+ }
+ },
postAction(action) {
const index = this.actions.indexOf(action);
@@ -49,6 +64,13 @@ export default {
eventHub.$emit('headerPostAction', action);
},
+ deletePipeline() {
+ const index = this.actions.findIndex(action => action.modal === DELETE_MODAL_ID);
+
+ this.$set(this.actions[index], 'isLoading', true);
+
+ eventHub.$emit('headerDeleteAction', this.actions[index]);
+ },
getActions() {
const actions = [];
@@ -58,7 +80,6 @@ export default {
label: __('Retry'),
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
- type: 'button',
isLoading: false,
});
}
@@ -68,7 +89,16 @@ export default {
label: __('Cancel running'),
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
- type: 'button',
+ isLoading: false,
+ });
+ }
+
+ if (this.pipeline.delete_path) {
+ actions.push({
+ label: __('Delete'),
+ path: this.pipeline.delete_path,
+ modal: DELETE_MODAL_ID,
+ cssClass: 'js-btn-delete-pipeline btn btn-danger btn-inverted',
isLoading: false,
});
}
@@ -76,6 +106,7 @@ export default {
return actions;
},
},
+ DELETE_MODAL_ID,
};
</script>
<template>
@@ -88,8 +119,21 @@ export default {
:user="pipeline.user"
:actions="actions"
item-name="Pipeline"
- @actionClicked="postAction"
+ @actionClicked="onActionClicked"
/>
+
<gl-loading-icon v-if="isLoading" :size="2" class="prepend-top-default append-bottom-default" />
+
+ <gl-modal
+ :modal-id="$options.DELETE_MODAL_ID"
+ :title="__('Delete pipeline')"
+ :ok-title="__('Delete pipeline')"
+ ok-variant="danger"
+ @ok="deletePipeline()"
+ >
+ <p>
+ {{ deleteModalConfirmationText }}
+ </p>
+ </gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue
index c6990683ec7..5e4147f8805 100644
--- a/app/assets/javascripts/pipelines/components/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -1,12 +1,11 @@
<script>
-import { GlLink, GlButton } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
export default {
name: 'PipelineNavControls',
components: {
LoadingButton,
- GlLink,
GlButton,
},
props: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
index 7c4e651373f..6ca96bbba5e 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -2,7 +2,6 @@
import _ from 'underscore';
import { GlLink } from '@gitlab/ui';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { s__, sprintf } from '~/locale';
@@ -15,7 +14,6 @@ export default {
components: {
GlModal: DeprecatedModal2,
GlLink,
- ClipboardButton,
CiIcon,
},
props: {
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 30c830d78f9..743c3ea271d 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -2,7 +2,6 @@
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import _ from 'underscore';
import { __, sprintf } from '~/locale';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import popover from '~/vue_shared/directives/popover';
const popoverTitle = sprintf(
@@ -17,7 +16,6 @@ const popoverTitle = sprintf(
export default {
components: {
- UserAvatarLink,
GlLink,
},
directives: {
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
index 28b2c706320..65c1f125b55 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue
@@ -3,11 +3,13 @@ import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import store from '~/pipelines/stores/test_reports';
import { __ } from '~/locale';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
name: 'TestsSuiteTable',
components: {
Icon,
+ SmartVirtualList,
},
store,
props: {
@@ -23,6 +25,8 @@ export default {
return this.getSuiteTests.length > 0;
},
},
+ maxShownRows: 30,
+ typicalRowHeight: 75,
};
</script>
@@ -34,7 +38,7 @@ export default {
</div>
</div>
- <div v-if="hasSuites" class="test-reports-table js-test-cases-table">
+ <div v-if="hasSuites" class="test-reports-table append-bottom-default 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') }}
@@ -53,52 +57,58 @@ export default {
</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"
+ <smart-virtual-list
+ :length="getSuiteTests.length"
+ :remain="$options.maxShownRows"
+ :size="$options.typicalRowHeight"
>
- <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
+ v-for="(testCase, index) in getSuiteTests"
+ :key="index"
+ class="gl-responsive-table-row rounded align-items-md-start mt-xs-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 text-truncate">{{ 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-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 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>
- <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 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>
- <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 class="table-section section-10 section-wrap">
+ <div role="rowheader" class="table-mobile-header">
+ {{ __('Duration') }}
+ </div>
+ <div class="table-mobile-content text-right pr-sm-1">
+ {{ testCase.formattedTime }}
+ </div>
</div>
</div>
- </div>
+ </smart-virtual-list>
</div>
<div v-else>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 1bac7ce9ac5..2fa3fa41eed 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -1,5 +1,5 @@
<script>
-import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui';
+import { GlButton, GlProgressBar } from '@gitlab/ui';
import { __ } from '~/locale';
import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
@@ -8,7 +8,6 @@ export default {
name: 'TestSummary',
components: {
GlButton,
- GlLink,
GlProgressBar,
Icon,
},
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
index 96177512e35..6effd6e949d 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue
@@ -2,9 +2,13 @@
import { mapGetters } from 'vuex';
import { s__ } from '~/locale';
import store from '~/pipelines/stores/test_reports';
+import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue';
export default {
name: 'TestsSummaryTable',
+ components: {
+ SmartVirtualList,
+ },
store,
props: {
heading: {
@@ -24,6 +28,8 @@ export default {
this.$emit('row-click', suite);
},
},
+ maxShownRows: 20,
+ typicalRowHeight: 55,
};
</script>
@@ -35,7 +41,7 @@ export default {
</div>
</div>
- <div v-if="hasSuites" class="test-reports-table js-test-suites-table">
+ <div v-if="hasSuites" class="test-reports-table append-bottom-default 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') }}
@@ -60,66 +66,72 @@ export default {
</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)"
+ <smart-virtual-list
+ :length="getTestSuites.length"
+ :remain="$options.maxShownRows"
+ :size="$options.typicalRowHeight"
>
- <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
+ 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>
- <div class="table-section section-25">
- <div role="rowheader" class="table-mobile-header font-weight-bold">
- {{ __('Duration') }}
+ <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-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 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-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 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-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 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-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 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-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 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 class="table-mobile-content">{{ testSuite.total_count }}</div>
</div>
- </div>
+ </smart-virtual-list>
</div>
<div v-else>
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index d8dbc3c2454..c874c4c6fdd 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
+import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
@@ -62,9 +63,11 @@ export default () => {
},
created() {
eventHub.$on('headerPostAction', this.postAction);
+ eventHub.$on('headerDeleteAction', this.deleteAction);
},
beforeDestroy() {
eventHub.$off('headerPostAction', this.postAction);
+ eventHub.$off('headerDeleteAction', this.deleteAction);
},
methods: {
postAction(action) {
@@ -73,6 +76,13 @@ export default () => {
.then(() => this.mediator.refreshPipeline())
.catch(() => Flash(__('An error occurred while making the request.')));
},
+ deleteAction(action) {
+ this.mediator.stopPipelinePoll();
+ this.mediator.service
+ .deleteAction(action.path)
+ .then(({ request }) => redirectTo(setUrlFragment(request.responseURL, 'delete_success')))
+ .catch(() => Flash(__('An error occurred while deleting the pipeline.')));
+ },
},
render(createElement) {
return createElement('pipeline-header', {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index bf021a0b447..f3387f00fc1 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -35,7 +35,7 @@ export default class pipelinesMediator {
if (!Visibility.hidden()) {
this.poll.restart();
} else {
- this.poll.stop();
+ this.stopPipelinePoll();
}
});
}
@@ -51,7 +51,7 @@ export default class pipelinesMediator {
}
refreshPipeline() {
- this.poll.stop();
+ this.stopPipelinePoll();
return this.service
.getPipeline()
@@ -64,6 +64,10 @@ export default class pipelinesMediator {
);
}
+ stopPipelinePoll() {
+ this.poll.stop();
+ }
+
/**
* Backend expects paramets in the following format: `expanded[]=id&expanded[]=id`
*/
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index e44eb9cdfd1..ba2830ec596 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -10,6 +10,11 @@ export default class PipelineService {
}
// eslint-disable-next-line class-methods-use-this
+ deleteAction(endpoint) {
+ return axios.delete(`${endpoint}.json`);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
postAction(endpoint) {
return axios.post(`${endpoint}.json`);
}
diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js
index d6cdd37a2c3..a31034361a8 100644
--- a/app/assets/javascripts/project_find_file.js
+++ b/app/assets/javascripts/project_find_file.js
@@ -55,26 +55,21 @@ export default class ProjectFindFile {
initEvent() {
this.inputElement.off('keyup');
- this.inputElement.on(
- 'keyup',
- (function(_this) {
- return function(event) {
- 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();
- return _this.element
- .find('tr.tree-item')
- .eq(0)
- .addClass('selected')
- .focus();
- }
- };
- })(this),
- );
+ this.inputElement.on('keyup', event => {
+ 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();
+ return this.element
+ .find('tr.tree-item')
+ .eq(0)
+ .addClass('selected')
+ .focus();
+ }
+ });
}
findFile() {
diff --git a/app/assets/javascripts/projects/project_import_gitlab_project.js b/app/assets/javascripts/projects/project_import_gitlab_project.js
index fbef3a0b059..4f222438500 100644
--- a/app/assets/javascripts/projects/project_import_gitlab_project.js
+++ b/app/assets/javascripts/projects/project_import_gitlab_project.js
@@ -1,19 +1,45 @@
import $ from 'jquery';
+import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility';
import { getParameterValues } from '../lib/utils/url_utility';
import projectNew from './project_new';
+const prepareParameters = () => {
+ const name = getParameterValues('name')[0];
+ const path = getParameterValues('path')[0];
+
+ // If the name param exists but the path doesn't then generate it from the name
+ if (name && !path) {
+ return { name, path: slugify(name) };
+ }
+
+ // If the path param exists but the name doesn't then generate it from the path
+ if (path && !name) {
+ return { name: convertToTitleCase(humanize(path, '-')), path };
+ }
+
+ return { name, path };
+};
+
export default () => {
- const pathParam = getParameterValues('path')[0];
- const nameParam = getParameterValues('name')[0];
- const $projectPath = $('.js-path-name');
+ let hasUserDefinedProjectName = false;
const $projectName = $('.js-project-name');
-
- // get the path url and append it in the input
- $projectPath.val(pathParam);
+ const $projectPath = $('.js-path-name');
+ const { name, path } = prepareParameters();
// get the project name from the URL and set it as input value
- $projectName.val(nameParam);
+ $projectName.val(name);
+
+ // get the path url and append it in the input
+ $projectPath.val(path);
// generate slug when project name changes
- $projectName.keyup(() => projectNew.onProjectNameChange($projectName, $projectPath));
+ $projectName.on('keyup', () => {
+ projectNew.onProjectNameChange($projectName, $projectPath);
+ hasUserDefinedProjectName = $projectName.val().trim().length > 0;
+ });
+
+ // generate project name from the slug if one isn't set
+ $projectPath.on('keyup', () =>
+ projectNew.onProjectPathChange($projectName, $projectPath, hasUserDefinedProjectName),
+ );
};
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 92c4c05bd87..2aa5f6ec626 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,14 +1,45 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
-import { slugify } from '../lib/utils/text_utility';
+import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility';
import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
+let hasUserDefinedProjectName = false;
+
+const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
+ const slug = slugify($projectNameInput.val());
+ $projectPathInput.val(slug);
+};
+
+const onProjectPathChange = ($projectNameInput, $projectPathInput, hasExistingProjectName) => {
+ const slug = $projectPathInput.val();
+
+ if (!hasExistingProjectName) {
+ $projectNameInput.val(convertToTitleCase(humanize(slug, '[-_]')));
+ }
+};
+
+const setProjectNamePathHandlers = ($projectNameInput, $projectPathInput) => {
+ $projectNameInput.off('keyup change').on('keyup change', () => {
+ onProjectNameChange($projectNameInput, $projectPathInput);
+ hasUserDefinedProjectName = $projectNameInput.val().trim().length > 0;
+ hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
+ });
+
+ $projectPathInput.off('keyup change').on('keyup change', () => {
+ onProjectPathChange($projectNameInput, $projectPathInput, hasUserDefinedProjectName);
+ hasUserDefinedProjectPath = $projectPathInput.val().trim().length > 0;
+ });
+};
const deriveProjectPathFromUrl = $projectImportUrl => {
+ const $currentProjectName = $projectImportUrl
+ .parents('.toggle-import-form')
+ .find('#project_name');
const $currentProjectPath = $projectImportUrl
.parents('.toggle-import-form')
.find('#project_path');
+
if (hasUserDefinedProjectPath) {
return;
}
@@ -30,14 +61,10 @@ const deriveProjectPathFromUrl = $projectImportUrl => {
const pathMatch = /\/([^/]+)$/.exec(importUrl);
if (pathMatch) {
$currentProjectPath.val(pathMatch[1]);
+ onProjectPathChange($currentProjectName, $currentProjectPath, false);
}
};
-const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
- const slug = slugify($projectNameInput.val());
- $projectPathInput.val(slug);
-};
-
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
@@ -202,10 +229,7 @@ const bindEvents = () => {
const $activeTabProjectName = $('.tab-pane.active #project_name');
const $activeTabProjectPath = $('.tab-pane.active #project_path');
$activeTabProjectName.focus();
- $activeTabProjectName.keyup(() => {
- onProjectNameChange($activeTabProjectName, $activeTabProjectPath);
- hasUserDefinedProjectPath = $activeTabProjectPath.val().trim().length > 0;
- });
+ setProjectNamePathHandlers($activeTabProjectName, $activeTabProjectPath);
}
$useTemplateBtn.on('change', chooseTemplate);
@@ -220,26 +244,24 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
- $projectPath.on('keyup', () => {
- hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
- });
-
$projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
$('.js-import-git-toggle-button').on('click', () => {
const $projectMirror = $('#project_mirror');
$projectMirror.attr('disabled', !$projectMirror.attr('disabled'));
+ setProjectNamePathHandlers(
+ $('.tab-pane.active #project_name'),
+ $('.tab-pane.active #project_path'),
+ );
});
- $projectName.on('keyup change', () => {
- onProjectNameChange($projectName, $projectPath);
- hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
- });
+ setProjectNamePathHandlers($projectName, $projectPath);
};
export default {
bindEvents,
deriveProjectPathFromUrl,
onProjectNameChange,
+ onProjectPathChange,
};
diff --git a/app/assets/javascripts/registry/list/components/collapsible_container.vue b/app/assets/javascripts/registry/list/components/collapsible_container.vue
index 86bb2d8092e..9786a1a3f75 100644
--- a/app/assets/javascripts/registry/list/components/collapsible_container.vue
+++ b/app/assets/javascripts/registry/list/components/collapsible_container.vue
@@ -14,7 +14,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TableRegistry from './table_registry.vue';
import { DELETE_REPO_ERROR_MESSAGE } from '../constants';
-import { __ } from '~/locale';
+import { __, sprintf } from '~/locale';
export default {
name: 'CollapsibeContainerRegisty',
@@ -55,6 +55,11 @@ export default {
canDeleteRepo() {
return this.repo.canDelete && !this.isDeleteDisabled;
},
+ deleteImageConfirmationMessage() {
+ return sprintf(__('Image %{imageName} was scheduled for deletion from the registry.'), {
+ imageName: this.repo.name,
+ });
+ },
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
@@ -69,7 +74,7 @@ export default {
this.track('confirm_delete');
return this.deleteItem(this.repo)
.then(() => {
- createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
+ createFlash(this.deleteImageConfirmationMessage, 'notice');
this.fetchRepos();
})
.catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE));
diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
index b2c700b817c..ca495cd2eca 100644
--- a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
+++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue
@@ -1,26 +1,23 @@
<script>
-import { mapState } from 'vuex';
-import { s__, sprintf } from '~/locale';
+import { mapState, mapActions } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import SettingsForm from './settings_form.vue';
export default {
- components: {},
+ components: {
+ GlLoadingIcon,
+ SettingsForm,
+ },
computed: {
...mapState({
- helpPagePath: 'helpPagePath',
+ isLoading: 'isLoading',
}),
-
- helpText() {
- return sprintf(
- s__(
- 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}',
- ),
- {
- helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
- helpLinkEnd: '</a>',
- },
- false,
- );
- },
+ },
+ mounted() {
+ this.fetchSettings();
+ },
+ methods: {
+ ...mapActions(['fetchSettings']),
},
};
</script>
@@ -28,16 +25,19 @@ export default {
<template>
<div>
<p>
- {{ s__('PackageRegistry|Tag retention policies are designed to:') }}
+ {{ s__('ContainerRegistry|Tag expiration policy is designed to:') }}
</p>
<ul>
- <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li>
+ <li>{{ s__('ContainerRegistry|Keep and protect the images that matter most.') }}</li>
<li>
{{
- s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.")
+ s__(
+ "ContainerRegistry|Automatically remove extra images that aren't designed to be kept.",
+ )
}}
</li>
</ul>
- <p ref="help-link" v-html="helpText"></p>
+ <gl-loading-icon v-if="isLoading" ref="loading-icon" size="xl" />
+ <settings-form v-else ref="settings-form" />
</div>
</template>
diff --git a/app/assets/javascripts/registry/settings/components/settings_form.vue b/app/assets/javascripts/registry/settings/components/settings_form.vue
new file mode 100644
index 00000000000..457bf35daab
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/components/settings_form.vue
@@ -0,0 +1,178 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlFormGroup, GlToggle, GlFormSelect, GlFormTextarea, GlButton, GlCard } from '@gitlab/ui';
+import { s__, __, sprintf } from '~/locale';
+import { NAME_REGEX_LENGTH } from '../constants';
+import { mapComputed } from '~/vuex_shared/bindings';
+
+export default {
+ components: {
+ GlFormGroup,
+ GlToggle,
+ GlFormSelect,
+ GlFormTextarea,
+ GlButton,
+ GlCard,
+ },
+ labelsConfig: {
+ cols: 3,
+ align: 'right',
+ },
+ computed: {
+ ...mapState(['formOptions']),
+ ...mapComputed(
+ [
+ 'enabled',
+ { key: 'cadence', getter: 'getCadence' },
+ { key: 'older_than', getter: 'getOlderThan' },
+ { key: 'keep_n', getter: 'getKeepN' },
+ 'name_regex',
+ ],
+ 'updateSettings',
+ 'settings',
+ ),
+ policyEnabledText() {
+ return this.enabled ? __('enabled') : __('disabled');
+ },
+ toggleDescriptionText() {
+ return sprintf(
+ s__('ContainerRegistry|Docker tag expiration policy is %{toggleStatus}'),
+ {
+ toggleStatus: `<strong>${this.policyEnabledText}</strong>`,
+ },
+ false,
+ );
+ },
+ regexHelpText() {
+ return sprintf(
+ s__(
+ 'ContainerRegistry|Wildcards such as %{codeStart}*-stable%{codeEnd} or %{codeStart}production/*%{codeEnd} are supported. To select all tags, use %{codeStart}.*%{codeEnd}',
+ ),
+ {
+ codeStart: '<code>',
+ codeEnd: '</code>',
+ },
+ false,
+ );
+ },
+ nameRegexPlaceholder() {
+ return '.*';
+ },
+ nameRegexState() {
+ return this.name_regex ? this.name_regex.length <= NAME_REGEX_LENGTH : null;
+ },
+ formIsInvalid() {
+ return this.nameRegexState === false;
+ },
+ },
+ methods: {
+ ...mapActions(['resetSettings', 'saveSettings']),
+ },
+};
+</script>
+
+<template>
+ <form ref="form-element" @submit.prevent="saveSettings" @reset.prevent="resetSettings">
+ <gl-card>
+ <template #header>
+ {{ s__('ContainerRegistry|Tag expiration policy') }}
+ </template>
+ <template>
+ <gl-form-group
+ id="expiration-policy-toggle-group"
+ :label-cols="$options.labelsConfig.cols"
+ :label-align="$options.labelsConfig.align"
+ label-for="expiration-policy-toggle"
+ :label="s__('ContainerRegistry|Expiration policy:')"
+ >
+ <div class="d-flex align-items-start">
+ <gl-toggle id="expiration-policy-toggle" v-model="enabled" />
+ <span class="mb-2 ml-1 lh-2" v-html="toggleDescriptionText"></span>
+ </div>
+ </gl-form-group>
+
+ <gl-form-group
+ id="expiration-policy-interval-group"
+ :label-cols="$options.labelsConfig.cols"
+ :label-align="$options.labelsConfig.align"
+ label-for="expiration-policy-interval"
+ :label="s__('ContainerRegistry|Expiration interval:')"
+ >
+ <gl-form-select id="expiration-policy-interval" v-model="older_than" :disabled="!enabled">
+ <option v-for="option in formOptions.olderThan" :key="option.key" :value="option.key">
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ id="expiration-policy-schedule-group"
+ :label-cols="$options.labelsConfig.cols"
+ :label-align="$options.labelsConfig.align"
+ label-for="expiration-policy-schedule"
+ :label="s__('ContainerRegistry|Expiration schedule:')"
+ >
+ <gl-form-select id="expiration-policy-schedule" v-model="cadence" :disabled="!enabled">
+ <option v-for="option in formOptions.cadence" :key="option.key" :value="option.key">
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ id="expiration-policy-latest-group"
+ :label-cols="$options.labelsConfig.cols"
+ :label-align="$options.labelsConfig.align"
+ label-for="expiration-policy-latest"
+ :label="s__('ContainerRegistry|Number of tags to retain:')"
+ >
+ <gl-form-select id="expiration-policy-latest" v-model="keep_n" :disabled="!enabled">
+ <option v-for="option in formOptions.keepN" :key="option.key" :value="option.key">
+ {{ option.label }}
+ </option>
+ </gl-form-select>
+ </gl-form-group>
+
+ <gl-form-group
+ id="expiration-policy-name-matching-group"
+ :label-cols="$options.labelsConfig.cols"
+ :label-align="$options.labelsConfig.align"
+ label-for="expiration-policy-name-matching"
+ :label="s__('ContainerRegistry|Expire Docker tags that match this regex:')"
+ :state="nameRegexState"
+ :invalid-feedback="
+ s__('ContainerRegistry|The value of this input should be less than 255 characters')
+ "
+ >
+ <gl-form-textarea
+ id="expiration-policy-name-matching"
+ v-model="name_regex"
+ :placeholder="nameRegexPlaceholder"
+ :state="nameRegexState"
+ :disabled="!enabled"
+ trim
+ />
+ <template #description>
+ <span ref="regex-description" v-html="regexHelpText"></span>
+ </template>
+ </gl-form-group>
+ </template>
+ <template #footer>
+ <div class="d-flex justify-content-end">
+ <gl-button ref="cancel-button" type="reset" class="mr-2 d-block">{{
+ __('Cancel')
+ }}</gl-button>
+ <gl-button
+ ref="save-button"
+ type="submit"
+ :disabled="formIsInvalid"
+ variant="success"
+ class="d-block"
+ >
+ {{ __('Save expiration policy') }}
+ </gl-button>
+ </div>
+ </template>
+ </gl-card>
+ </form>
+</template>
diff --git a/app/assets/javascripts/registry/settings/constants.js b/app/assets/javascripts/registry/settings/constants.js
new file mode 100644
index 00000000000..c0dac466b29
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/constants.js
@@ -0,0 +1,15 @@
+import { s__ } from '~/locale';
+
+export const FETCH_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the expiration policy.',
+);
+
+export const UPDATE_SETTINGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while updating the expiration policy.',
+);
+
+export const UPDATE_SETTINGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Expiration policy successfully saved.',
+);
+
+export const NAME_REGEX_LENGTH = 255;
diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
index 2938178ea86..927b6059884 100644
--- a/app/assets/javascripts/registry/settings/registry_settings_bundle.js
+++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
-import store from './stores/';
+import store from './store/';
import RegistrySettingsApp from './components/registry_settings_app.vue';
Vue.use(Translate);
diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js
new file mode 100644
index 00000000000..5e46d564121
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/store/actions.js
@@ -0,0 +1,42 @@
+import Api from '~/api';
+import createFlash from '~/flash';
+import {
+ FETCH_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_ERROR_MESSAGE,
+ UPDATE_SETTINGS_SUCCESS_MESSAGE,
+} from '../constants';
+import * as types from './mutation_types';
+
+export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
+export const updateSettings = ({ commit }, data) => commit(types.UPDATE_SETTINGS, data);
+export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING);
+export const receiveSettingsSuccess = ({ commit }, data = {}) => commit(types.SET_SETTINGS, data);
+export const receiveSettingsError = () => createFlash(FETCH_SETTINGS_ERROR_MESSAGE);
+export const updateSettingsError = () => createFlash(UPDATE_SETTINGS_ERROR_MESSAGE);
+export const resetSettings = ({ commit }) => commit(types.RESET_SETTINGS);
+
+export const fetchSettings = ({ dispatch, state }) => {
+ dispatch('toggleLoading');
+ return Api.project(state.projectId)
+ .then(({ data: { container_expiration_policy } }) =>
+ dispatch('receiveSettingsSuccess', container_expiration_policy),
+ )
+ .catch(() => dispatch('receiveSettingsError'))
+ .finally(() => dispatch('toggleLoading'));
+};
+
+export const saveSettings = ({ dispatch, state }) => {
+ dispatch('toggleLoading');
+ return Api.updateProject(state.projectId, {
+ container_expiration_policy_attributes: state.settings,
+ })
+ .then(({ data: { container_expiration_policy } }) => {
+ dispatch('receiveSettingsSuccess', container_expiration_policy);
+ createFlash(UPDATE_SETTINGS_SUCCESS_MESSAGE, 'success');
+ })
+ .catch(() => dispatch('updateSettingsError'))
+ .finally(() => dispatch('toggleLoading'));
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/registry/settings/store/getters.js b/app/assets/javascripts/registry/settings/store/getters.js
new file mode 100644
index 00000000000..fc32a9f08e4
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/store/getters.js
@@ -0,0 +1,8 @@
+import { findDefaultOption } from '../utils';
+
+export const getCadence = state =>
+ state.settings.cadence || findDefaultOption(state.formOptions.cadence);
+export const getKeepN = state =>
+ state.settings.keep_n || findDefaultOption(state.formOptions.keepN);
+export const getOlderThan = state =>
+ state.settings.older_than || findDefaultOption(state.formOptions.olderThan);
diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/store/index.js
index 91a35aac149..c2500454d8e 100644
--- a/app/assets/javascripts/registry/settings/stores/index.js
+++ b/app/assets/javascripts/registry/settings/store/index.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
+import * as getters from './getters';
import state from './state';
Vue.use(Vuex);
@@ -11,6 +12,7 @@ export const createStore = () =>
state,
actions,
mutations,
+ getters,
});
export default createStore();
diff --git a/app/assets/javascripts/registry/settings/store/mutation_types.js b/app/assets/javascripts/registry/settings/store/mutation_types.js
new file mode 100644
index 00000000000..db499ffa761
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/store/mutation_types.js
@@ -0,0 +1,5 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const UPDATE_SETTINGS = 'UPDATE_SETTINGS';
+export const TOGGLE_LOADING = 'TOGGLE_LOADING';
+export const SET_SETTINGS = 'SET_SETTINGS';
+export const RESET_SETTINGS = 'RESET_SETTINGS';
diff --git a/app/assets/javascripts/registry/settings/store/mutations.js b/app/assets/javascripts/registry/settings/store/mutations.js
new file mode 100644
index 00000000000..25a67cc6973
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/store/mutations.js
@@ -0,0 +1,25 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, initialState) {
+ state.projectId = initialState.projectId;
+ state.formOptions = {
+ cadence: JSON.parse(initialState.cadenceOptions),
+ keepN: JSON.parse(initialState.keepNOptions),
+ olderThan: JSON.parse(initialState.olderThanOptions),
+ };
+ },
+ [types.UPDATE_SETTINGS](state, settings) {
+ state.settings = { ...state.settings, ...settings };
+ },
+ [types.SET_SETTINGS](state, settings) {
+ state.settings = settings;
+ state.original = Object.freeze(settings);
+ },
+ [types.RESET_SETTINGS](state) {
+ state.settings = { ...state.original };
+ },
+ [types.TOGGLE_LOADING](state) {
+ state.isLoading = !state.isLoading;
+ },
+};
diff --git a/app/assets/javascripts/registry/settings/store/state.js b/app/assets/javascripts/registry/settings/store/state.js
new file mode 100644
index 00000000000..50c882e1839
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/store/state.js
@@ -0,0 +1,30 @@
+export default () => ({
+ /*
+ * Project Id used to build the API call
+ */
+ projectId: '',
+ /*
+ * Boolean to determine if the UI is loading data from the API
+ */
+ isLoading: false,
+ /*
+ * This contains the data shown and manipulated in the UI
+ * Has the following structure:
+ * {
+ * enabled: Boolean
+ * cadence: String,
+ * older_than: String,
+ * keep_n: String,
+ * name_regex: String
+ * }
+ */
+ settings: {},
+ /*
+ * Same structure as settings, above but Frozen object and used only in case the user clicks 'cancel'
+ */
+ original: {},
+ /*
+ * Contains the options used to populate the form selects
+ */
+ formOptions: {},
+});
diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js
deleted file mode 100644
index f2c469d4edb..00000000000
--- a/app/assets/javascripts/registry/settings/stores/actions.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import * as types from './mutation_types';
-
-export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
-
-// to avoid eslint error until more actions are added to the store
-export default () => {};
diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js
deleted file mode 100644
index 8a0f519eabd..00000000000
--- a/app/assets/javascripts/registry/settings/stores/mutation_types.js
+++ /dev/null
@@ -1,4 +0,0 @@
-export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
-
-// to avoid eslint error until more actions are added to the store
-export default () => {};
diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js
deleted file mode 100644
index 4f32e11ed52..00000000000
--- a/app/assets/javascripts/registry/settings/stores/mutations.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import * as types from './mutation_types';
-
-export default {
- [types.SET_INITIAL_STATE](state, initialState) {
- state.helpPagePath = initialState.helpPagePath;
- state.registrySettingsEndpoint = initialState.registrySettingsEndpoint;
- },
-};
diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js
deleted file mode 100644
index 4c0439458b6..00000000000
--- a/app/assets/javascripts/registry/settings/stores/state.js
+++ /dev/null
@@ -1,10 +0,0 @@
-export default () => ({
- /*
- * Help page path to generate the link
- */
- helpPagePath: '',
- /*
- * Settings endpoint to call to fetch and update the settings
- */
- registrySettingsEndpoint: '',
-});
diff --git a/app/assets/javascripts/registry/settings/utils.js b/app/assets/javascripts/registry/settings/utils.js
new file mode 100644
index 00000000000..75af401e96d
--- /dev/null
+++ b/app/assets/javascripts/registry/settings/utils.js
@@ -0,0 +1,6 @@
+export const findDefaultOption = options => {
+ const item = options.find(o => o.default);
+ return item ? item.key : null;
+};
+
+export default () => {};
diff --git a/app/assets/javascripts/releases/list/components/app.vue b/app/assets/javascripts/releases/list/components/app.vue
index a414b3ccd4e..eb63e709ebd 100644
--- a/app/assets/javascripts/releases/list/components/app.vue
+++ b/app/assets/javascripts/releases/list/components/app.vue
@@ -66,7 +66,7 @@ export default {
:svg-path="illustrationPath"
:description="
__(
- 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.',
+ 'Releases are based on Git tags and mark specific points in a project\'s development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.',
)
"
:primary-button-link="documentationLink"
diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue
index 4d8d8682401..d924b5795f0 100644
--- a/app/assets/javascripts/releases/list/components/release_block.vue
+++ b/app/assets/javascripts/releases/list/components/release_block.vue
@@ -1,35 +1,27 @@
<script>
-/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
-import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
-import Icon from '~/vue_shared/components/icon.vue';
-import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { __, n__, sprintf } from '~/locale';
import { slugify } from '~/lib/utils/text_utility';
import { getLocationHash } from '~/lib/utils/url_utility';
import { scrollToElement } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import ReleaseBlockFooter from './release_block_footer.vue';
import EvidenceBlock from './evidence_block.vue';
+import ReleaseBlockAssets from './release_block_assets.vue';
+import ReleaseBlockFooter from './release_block_footer.vue';
+import ReleaseBlockHeader from './release_block_header.vue';
+import ReleaseBlockMetadata from './release_block_metadata.vue';
import ReleaseBlockMilestoneInfo from './release_block_milestone_info.vue';
export default {
name: 'ReleaseBlock',
components: {
EvidenceBlock,
- GlLink,
- GlBadge,
- GlButton,
- Icon,
- UserAvatarLink,
+ ReleaseBlockAssets,
ReleaseBlockFooter,
+ ReleaseBlockHeader,
+ ReleaseBlockMetadata,
ReleaseBlockMilestoneInfo,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- mixins: [timeagoMixin, glFeatureFlagsMixin()],
+ mixins: [glFeatureFlagsMixin()],
props: {
release: {
type: Object,
@@ -46,45 +38,14 @@ export default {
id() {
return slugify(this.release.tag_name);
},
- releasedTimeAgo() {
- return sprintf(__('released %{time}'), {
- time: this.timeFormatted(this.release.released_at),
- });
- },
- userImageAltDescription() {
- return this.author && this.author.username
- ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
- : null;
- },
- commit() {
- return this.release.commit || {};
- },
- commitUrl() {
- return this.release.commit_path;
- },
- tagUrl() {
- return this.release.tag_path;
- },
assets() {
return this.release.assets || {};
},
- author() {
- return this.release.author || {};
- },
- hasAuthor() {
- return !_.isEmpty(this.author);
- },
hasEvidence() {
return Boolean(this.release.evidence_sha);
},
- shouldRenderMilestones() {
- return !_.isEmpty(this.release.milestones);
- },
- labelText() {
- return n__('Milestone', 'Milestones', this.release.milestones.length);
- },
- shouldShowEditButton() {
- return Boolean(this.release._links && this.release._links.edit_url);
+ milestones() {
+ return this.release.milestones || [];
},
shouldShowEvidence() {
return this.glFeatures.releaseEvidenceCollection;
@@ -92,6 +53,11 @@ export default {
shouldShowFooter() {
return this.glFeatures.releaseIssueSummary;
},
+ shouldRenderAssets() {
+ return Boolean(
+ this.assets.links.length || (this.assets.sources && this.assets.sources.length),
+ );
+ },
shouldRenderReleaseMetaData() {
return !this.glFeatures.releaseIssueSummary;
},
@@ -114,127 +80,15 @@ export default {
</script>
<template>
<div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
- <div class="card-header d-flex align-items-center bg-white pr-0">
- <h2 class="card-title my-2 mr-auto gl-font-size-20">
- {{ release.name }}
- <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
- __('Upcoming Release')
- }}</gl-badge>
- </h2>
- <gl-link
- v-if="shouldShowEditButton"
- v-gl-tooltip
- class="btn btn-default append-right-10 js-edit-button ml-2"
- :title="__('Edit this release')"
- :href="release._links.edit_url"
- >
- <icon name="pencil" />
- </gl-link>
- </div>
+ <release-block-header :release="release" />
<div class="card-body">
<div v-if="shouldRenderMilestoneInfo">
- <release-block-milestone-info :milestones="release.milestones" />
+ <release-block-milestone-info :milestones="milestones" />
<hr class="mb-3 mt-0" />
</div>
- <div v-if="shouldRenderReleaseMetaData" class="card-subtitle d-flex flex-wrap text-secondary">
- <div class="append-right-8">
- <icon name="commit" class="align-middle" />
- <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
- {{ commit.short_id }}
- </gl-link>
- <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
- </div>
-
- <div class="append-right-8">
- <icon name="tag" class="align-middle" />
- <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
- {{ release.tag_name }}
- </gl-link>
- <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
- </div>
-
- <template v-if="shouldRenderMilestones">
- <div class="js-milestone-list-label">
- <icon name="flag" class="align-middle" />
- <span class="js-label-text">{{ labelText }}</span>
- </div>
-
- <template v-for="(milestone, index) in release.milestones">
- <gl-link
- :key="milestone.id"
- v-gl-tooltip
- :title="milestone.description"
- :href="milestone.web_url"
- class="append-right-4 prepend-left-4 js-milestone-link"
- >
- {{ milestone.title }}
- </gl-link>
- <template v-if="index !== release.milestones.length - 1">
- &bull;
- </template>
- </template>
- </template>
-
- <div class="append-right-4">
- &bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
- {{ releasedTimeAgo }}
- </span>
- </div>
-
- <div v-if="hasAuthor" class="d-flex">
- by
- <user-avatar-link
- class="prepend-left-4"
- :link-href="author.web_url"
- :img-src="author.avatar_url"
- :img-alt="userImageAltDescription"
- :tooltip-text="author.username"
- />
- </div>
- </div>
-
- <div
- v-if="assets.links.length || (assets.sources && assets.sources.length)"
- class="card-text prepend-top-default"
- >
- <b>
- {{ __('Assets') }}
- <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
- </b>
-
- <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
- <li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
- <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
- <icon name="package" class="align-middle append-right-4 align-text-bottom" />
- {{ link.name }}
- <span v-if="link.external">{{ __('(external source)') }}</span>
- </gl-link>
- </li>
- </ul>
-
- <div v-if="assets.sources && assets.sources.length" class="dropdown">
- <button
- type="button"
- class="btn btn-link"
- data-toggle="dropdown"
- aria-haspopup="true"
- aria-expanded="false"
- >
- <icon name="doc-code" class="align-top append-right-4" />
- {{ __('Source code') }}
- <icon name="arrow-down" />
- </button>
-
- <div class="js-sources-dropdown dropdown-menu">
- <li v-for="asset in assets.sources" :key="asset.url">
- <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
- </li>
- </div>
- </div>
- </div>
-
+ <release-block-metadata v-if="shouldRenderReleaseMetaData" :release="release" />
+ <release-block-assets v-if="shouldRenderAssets" :assets="assets" />
<evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
<div class="card-text prepend-top-default">
diff --git a/app/assets/javascripts/releases/list/components/release_block_assets.vue b/app/assets/javascripts/releases/list/components/release_block_assets.vue
new file mode 100644
index 00000000000..e840bc90d68
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_assets.vue
@@ -0,0 +1,65 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReleaseBlockAssets',
+ components: {
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ assets: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ hasAssets() {
+ return Boolean(this.assets.count);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="card-text prepend-top-default">
+ <b>
+ {{ __('Assets') }}
+ <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
+ </b>
+
+ <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
+ <li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
+ <gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
+ <icon name="package" class="align-middle append-right-4 align-text-bottom" />
+ {{ link.name }}
+ <span v-if="link.external">{{ __('(external source)') }}</span>
+ </gl-link>
+ </li>
+ </ul>
+
+ <div v-if="hasAssets" class="dropdown">
+ <button
+ type="button"
+ class="btn btn-link"
+ data-toggle="dropdown"
+ aria-haspopup="true"
+ aria-expanded="false"
+ >
+ <icon name="doc-code" class="align-top append-right-4" />
+ {{ __('Source code') }}
+ <icon name="arrow-down" />
+ </button>
+
+ <div class="js-sources-dropdown dropdown-menu">
+ <li v-for="asset in assets.sources" :key="asset.url">
+ <gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
+ </li>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block_author.vue b/app/assets/javascripts/releases/list/components/release_block_author.vue
new file mode 100644
index 00000000000..ff6b00d8221
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_author.vue
@@ -0,0 +1,42 @@
+<script>
+import { __, sprintf } from '~/locale';
+import { GlSprintf } from '@gitlab/ui';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ name: 'ReleaseBlockAuthor',
+ components: {
+ GlSprintf,
+ UserAvatarLink,
+ },
+ props: {
+ author: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ userImageAltDescription() {
+ return this.author && this.author.username
+ ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
+ : null;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="d-flex">
+ <gl-sprintf message="by %{user}">
+ <template #user>
+ <user-avatar-link
+ class="prepend-left-4"
+ :link-href="author.web_url"
+ :img-src="author.avatar_url"
+ :img-alt="userImageAltDescription"
+ :tooltip-text="author.username"
+ />
+ </template>
+ </gl-sprintf>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block_header.vue b/app/assets/javascripts/releases/list/components/release_block_header.vue
new file mode 100644
index 00000000000..9c5dcf2a709
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_header.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReleaseBlockHeader',
+ components: {
+ GlLink,
+ GlBadge,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ release: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ shouldShowEditButton() {
+ return Boolean(this.release._links && this.release._links.edit_url);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="card-header d-flex align-items-center bg-white pr-0">
+ <h2 class="card-title my-2 mr-auto gl-font-size-20">
+ {{ release.name }}
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
+ }}</gl-badge>
+ </h2>
+ <gl-link
+ v-if="shouldShowEditButton"
+ v-gl-tooltip
+ class="btn btn-default append-right-10 js-edit-button ml-2"
+ :title="__('Edit this release')"
+ :href="release._links.edit_url"
+ >
+ <icon name="pencil" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block_metadata.vue b/app/assets/javascripts/releases/list/components/release_block_metadata.vue
new file mode 100644
index 00000000000..f0aad594062
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_metadata.vue
@@ -0,0 +1,84 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import ReleaseBlockAuthor from './release_block_author.vue';
+import ReleaseBlockMilestones from './release_block_milestones.vue';
+
+export default {
+ name: 'ReleaseBlockMetadata',
+ components: {
+ Icon,
+ GlLink,
+ ReleaseBlockAuthor,
+ ReleaseBlockMilestones,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ release: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ author() {
+ return this.release.author;
+ },
+ commit() {
+ return this.release.commit || {};
+ },
+ commitUrl() {
+ return this.release.commit_path;
+ },
+ hasAuthor() {
+ return Boolean(this.author);
+ },
+ releasedTimeAgo() {
+ return sprintf(__('released %{time}'), {
+ time: this.timeFormatted(this.release.released_at),
+ });
+ },
+ shouldRenderMilestones() {
+ return Boolean(this.release.milestones?.length);
+ },
+ tagUrl() {
+ return this.release.tag_path;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="card-subtitle d-flex flex-wrap text-secondary">
+ <div class="append-right-8">
+ <icon name="commit" class="align-middle" />
+ <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
+ {{ commit.short_id }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span>
+ </div>
+
+ <div class="append-right-8">
+ <icon name="tag" class="align-middle" />
+ <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
+ {{ release.tag_name }}
+ </gl-link>
+ <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
+ </div>
+
+ <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" />
+
+ <div class="append-right-4">
+ &bull;
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
+ {{ releasedTimeAgo }}
+ </span>
+ </div>
+
+ <release-block-author v-if="hasAuthor" :author="author" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/list/components/release_block_milestones.vue b/app/assets/javascripts/releases/list/components/release_block_milestones.vue
new file mode 100644
index 00000000000..a3dff75b828
--- /dev/null
+++ b/app/assets/javascripts/releases/list/components/release_block_milestones.vue
@@ -0,0 +1,51 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReleaseBlockMilestones',
+ components: {
+ GlLink,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ milestones: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ labelText() {
+ return n__('Milestone', 'Milestones', this.milestones.length);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="js-milestone-list-label">
+ <icon name="flag" class="align-middle" />
+ <span class="js-label-text">{{ labelText }}</span>
+ </div>
+
+ <template v-for="(milestone, index) in milestones">
+ <gl-link
+ :key="milestone.id"
+ v-gl-tooltip
+ :title="milestone.description"
+ :href="milestone.web_url"
+ class="mx-1 js-milestone-link"
+ >
+ {{ milestone.title }}
+ </gl-link>
+ <template v-if="index !== milestones.length - 1">
+ &bull;
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue
index 6019af2dfe0..40ce200befb 100644
--- a/app/assets/javascripts/reports/components/modal.vue
+++ b/app/assets/javascripts/reports/components/modal.vue
@@ -1,14 +1,12 @@
<script>
// import { sprintf, __ } from '~/locale';
import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import CodeBlock from '~/vue_shared/components/code_block.vue';
import { fieldTypes } from '../constants';
export default {
components: {
Modal: DeprecatedModal2,
- LoadingButton,
CodeBlock,
},
props: {
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index 45c890769a0..20b0c52dbda 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -165,21 +165,23 @@ export default {
<template>
<section class="media-section">
<div class="media">
- <status-icon :status="statusIconName" :size="24" />
- <div class="media-body d-flex flex-align-self-center">
- <span class="js-code-text code-text">
- {{ headerText }}
- <slot :name="slotName"></slot>
-
- <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" />
- </span>
+ <status-icon :status="statusIconName" :size="24" class="align-self-center" />
+ <div class="media-body d-flex flex-align-self-center align-items-center">
+ <div class="js-code-text code-text">
+ <div>
+ {{ headerText }}
+ <slot :name="slotName"></slot>
+ <popover v-if="hasPopover" :options="popoverOptions" class="prepend-left-5" />
+ </div>
+ <slot name="subHeading"></slot>
+ </div>
<slot name="actionButtons"></slot>
<button
v-if="isCollapsible"
type="button"
- class="js-collapse-btn btn float-right btn-sm align-self-start qa-expand-report-button"
+ class="js-collapse-btn btn float-right btn-sm align-self-center qa-expand-report-button"
@click="toggleCollapsed"
>
{{ collapseText }}
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index 70678b0db37..fe1724acf89 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -90,7 +90,7 @@ export default {
<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="m-auto" />
+ <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
<template v-else>
<user-avatar-link
v-if="commit.author"
@@ -104,7 +104,11 @@ export default {
</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">
+ <gl-link
+ :href="commit.webUrl"
+ :class="{ 'font-italic': !commit.message }"
+ class="commit-row-message item-title"
+ >
{{ commit.title }}
</gl-link>
<gl-button
diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue
index 6b3822151ff..2bc93c3f1c1 100644
--- a/app/assets/javascripts/repository/components/preview/index.vue
+++ b/app/assets/javascripts/repository/components/preview/index.vue
@@ -44,7 +44,7 @@ export default {
</div>
</div>
<div class="blob-viewer">
- <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" />
+ <gl-loading-icon v-if="loading > 0" size="md" color="dark" class="my-4 mx-auto" />
<div v-else-if="readme" v-html="readme.html"></div>
</div>
</article>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 8f2e9264bca..29a3340b83d 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -34,6 +34,11 @@ export default {
type: Boolean,
required: true,
},
+ loadingPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -69,7 +74,12 @@ export default {
<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" />
+ <parent-row
+ v-show="showParentRow"
+ :commit-ref="ref"
+ :path="path"
+ :loading-path="loadingPath"
+ />
<template v-for="val in entries">
<table-row
v-for="entry in val"
@@ -84,6 +94,7 @@ export default {
:url="entry.webUrl"
:submodule-tree-url="entry.treeUrl"
:lfs-oid="entry.lfsOid"
+ :loading-path="loadingPath"
/>
</template>
<template v-if="isLoading">
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
index 3c39f404226..70a188f98cc 100644
--- a/app/assets/javascripts/repository/components/table/parent_row.vue
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -1,5 +1,10 @@
<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+
export default {
+ components: {
+ GlLoadingIcon,
+ },
props: {
commitRef: {
type: String,
@@ -9,13 +14,21 @@ export default {
type: String,
required: true,
},
+ loadingPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
- parentRoute() {
+ parentPath() {
const splitArray = this.path.split('/');
splitArray.pop();
- return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` };
+ return splitArray.join('/');
+ },
+ parentRoute() {
+ return { path: `/tree/${this.commitRef}/${this.parentPath}` };
},
},
methods: {
@@ -29,7 +42,13 @@ export default {
<template>
<tr class="tree-item">
<td colspan="3" class="tree-item-file-name" @click.self="clickRow">
- <router-link :to="parentRoute" :aria-label="__('Go to parent')">
+ <gl-loading-icon
+ v-if="parentPath === loadingPath"
+ size="sm"
+ inline
+ class="d-inline-block align-text-bottom"
+ />
+ <router-link v-else :to="parentRoute" :aria-label="__('Go to parent')">
..
</router-link>
</td>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index cf0457a2abf..a8e13241c37 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui';
+import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective, GlLoadingIcon } 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';
@@ -12,6 +12,7 @@ export default {
GlBadge,
GlLink,
GlSkeletonLoading,
+ GlLoadingIcon,
TimeagoTooltip,
Icon,
},
@@ -76,6 +77,11 @@ export default {
required: false,
default: null,
},
+ loadingPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -125,7 +131,13 @@ export default {
<template>
<tr :class="`file_${id}`" class="tree-item" @click="openRow">
<td class="tree-item-file-name">
- <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
+ <gl-loading-icon
+ v-if="path === loadingPath"
+ size="sm"
+ inline
+ class="d-inline-block align-text-bottom fa-fw"
+ />
+ <i v-else :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
<component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated">
{{ fullPath }}
</component>
diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue
index 949e653fc8f..92e33b013c3 100644
--- a/app/assets/javascripts/repository/components/tree_content.vue
+++ b/app/assets/javascripts/repository/components/tree_content.vue
@@ -5,6 +5,7 @@ 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 getVueFileListLfsBadge from '../queries/getVueFileListLfsBadge.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';
@@ -20,6 +21,9 @@ export default {
projectPath: {
query: getProjectPath,
},
+ vueFileListLfsBadge: {
+ query: getVueFileListLfsBadge,
+ },
},
props: {
path: {
@@ -27,6 +31,11 @@ export default {
required: false,
default: '/',
},
+ loadingPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -38,6 +47,7 @@ export default {
blobs: [],
},
isLoadingFiles: false,
+ vueFileListLfsBadge: false,
};
},
computed: {
@@ -72,6 +82,7 @@ export default {
path: this.path || '/',
nextPageCursor: this.nextPageCursor,
pageSize: PAGE_SIZE,
+ vueLfsEnabled: this.vueFileListLfsBadge,
},
})
.then(({ data }) => {
@@ -109,7 +120,12 @@ export default {
<template>
<div>
- <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" />
+ <file-table
+ :path="path"
+ :entries="entries"
+ :is-loading="isLoadingFiles"
+ :loading-path="loadingPath"
+ />
<file-preview v-if="readme" :blob="readme" />
</div>
</template>
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index ae6409a0ac9..2ef0c078f13 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -23,6 +23,7 @@ export default function setupVueRepositoryList() {
projectPath,
projectShortPath,
ref,
+ vueFileListLfsBadge: gon?.features?.vueFileListLfsBadge,
commits: [],
},
});
diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js
new file mode 100644
index 00000000000..e68996245a8
--- /dev/null
+++ b/app/assets/javascripts/repository/mixins/preload.js
@@ -0,0 +1,36 @@
+import getFiles from '../queries/getFiles.query.graphql';
+import getRefMixin from './get_ref';
+import getProjectPath from '../queries/getProjectPath.query.graphql';
+
+export default {
+ mixins: [getRefMixin],
+ apollo: {
+ projectPath: {
+ query: getProjectPath,
+ },
+ },
+ data() {
+ return { projectPath: '', loadingPath: null };
+ },
+ beforeRouteUpdate(to, from, next) {
+ this.preload(to.params.pathMatch, next);
+ },
+ methods: {
+ preload(path, next) {
+ this.loadingPath = path.replace(/^\//, '');
+
+ return this.$apollo
+ .query({
+ query: getFiles,
+ variables: {
+ projectPath: this.projectPath,
+ ref: this.ref,
+ path: this.loadingPath,
+ nextPageCursor: '',
+ pageSize: 100,
+ },
+ })
+ .then(() => next());
+ },
+ },
+};
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
index dd4d437f4dd..adc332fa370 100644
--- a/app/assets/javascripts/repository/pages/tree.vue
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -1,11 +1,13 @@
<script>
import TreeContent from '../components/tree_content.vue';
import { updateElementsVisibility } from '../utils/dom';
+import preloadMixin from '../mixins/preload';
export default {
components: {
TreeContent,
},
+ mixins: [preloadMixin],
props: {
path: {
type: String,
@@ -34,5 +36,5 @@ export default {
</script>
<template>
- <tree-content :path="path" />
+ <tree-content :path="path" :loading-path="loadingPath" />
</template>
diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql
index 2aaf5066b4a..01ad72ef752 100644
--- a/app/assets/javascripts/repository/queries/getFiles.query.graphql
+++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql
@@ -14,6 +14,7 @@ query getFiles(
$ref: String!
$pageSize: Int!
$nextPageCursor: String
+ $vueLfsEnabled: Boolean = false
) {
project(fullPath: $projectPath) {
repository {
@@ -46,7 +47,7 @@ query getFiles(
node {
...TreeEntry
webUrl
- lfsOid
+ lfsOid @include(if: $vueLfsEnabled)
}
}
pageInfo {
diff --git a/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
new file mode 100644
index 00000000000..3c3d14881da
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getVueFileListLfsBadge.query.graphql
@@ -0,0 +1,3 @@
+query getProjectShortPath {
+ vueFileListLfsBadge @client
+}
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index 9be025afe39..c812614e94d 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -6,6 +6,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
sha
title
description
+ message
webUrl
authoredDate
authorName
diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js
index e43b2bdc33a..5b62271b02e 100644
--- a/app/assets/javascripts/repository/utils/readme.js
+++ b/app/assets/javascripts/repository/utils/readme.js
@@ -1,21 +1,32 @@
-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');
+const FILENAMES = ['index', 'readme'];
-// eslint-disable-next-line import/prefer-default-export
-export const readmeFile = blobs => {
- const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1);
+const MARKUP_EXTENSIONS = [
+ 'ad',
+ 'adoc',
+ 'asciidoc',
+ 'creole',
+ 'markdown',
+ 'md',
+ 'mdown',
+ 'mediawiki',
+ 'mkd',
+ 'mkdn',
+ 'org',
+ 'rdoc',
+ 'rst',
+ 'textile',
+ 'wiki',
+];
- const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1);
- const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1);
+const isRichReadme = file => {
+ const re = new RegExp(`^(${FILENAMES.join('|')})\\.(${MARKUP_EXTENSIONS.join('|')})$`, 'i');
+ return re.test(file.name);
+};
- return previewableReadme || plainReadme;
+const isPlainReadme = file => {
+ const re = new RegExp(`^(${FILENAMES.join('|')})(\\.txt)?$`, 'i');
+ return re.test(file.name);
};
+
+// eslint-disable-next-line import/prefer-default-export
+export const readmeFile = blobs => blobs.find(isRichReadme) || blobs.find(isPlainReadme);
diff --git a/app/assets/javascripts/self_monitor/components/self_monitor_form.vue b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
new file mode 100644
index 00000000000..2f364eae67f
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/components/self_monitor_form.vue
@@ -0,0 +1,160 @@
+<script>
+import Vue from 'vue';
+import { GlFormGroup, GlButton, GlModal, GlToast, GlToggle } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+import { __, s__, sprintf } from '~/locale';
+import { visitUrl, getBaseURL } from '~/lib/utils/url_utility';
+
+Vue.use(GlToast);
+
+export default {
+ components: {
+ GlFormGroup,
+ GlButton,
+ GlModal,
+ GlToggle,
+ },
+ formLabels: {
+ createProject: __('Create Project'),
+ },
+ data() {
+ return {
+ modalId: 'delete-self-monitor-modal',
+ };
+ },
+ computed: {
+ ...mapState('selfMonitoring', [
+ 'projectEnabled',
+ 'projectCreated',
+ 'showAlert',
+ 'projectPath',
+ 'loading',
+ 'alertContent',
+ ]),
+ selfMonitorEnabled: {
+ get() {
+ return this.projectEnabled;
+ },
+ set(projectEnabled) {
+ this.setSelfMonitor(projectEnabled);
+ },
+ },
+ selfMonitorProjectFullUrl() {
+ return `${getBaseURL()}/${this.projectPath}`;
+ },
+ selfMonitoringFormText() {
+ if (this.projectCreated) {
+ return sprintf(
+ s__(
+ 'SelfMonitoring|Enabling this feature creates a %{projectLinkStart}project%{projectLinkEnd} that can be used to monitor the health of your instance.',
+ ),
+ {
+ projectLinkStart: `<a href="${this.selfMonitorProjectFullUrl}">`,
+ projectLinkEnd: '</a>',
+ },
+ false,
+ );
+ }
+
+ return s__(
+ 'SelfMonitoring|Enabling this feature creates a project that can be used to monitor the health of your instance.',
+ );
+ },
+ },
+ watch: {
+ selfMonitorEnabled() {
+ this.saveChangesSelfMonitorProject();
+ },
+ showAlert() {
+ let toastOptions = {
+ onComplete: () => {
+ this.resetAlert();
+ },
+ };
+
+ if (this.showAlert) {
+ if (this.alertContent.actionName && this.alertContent.actionName.length > 0) {
+ toastOptions = {
+ ...toastOptions,
+ action: {
+ text: this.alertContent.actionText,
+ onClick: (_, toastObject) => {
+ this[this.alertContent.actionName]();
+ toastObject.goAway(0);
+ },
+ },
+ };
+ }
+ this.$toast.show(this.alertContent.message, toastOptions);
+ }
+ },
+ },
+ methods: {
+ ...mapActions('selfMonitoring', [
+ 'setSelfMonitor',
+ 'createProject',
+ 'deleteProject',
+ 'resetAlert',
+ ]),
+ hideSelfMonitorModal() {
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ this.setSelfMonitor(true);
+ },
+ showSelfMonitorModal() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ saveChangesSelfMonitorProject() {
+ if (this.projectCreated && !this.projectEnabled) {
+ this.showSelfMonitorModal();
+ } else {
+ this.createProject();
+ }
+ },
+ viewSelfMonitorProject() {
+ visitUrl(this.selfMonitorProjectFullUrl);
+ },
+ },
+};
+</script>
+<template>
+ <section class="settings no-animate js-self-monitoring-settings">
+ <div class="settings-header">
+ <h4 class="js-section-header">
+ {{ s__('SelfMonitoring|Self monitoring') }}
+ </h4>
+ <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
+ <p class="js-section-sub-header">
+ {{ s__('SelfMonitoring|Enable or disable instance self monitoring') }}
+ </p>
+ </div>
+ <div class="settings-content">
+ <form name="self-monitoring-form">
+ <p v-html="selfMonitoringFormText"></p>
+ <gl-form-group :label="$options.formLabels.createProject" label-for="self-monitor-toggle">
+ <gl-toggle
+ v-model="selfMonitorEnabled"
+ :is-loading="loading"
+ name="self-monitor-toggle"
+ />
+ </gl-form-group>
+ </form>
+ </div>
+ <gl-modal
+ :title="s__('SelfMonitoring|Disable self monitoring?')"
+ :modal-id="modalId"
+ :ok-title="__('Delete project')"
+ :cancel-title="__('Cancel')"
+ ok-variant="danger"
+ @ok="deleteProject"
+ @cancel="hideSelfMonitorModal"
+ >
+ <div>
+ {{
+ s__(
+ 'SelfMonitoring|Disabling this feature will delete the self monitoring project. Are you sure you want to delete the project?',
+ )
+ }}
+ </div>
+ </gl-modal>
+ </section>
+</template>
diff --git a/app/assets/javascripts/self_monitor/index.js b/app/assets/javascripts/self_monitor/index.js
new file mode 100644
index 00000000000..42c94e11989
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/index.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import store from './store';
+import SelfMonitorForm from './components/self_monitor_form.vue';
+
+export default () => {
+ const el = document.querySelector('.js-self-monitoring-settings');
+ let selfMonitorProjectCreated;
+
+ if (el) {
+ selfMonitorProjectCreated = el.dataset.selfMonitoringProjectExists;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ store: store({
+ projectEnabled: selfMonitorProjectCreated,
+ ...el.dataset,
+ }),
+ render(createElement) {
+ return createElement(SelfMonitorForm);
+ },
+ });
+ }
+};
diff --git a/app/assets/javascripts/self_monitor/store/actions.js b/app/assets/javascripts/self_monitor/store/actions.js
new file mode 100644
index 00000000000..f8430a9b136
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/actions.js
@@ -0,0 +1,126 @@
+import { __, s__ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+const TWO_MINUTES = 120000;
+
+function backOffRequest(makeRequestCallback) {
+ return backOff((next, stop) => {
+ makeRequestCallback()
+ .then(resp => {
+ if (resp.status === statusCodes.ACCEPTED) {
+ next();
+ } else {
+ stop(resp);
+ }
+ })
+ .catch(stop);
+ }, TWO_MINUTES);
+}
+
+export const setSelfMonitor = ({ commit }, enabled) => commit(types.SET_ENABLED, enabled);
+
+export const createProject = ({ dispatch }) => dispatch('requestCreateProject');
+
+export const resetAlert = ({ commit }) => commit(types.SET_SHOW_ALERT, false);
+
+export const requestCreateProject = ({ dispatch, state, commit }) => {
+ commit(types.SET_LOADING, true);
+ axios
+ .post(state.createProjectEndpoint)
+ .then(resp => {
+ if (resp.status === statusCodes.ACCEPTED) {
+ dispatch('requestCreateProjectStatus', resp.data.job_id);
+ }
+ })
+ .catch(error => {
+ dispatch('requestCreateProjectError', error);
+ });
+};
+
+export const requestCreateProjectStatus = ({ dispatch, state }, jobId) => {
+ backOffRequest(() => axios.get(state.createProjectStatusEndpoint, { params: { job_id: jobId } }))
+ .then(resp => {
+ if (resp.status === statusCodes.OK) {
+ dispatch('requestCreateProjectSuccess', resp.data);
+ }
+ })
+ .catch(error => {
+ dispatch('requestCreateProjectError', error);
+ });
+};
+
+export const requestCreateProjectSuccess = ({ commit }, selfMonitorData) => {
+ commit(types.SET_LOADING, false);
+ commit(types.SET_PROJECT_URL, selfMonitorData.project_full_path);
+ commit(types.SET_ALERT_CONTENT, {
+ message: s__('SelfMonitoring|Self monitoring project has been successfully created.'),
+ actionText: __('View project'),
+ actionName: 'viewSelfMonitorProject',
+ });
+ commit(types.SET_SHOW_ALERT, true);
+ commit(types.SET_PROJECT_CREATED, true);
+};
+
+export const requestCreateProjectError = ({ commit }, error) => {
+ const { response } = error;
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ commit(types.SET_ALERT_CONTENT, {
+ message: `${__('There was an error saving your changes.')} ${message}`,
+ });
+ commit(types.SET_SHOW_ALERT, true);
+ commit(types.SET_LOADING, false);
+};
+
+export const deleteProject = ({ dispatch }) => dispatch('requestDeleteProject');
+
+export const requestDeleteProject = ({ dispatch, state, commit }) => {
+ commit(types.SET_LOADING, true);
+ axios
+ .delete(state.deleteProjectEndpoint)
+ .then(resp => {
+ if (resp.status === statusCodes.ACCEPTED) {
+ dispatch('requestDeleteProjectStatus', resp.data.job_id);
+ }
+ })
+ .catch(error => {
+ dispatch('requestDeleteProjectError', error);
+ });
+};
+
+export const requestDeleteProjectStatus = ({ dispatch, state }, jobId) => {
+ backOffRequest(() => axios.get(state.deleteProjectStatusEndpoint, { params: { job_id: jobId } }))
+ .then(resp => {
+ if (resp.status === statusCodes.OK) {
+ dispatch('requestDeleteProjectSuccess', resp.data);
+ }
+ })
+ .catch(error => {
+ dispatch('requestDeleteProjectError', error);
+ });
+};
+
+export const requestDeleteProjectSuccess = ({ commit }) => {
+ commit(types.SET_PROJECT_URL, '');
+ commit(types.SET_PROJECT_CREATED, false);
+ commit(types.SET_ALERT_CONTENT, {
+ message: s__('SelfMonitoring|Self monitoring project has been successfully deleted.'),
+ actionText: __('Undo'),
+ actionName: 'createProject',
+ });
+ commit(types.SET_SHOW_ALERT, true);
+ commit(types.SET_LOADING, false);
+};
+
+export const requestDeleteProjectError = ({ commit }, error) => {
+ const { response } = error;
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ commit(types.SET_ALERT_CONTENT, {
+ message: `${__('There was an error saving your changes.')} ${message}`,
+ });
+ commit(types.SET_LOADING, false);
+};
diff --git a/app/assets/javascripts/self_monitor/store/index.js b/app/assets/javascripts/self_monitor/store/index.js
new file mode 100644
index 00000000000..a222e9c87b8
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/index.js
@@ -0,0 +1,21 @@
+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({
+ modules: {
+ selfMonitoring: {
+ namespaced: true,
+ state: createState(initialState),
+ actions,
+ mutations,
+ },
+ },
+ });
+
+export default createStore;
diff --git a/app/assets/javascripts/self_monitor/store/mutation_types.js b/app/assets/javascripts/self_monitor/store/mutation_types.js
new file mode 100644
index 00000000000..c5952b66144
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/mutation_types.js
@@ -0,0 +1,6 @@
+export const SET_ENABLED = 'SET_ENABLED';
+export const SET_PROJECT_CREATED = 'SET_PROJECT_CREATED';
+export const SET_SHOW_ALERT = 'SET_SHOW_ALERT';
+export const SET_PROJECT_URL = 'SET_PROJECT_URL';
+export const SET_LOADING = 'SET_LOADING';
+export const SET_ALERT_CONTENT = 'SET_ALERT_CONTENT';
diff --git a/app/assets/javascripts/self_monitor/store/mutations.js b/app/assets/javascripts/self_monitor/store/mutations.js
new file mode 100644
index 00000000000..7dca8bcdc4d
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/mutations.js
@@ -0,0 +1,22 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_ENABLED](state, enabled) {
+ state.projectEnabled = enabled;
+ },
+ [types.SET_PROJECT_CREATED](state, created) {
+ state.projectCreated = created;
+ },
+ [types.SET_SHOW_ALERT](state, show) {
+ state.showAlert = show;
+ },
+ [types.SET_PROJECT_URL](state, url) {
+ state.projectPath = url;
+ },
+ [types.SET_LOADING](state, loading) {
+ state.loading = loading;
+ },
+ [types.SET_ALERT_CONTENT](state, content) {
+ state.alertContent = content;
+ },
+};
diff --git a/app/assets/javascripts/self_monitor/store/state.js b/app/assets/javascripts/self_monitor/store/state.js
new file mode 100644
index 00000000000..b8b4a4af614
--- /dev/null
+++ b/app/assets/javascripts/self_monitor/store/state.js
@@ -0,0 +1,15 @@
+import { parseBoolean } from '~/lib/utils/common_utils';
+
+export default (initialState = {}) => ({
+ projectEnabled: parseBoolean(initialState.projectEnabled) || false,
+ projectCreated: parseBoolean(initialState.selfMonitorProjectCreated) || false,
+ createProjectEndpoint: initialState.createSelfMonitoringProjectPath || '',
+ deleteProjectEndpoint: initialState.deleteSelfMonitoringProjectPath || '',
+ createProjectStatusEndpoint: initialState.statusCreateSelfMonitoringProjectPath || '',
+ deleteProjectStatusEndpoint: initialState.statusDeleteSelfMonitoringProjectPath || '',
+ selfMonitorProjectPath: initialState.selfMonitoringProjectFullPath || '',
+ showAlert: false,
+ projectPath: '',
+ loading: false,
+ alertContent: {},
+});
diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
new file mode 100644
index 00000000000..c90478db620
--- /dev/null
+++ b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue
@@ -0,0 +1,43 @@
+<script>
+import Stacktrace from '~/error_tracking/components/stacktrace.vue';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { mapActions, mapState, mapGetters } from 'vuex';
+
+export default {
+ name: 'SentryErrorStackTrace',
+ components: {
+ Stacktrace,
+ GlLoadingIcon,
+ },
+ props: {
+ issueStackTracePath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState('details', ['loadingStacktrace', 'stacktraceData']),
+ ...mapGetters('details', ['stacktrace']),
+ },
+ mounted() {
+ this.startPollingStacktrace(this.issueStackTracePath);
+ },
+ methods: {
+ ...mapActions('details', ['startPollingStacktrace']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div :class="{ 'border-bottom-0': loadingStacktrace }" class="card card-slim mt-4 mb-0">
+ <div class="card-header border-bottom-0">
+ <h5 class="card-title my-1">{{ __('Stack trace') }}</h5>
+ </div>
+ </div>
+ <div v-if="loadingStacktrace" class="card">
+ <gl-loading-icon class="py-2" label="Fetching stack trace" :size="1" />
+ </div>
+ <stacktrace v-else :entries="stacktrace" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/sentry_error_stack_trace/index.js b/app/assets/javascripts/sentry_error_stack_trace/index.js
new file mode 100644
index 00000000000..9b24ddc335d
--- /dev/null
+++ b/app/assets/javascripts/sentry_error_stack_trace/index.js
@@ -0,0 +1,22 @@
+import Vue from 'vue';
+import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue';
+import store from '~/error_tracking/store';
+
+export default function initSentryErrorStacktrace() {
+ const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace');
+ if (sentryErrorStackTraceEl) {
+ const { issueStackTracePath } = sentryErrorStackTraceEl.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: sentryErrorStackTraceEl,
+ components: {
+ SentryErrorStackTrace,
+ },
+ store,
+ render: createElement =>
+ createElement('sentry-error-stack-trace', {
+ props: { issueStackTracePath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 308bc4a2ddd..cdbf57f3e55 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -2,7 +2,6 @@
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
-import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
import { CHECKING_INSTALLED } from '../constants';
@@ -10,7 +9,6 @@ import { CHECKING_INSTALLED } from '../constants';
export default {
components: {
EnvironmentRow,
- FunctionRow,
EmptyState,
GlLoadingIcon,
},
diff --git a/app/assets/javascripts/sidebar/components/participants/participants.vue b/app/assets/javascripts/sidebar/components/participants/participants.vue
index 38b19d66163..f2ef7a2268e 100644
--- a/app/assets/javascripts/sidebar/components/participants/participants.vue
+++ b/app/assets/javascripts/sidebar/components/participants/participants.vue
@@ -28,6 +28,11 @@ export default {
required: false,
default: 7,
},
+ showParticipantLabel: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -80,6 +85,7 @@ export default {
<template>
<div>
<div
+ v-if="showParticipantLabel"
v-tooltip
:title="participantLabel"
class="sidebar-collapsed-icon"
@@ -92,7 +98,7 @@ export default {
<gl-loading-icon v-if="loading" class="js-participants-collapsed-loading-icon" />
<span v-else class="js-participants-collapsed-count"> {{ participantCount }} </span>
</div>
- <div class="title hide-collapsed">
+ <div v-if="showParticipantLabel" class="title hide-collapsed">
<gl-loading-icon
v-if="loading"
:inline="true"
diff --git a/app/assets/javascripts/snippets/components/app.vue b/app/assets/javascripts/snippets/components/app.vue
index bd2cb8e4595..7a2145a800c 100644
--- a/app/assets/javascripts/snippets/components/app.vue
+++ b/app/assets/javascripts/snippets/components/app.vue
@@ -1,11 +1,13 @@
<script>
import GetSnippetQuery from '../queries/snippet.query.graphql';
import SnippetHeader from './snippet_header.vue';
+import SnippetTitle from './snippet_title.vue';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
SnippetHeader,
+ SnippetTitle,
GlLoadingIcon,
},
apollo: {
@@ -45,6 +47,9 @@ export default {
:size="2"
class="loading-animation prepend-top-20 append-bottom-20"
/>
- <snippet-header v-else :snippet="snippet" />
+ <template v-else>
+ <snippet-header :snippet="snippet" />
+ <snippet-title :snippet="snippet" />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
new file mode 100644
index 00000000000..fc8a9b4a390
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -0,0 +1,35 @@
+<script>
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { GlSprintf } from '@gitlab/ui';
+
+export default {
+ components: {
+ TimeAgoTooltip,
+ GlSprintf,
+ },
+ props: {
+ snippet: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="snippet-header limited-header-width">
+ <h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
+ {{ snippet.title }}
+ </h2>
+ <div v-if="snippet.description" class="description" data-qa-selector="snippet_description">
+ <div class="md">{{ snippet.description }}</div>
+ </div>
+
+ <small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
+ <gl-sprintf message="Edited %{timeago}">
+ <template #timeago>
+ <time-ago-tooltip :time="snippet.updatedAt" tooltip-placement="bottom" />
+ </template>
+ </gl-sprintf>
+ </small>
+ </div>
+</template>
diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js
index a530c4a99e2..59276ee79d8 100644
--- a/app/assets/javascripts/tree.js
+++ b/app/assets/javascripts/tree.js
@@ -21,7 +21,9 @@ export default class TreeView {
}
});
// Show the "Loading commit data" for only the first element
- $('span.log_loading:first').removeClass('hide');
+ $('span.log_loading')
+ .first()
+ .removeClass('hide');
}
initKeyNav() {
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 57fbb88ca2e..6d7d863f273 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -27,633 +27,623 @@ function UsersSelect(currentUser, els, options = {}) {
}
const { handleClick } = options;
+ const userSelect = this;
+
+ $els.each((i, dropdown) => {
+ const userSelect = this;
+ const options = {};
+ const $dropdown = $(dropdown);
+ options.projectId = $dropdown.data('projectId');
+ options.groupId = $dropdown.data('groupId');
+ options.showCurrentUser = $dropdown.data('currentUser');
+ options.todoFilter = $dropdown.data('todoFilter');
+ options.todoStateFilter = $dropdown.data('todoStateFilter');
+ options.iid = $dropdown.data('iid');
+ options.issuableType = $dropdown.data('issuableType');
+ const showNullUser = $dropdown.data('nullUser');
+ const defaultNullUser = $dropdown.data('nullUserDefault');
+ const showMenuAbove = $dropdown.data('showMenuAbove');
+ const showAnyUser = $dropdown.data('anyUser');
+ const firstUser = $dropdown.data('firstUser');
+ options.authorId = $dropdown.data('authorId');
+ const defaultLabel = $dropdown.data('defaultLabel');
+ const issueURL = $dropdown.data('issueUpdate');
+ const $selectbox = $dropdown.closest('.selectbox');
+ let $block = $selectbox.closest('.block');
+ const abilityName = $dropdown.data('abilityName');
+ let $value = $block.find('.value');
+ const $collapsedSidebar = $block.find('.sidebar-collapsed-user');
+ // eslint-disable-next-line no-jquery/no-fade
+ const $loading = $block.find('.block-loading').fadeOut();
+ const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null;
+ let selectedId = $dropdown.data('selected');
+ let assignTo;
+ let assigneeTemplate;
+ let collapsedAssigneeTemplate;
+
+ if (selectedId === undefined) {
+ selectedId = selectedIdDefault;
+ }
- $els.each(
- (function(_this) {
- return function(i, dropdown) {
- const options = {};
- const $dropdown = $(dropdown);
- options.projectId = $dropdown.data('projectId');
- options.groupId = $dropdown.data('groupId');
- options.showCurrentUser = $dropdown.data('currentUser');
- options.todoFilter = $dropdown.data('todoFilter');
- options.todoStateFilter = $dropdown.data('todoStateFilter');
- options.iid = $dropdown.data('iid');
- options.issuableType = $dropdown.data('issuableType');
- const showNullUser = $dropdown.data('nullUser');
- const defaultNullUser = $dropdown.data('nullUserDefault');
- const showMenuAbove = $dropdown.data('showMenuAbove');
- const showAnyUser = $dropdown.data('anyUser');
- const firstUser = $dropdown.data('firstUser');
- options.authorId = $dropdown.data('authorId');
- const defaultLabel = $dropdown.data('defaultLabel');
- const issueURL = $dropdown.data('issueUpdate');
- const $selectbox = $dropdown.closest('.selectbox');
- let $block = $selectbox.closest('.block');
- const abilityName = $dropdown.data('abilityName');
- let $value = $block.find('.value');
- const $collapsedSidebar = $block.find('.sidebar-collapsed-user');
- const $loading = $block.find('.block-loading').fadeOut();
- const selectedIdDefault = defaultNullUser && showNullUser ? 0 : null;
- let selectedId = $dropdown.data('selected');
- let assignTo;
- let assigneeTemplate;
- let collapsedAssigneeTemplate;
-
- if (selectedId === undefined) {
- selectedId = selectedIdDefault;
- }
-
- const assignYourself = function() {
- const unassignedSelected = $dropdown
- .closest('.selectbox')
- .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
+ const assignYourself = function() {
+ const unassignedSelected = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
+ }
+
+ // Save current selected user to the DOM
+ const currentUserInfo = $dropdown.data('currentUserInfo') || {};
+ const currentUser = userSelect.currentUser || {};
+ const fieldName = $dropdown.data('fieldName');
+ const userName = currentUserInfo.name;
+ const userId = currentUserInfo.id || currentUser.id;
+
+ const inputHtmlString = _.template(`
+ <input type="hidden" name="<%- fieldName %>"
+ data-meta="<%- userName %>"
+ value="<%- userId %>" />
+ `)({ fieldName, userName, userId });
+
+ if ($selectbox) {
+ $dropdown.parent().before(inputHtmlString);
+ } else {
+ $dropdown.after(inputHtmlString);
+ }
+ };
- if (unassignedSelected) {
- unassignedSelected.remove();
- }
+ if ($block[0]) {
+ $block[0].addEventListener('assignYourself', assignYourself);
+ }
- // Save current selected user to the DOM
- const currentUserInfo = $dropdown.data('currentUserInfo') || {};
- const currentUser = _this.currentUser || {};
- const fieldName = $dropdown.data('fieldName');
- const userName = currentUserInfo.name;
- const userId = currentUserInfo.id || currentUser.id;
-
- const inputHtmlString = _.template(`
- <input type="hidden" name="<%- fieldName %>"
- data-meta="<%- userName %>"
- value="<%- userId %>" />
- `)({ fieldName, userName, userId });
-
- if ($selectbox) {
- $dropdown.parent().before(inputHtmlString);
- } else {
- $dropdown.after(inputHtmlString);
- }
- };
+ const getSelectedUserInputs = function() {
+ return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`);
+ };
- if ($block[0]) {
- $block[0].addEventListener('assignYourself', assignYourself);
- }
+ const getSelected = function() {
+ return getSelectedUserInputs()
+ .map((index, input) => parseInt(input.value, 10))
+ .get();
+ };
- const getSelectedUserInputs = function() {
- return $selectbox.find(`input[name="${$dropdown.data('fieldName')}"]`);
- };
-
- const getSelected = function() {
- return getSelectedUserInputs()
- .map((index, input) => parseInt(input.value, 10))
- .get();
- };
-
- const checkMaxSelect = function() {
- const maxSelect = $dropdown.data('maxSelect');
- if (maxSelect) {
- const selected = getSelected();
-
- if (selected.length > maxSelect) {
- const firstSelectedId = selected[0];
- const firstSelected = $dropdown
- .closest('.selectbox')
- .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
-
- firstSelected.remove();
- emitSidebarEvent('sidebar.removeAssignee', {
- id: firstSelectedId,
- });
- }
- }
- };
-
- const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
- const selectedUsers = getSelected().filter(u => u !== 0);
-
- const firstUser = getSelectedUserInputs()
- .map((index, input) => ({
- name: input.dataset.meta,
- value: parseInt(input.value, 10),
- }))
- .filter(u => u.id !== 0)
- .get(0);
-
- if (selectedUsers.length === 0) {
- return s__('UsersSelect|Unassigned');
- } else if (selectedUsers.length === 1) {
- return firstUser.name;
- } else if (isSelected) {
- const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
- return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
- name: selectedUser.name,
- length: otherSelected.length,
- });
- } else {
- return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
- name: firstUser.name,
- length: selectedUsers.length - 1,
- });
- }
- };
+ const checkMaxSelect = function() {
+ const maxSelect = $dropdown.data('maxSelect');
+ if (maxSelect) {
+ const selected = getSelected();
- $('.assign-to-me-link').on('click', e => {
- e.preventDefault();
- $(e.currentTarget).hide();
+ if (selected.length > maxSelect) {
+ const firstSelectedId = selected[0];
+ const firstSelected = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=${firstSelectedId}]`);
- if ($dropdown.data('multiSelect')) {
- assignYourself();
- checkMaxSelect();
+ firstSelected.remove();
+ emitSidebarEvent('sidebar.removeAssignee', {
+ id: firstSelectedId,
+ });
+ }
+ }
+ };
- const currentUserInfo = $dropdown.data('currentUserInfo');
- $dropdown
- .find('.dropdown-toggle-text')
- .text(getMultiSelectDropdownTitle(currentUserInfo))
- .removeClass('is-default');
- } else {
- const $input = $(`input[name="${$dropdown.data('fieldName')}"]`);
- $input.val(gon.current_user_id);
- selectedId = $input.val();
- $dropdown
- .find('.dropdown-toggle-text')
- .text(gon.current_user_fullname)
- .removeClass('is-default');
- }
+ const getMultiSelectDropdownTitle = function(selectedUser, isSelected) {
+ const selectedUsers = getSelected().filter(u => u !== 0);
+
+ const firstUser = getSelectedUserInputs()
+ .map((index, input) => ({
+ name: input.dataset.meta,
+ value: parseInt(input.value, 10),
+ }))
+ .filter(u => u.id !== 0)
+ .get(0);
+
+ if (selectedUsers.length === 0) {
+ return s__('UsersSelect|Unassigned');
+ } else if (selectedUsers.length === 1) {
+ return firstUser.name;
+ } else if (isSelected) {
+ const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: selectedUser.name,
+ length: otherSelected.length,
});
-
- $block.on('click', '.js-assign-yourself', e => {
- e.preventDefault();
- return assignTo(_this.currentUser.id);
+ } else {
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: firstUser.name,
+ length: selectedUsers.length - 1,
});
+ }
+ };
- assignTo = function(selected) {
- const data = {};
- data[abilityName] = {};
- data[abilityName].assignee_id = selected != null ? selected : null;
- $loading.removeClass('hidden').fadeIn();
- $dropdown.trigger('loading.gl.dropdown');
-
- return axios.put(issueURL, data).then(({ data }) => {
- let user = {};
- let tooltipTitle = user.name;
- $dropdown.trigger('loaded.gl.dropdown');
- $loading.fadeOut();
- if (data.assignee) {
- user = {
- name: data.assignee.name,
- username: data.assignee.username,
- avatar: data.assignee.avatar_url,
- };
- tooltipTitle = _.escape(user.name);
- } else {
- user = {
- name: s__('UsersSelect|Unassigned'),
- username: '',
- avatar: '',
+ $('.assign-to-me-link').on('click', e => {
+ e.preventDefault();
+ $(e.currentTarget).hide();
+
+ if ($dropdown.data('multiSelect')) {
+ assignYourself();
+ checkMaxSelect();
+
+ const currentUserInfo = $dropdown.data('currentUserInfo');
+ $dropdown
+ .find('.dropdown-toggle-text')
+ .text(getMultiSelectDropdownTitle(currentUserInfo))
+ .removeClass('is-default');
+ } else {
+ const $input = $(`input[name="${$dropdown.data('fieldName')}"]`);
+ $input.val(gon.current_user_id);
+ selectedId = $input.val();
+ $dropdown
+ .find('.dropdown-toggle-text')
+ .text(gon.current_user_fullname)
+ .removeClass('is-default');
+ }
+ });
+
+ $block.on('click', '.js-assign-yourself', e => {
+ e.preventDefault();
+ return assignTo(userSelect.currentUser.id);
+ });
+
+ assignTo = function(selected) {
+ const data = {};
+ data[abilityName] = {};
+ data[abilityName].assignee_id = selected != null ? selected : null;
+ // eslint-disable-next-line no-jquery/no-fade
+ $loading.removeClass('hidden').fadeIn();
+ $dropdown.trigger('loading.gl.dropdown');
+
+ return axios.put(issueURL, data).then(({ data }) => {
+ let user = {};
+ let tooltipTitle = user.name;
+ $dropdown.trigger('loaded.gl.dropdown');
+ // eslint-disable-next-line no-jquery/no-fade
+ $loading.fadeOut();
+ if (data.assignee) {
+ user = {
+ name: data.assignee.name,
+ username: data.assignee.username,
+ avatar: data.assignee.avatar_url,
+ };
+ tooltipTitle = _.escape(user.name);
+ } else {
+ user = {
+ name: s__('UsersSelect|Unassigned'),
+ username: '',
+ avatar: '',
+ };
+ tooltipTitle = s__('UsersSelect|Assignee');
+ }
+ $value.html(assigneeTemplate(user));
+ $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
+ return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
+ });
+ };
+ collapsedAssigneeTemplate = _.template(
+ '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
+ );
+ assigneeTemplate = _.template(
+ `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
+ ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
+ openingTag: '<a href="#" class="js-assign-yourself">',
+ closingTag: '</a>',
+ })}</span> <% } %>`,
+ );
+ return $dropdown.glDropdown({
+ showMenuAbove,
+ data(term, callback) {
+ return userSelect.users(term, options, users => {
+ // GitLabDropdownFilter returns this.instance
+ // GitLabDropdownRemote returns this.options.instance
+ const glDropdown = this.instance || this.options.instance;
+ glDropdown.options.processData(term, users, callback);
+ });
+ },
+ processData(term, data, callback) {
+ let users = data;
+
+ // Only show assigned user list when there is no search term
+ if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
+ const selectedInputs = getSelectedUserInputs();
+
+ // Potential duplicate entries when dealing with issue board
+ // because issue board is also managed by vue
+ const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
+ .filter(input => {
+ const userId = parseInt(input.value, 10);
+ const inUsersArray = users.find(u => u.id === userId);
+
+ return !inUsersArray && userId !== 0;
+ })
+ .map(input => {
+ const userId = parseInt(input.value, 10);
+ const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset;
+ return {
+ avatar_url: avatarUrl || avatar_url,
+ id: userId,
+ name,
+ username,
+ can_merge: parseBoolean(canMerge),
};
- tooltipTitle = s__('UsersSelect|Assignee');
- }
- $value.html(assigneeTemplate(user));
- $collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
- return $collapsedSidebar.html(collapsedAssigneeTemplate(user));
- });
- };
- collapsedAssigneeTemplate = _.template(
- '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
- );
- assigneeTemplate = _.template(
- `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
- ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
- openingTag: '<a href="#" class="js-assign-yourself">',
- closingTag: '</a>',
- })}</span> <% } %>`,
- );
- return $dropdown.glDropdown({
- showMenuAbove,
- data(term, callback) {
- return _this.users(term, options, users => {
- // GitLabDropdownFilter returns this.instance
- // GitLabDropdownRemote returns this.options.instance
- const glDropdown = this.instance || this.options.instance;
- glDropdown.options.processData(term, users, callback);
});
- },
- processData(term, data, callback) {
- let users = data;
-
- // Only show assigned user list when there is no search term
- if ($dropdown.hasClass('js-multiselect') && term.length === 0) {
- const selectedInputs = getSelectedUserInputs();
-
- // Potential duplicate entries when dealing with issue board
- // because issue board is also managed by vue
- const selectedUsers = _.uniq(selectedInputs, false, a => a.value)
- .filter(input => {
- const userId = parseInt(input.value, 10);
- const inUsersArray = users.find(u => u.id === userId);
-
- return !inUsersArray && userId !== 0;
- })
- .map(input => {
- const userId = parseInt(input.value, 10);
- const { avatarUrl, avatar_url, name, username, canMerge } = input.dataset;
- return {
- avatar_url: avatarUrl || avatar_url,
- id: userId,
- name,
- username,
- can_merge: parseBoolean(canMerge),
- };
- });
- users = data.concat(selectedUsers);
- }
+ users = data.concat(selectedUsers);
+ }
- let anyUser;
- let index;
- let len;
- let name;
- let obj;
- let showDivider;
- if (term.length === 0) {
- showDivider = 0;
- if (firstUser) {
- // Move current user to the front of the list
- for (index = 0, len = users.length; index < len; index += 1) {
- obj = users[index];
- if (obj.username === firstUser) {
- users.splice(index, 1);
- users.unshift(obj);
- break;
- }
- }
+ let anyUser;
+ let index;
+ let len;
+ let name;
+ let obj;
+ let showDivider;
+ if (term.length === 0) {
+ showDivider = 0;
+ if (firstUser) {
+ // Move current user to the front of the list
+ for (index = 0, len = users.length; index < len; index += 1) {
+ obj = users[index];
+ if (obj.username === firstUser) {
+ users.splice(index, 1);
+ users.unshift(obj);
+ break;
}
- if (showNullUser) {
+ }
+ }
+ if (showNullUser) {
+ showDivider += 1;
+ users.unshift({
+ beforeDivider: true,
+ name: s__('UsersSelect|Unassigned'),
+ id: 0,
+ });
+ }
+ if (showAnyUser) {
+ showDivider += 1;
+ name = showAnyUser;
+ if (name === true) {
+ name = s__('UsersSelect|Any User');
+ }
+ anyUser = {
+ beforeDivider: true,
+ name,
+ id: null,
+ };
+ users.unshift(anyUser);
+ }
+
+ if (showDivider) {
+ users.splice(showDivider, 0, { type: 'divider' });
+ }
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const selected = getSelected().filter(i => i !== 0);
+
+ if (selected.length > 0) {
+ if ($dropdown.data('dropdownHeader')) {
showDivider += 1;
- users.unshift({
- beforeDivider: true,
- name: s__('UsersSelect|Unassigned'),
- id: 0,
+ users.splice(showDivider, 0, {
+ type: 'header',
+ content: $dropdown.data('dropdownHeader'),
});
}
- if (showAnyUser) {
- showDivider += 1;
- name = showAnyUser;
- if (name === true) {
- name = s__('UsersSelect|Any User');
- }
- anyUser = {
- beforeDivider: true,
- name,
- id: null,
- };
- users.unshift(anyUser);
- }
- if (showDivider) {
- users.splice(showDivider, 0, { type: 'divider' });
- }
+ const selectedUsers = users
+ .filter(u => selected.indexOf(u.id) !== -1)
+ .sort((a, b) => a.name > b.name);
- if ($dropdown.hasClass('js-multiselect')) {
- const selected = getSelected().filter(i => i !== 0);
+ users = users.filter(u => selected.indexOf(u.id) === -1);
- if (selected.length > 0) {
- if ($dropdown.data('dropdownHeader')) {
- showDivider += 1;
- users.splice(showDivider, 0, {
- type: 'header',
- content: $dropdown.data('dropdownHeader'),
- });
- }
+ selectedUsers.forEach(selectedUser => {
+ showDivider += 1;
+ users.splice(showDivider, 0, selectedUser);
+ });
- const selectedUsers = users
- .filter(u => selected.indexOf(u.id) !== -1)
- .sort((a, b) => a.name > b.name);
+ users.splice(showDivider + 1, 0, { type: 'divider' });
+ }
+ }
+ }
- users = users.filter(u => selected.indexOf(u.id) === -1);
+ callback(users);
+ if (showMenuAbove) {
+ $dropdown.data('glDropdown').positionMenuAbove();
+ }
+ },
+ filterable: true,
+ filterRemote: true,
+ search: {
+ fields: ['name', 'username'],
+ },
+ selectable: true,
+ fieldName: $dropdown.data('fieldName'),
+ toggleLabel(selected, el, glDropdown) {
+ const inputValue = glDropdown.filterInput.val();
+
+ if (this.multiSelect && inputValue === '') {
+ // Remove non-users from the fullData array
+ const users = glDropdown.filteredFullData();
+ const callback = glDropdown.parseData.bind(glDropdown);
+
+ // Update the data model
+ this.processData(inputValue, users, callback);
+ }
- selectedUsers.forEach(selectedUser => {
- showDivider += 1;
- users.splice(showDivider, 0, selectedUser);
- });
+ if (this.multiSelect) {
+ return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
+ }
- users.splice(showDivider + 1, 0, { type: 'divider' });
- }
- }
- }
+ if (selected && 'id' in selected && $(el).hasClass('is-active')) {
+ $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
+ if (selected.text) {
+ return selected.text;
+ } else {
+ return selected.name;
+ }
+ } else {
+ $dropdown.find('.dropdown-toggle-text').addClass('is-default');
+ return defaultLabel;
+ }
+ },
+ defaultLabel,
+ hidden() {
+ if ($dropdown.hasClass('js-multiselect')) {
+ emitSidebarEvent('sidebar.saveAssignees');
+ }
- callback(users);
- if (showMenuAbove) {
- $dropdown.data('glDropdown').positionMenuAbove();
- }
- },
- filterable: true,
- filterRemote: true,
- search: {
- fields: ['name', 'username'],
- },
- selectable: true,
- fieldName: $dropdown.data('fieldName'),
- toggleLabel(selected, el, glDropdown) {
- const inputValue = glDropdown.filterInput.val();
-
- if (this.multiSelect && inputValue === '') {
- // Remove non-users from the fullData array
- const users = glDropdown.filteredFullData();
- const callback = glDropdown.parseData.bind(glDropdown);
-
- // Update the data model
- this.processData(inputValue, users, callback);
- }
+ if (!$dropdown.data('alwaysShowSelectbox')) {
+ $selectbox.hide();
- if (this.multiSelect) {
- return getMultiSelectDropdownTitle(selected, $(el).hasClass('is-active'));
- }
+ // Recalculate where .value is because vue might have changed it
+ $block = $selectbox.closest('.block');
+ $value = $block.find('.value');
+ // display:block overrides the hide-collapse rule
+ $value.css('display', '');
+ }
+ },
+ multiSelect: $dropdown.hasClass('js-multiselect'),
+ inputMeta: $dropdown.data('inputMeta'),
+ clicked(options) {
+ const { $el, e, isMarking } = options;
+ const user = options.selectedObj;
+
+ $el.tooltip('dispose');
+
+ if ($dropdown.hasClass('js-multiselect')) {
+ const isActive = $el.hasClass('is-active');
+ const previouslySelected = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`);
- if (selected && 'id' in selected && $(el).hasClass('is-active')) {
- $dropdown.find('.dropdown-toggle-text').removeClass('is-default');
- if (selected.text) {
- return selected.text;
- } else {
- return selected.name;
- }
- } else {
- $dropdown.find('.dropdown-toggle-text').addClass('is-default');
- return defaultLabel;
+ // Enables support for limiting the number of users selected
+ // Automatically removes the first on the list if more users are selected
+ checkMaxSelect();
+
+ if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
+ // Unassigned selected
+ previouslySelected.each((index, element) => {
+ element.remove();
+ });
+ emitSidebarEvent('sidebar.removeAllAssignees');
+ } else if (isActive) {
+ // user selected
+ emitSidebarEvent('sidebar.addAssignee', user);
+
+ // Remove unassigned selection (if it was previously selected)
+ const unassignedSelected = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
+
+ if (unassignedSelected) {
+ unassignedSelected.remove();
}
- },
- defaultLabel,
- hidden() {
- if ($dropdown.hasClass('js-multiselect')) {
- emitSidebarEvent('sidebar.saveAssignees');
+ } else {
+ if (previouslySelected.length === 0) {
+ // Select unassigned because there is no more selected users
+ this.addInput($dropdown.data('fieldName'), 0, {});
}
- if (!$dropdown.data('alwaysShowSelectbox')) {
- $selectbox.hide();
+ // User unselected
+ emitSidebarEvent('sidebar.removeAssignee', user);
+ }
- // Recalculate where .value is because vue might have changed it
- $block = $selectbox.closest('.block');
- $value = $block.find('.value');
- // display:block overrides the hide-collapse rule
- $value.css('display', '');
- }
- },
- multiSelect: $dropdown.hasClass('js-multiselect'),
- inputMeta: $dropdown.data('inputMeta'),
- clicked(options) {
- const { $el, e, isMarking } = options;
- const user = options.selectedObj;
-
- $el.tooltip('dispose');
-
- if ($dropdown.hasClass('js-multiselect')) {
- const isActive = $el.hasClass('is-active');
- const previouslySelected = $dropdown
- .closest('.selectbox')
- .find(`input[name='${$dropdown.data('fieldName')}'][value!=0]`);
-
- // Enables support for limiting the number of users selected
- // Automatically removes the first on the list if more users are selected
- checkMaxSelect();
-
- if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
- // Unassigned selected
- previouslySelected.each((index, element) => {
- element.remove();
- });
- emitSidebarEvent('sidebar.removeAllAssignees');
- } else if (isActive) {
- // user selected
- emitSidebarEvent('sidebar.addAssignee', user);
-
- // Remove unassigned selection (if it was previously selected)
- const unassignedSelected = $dropdown
- .closest('.selectbox')
- .find(`input[name='${$dropdown.data('fieldName')}'][value=0]`);
-
- if (unassignedSelected) {
- unassignedSelected.remove();
- }
- } else {
- if (previouslySelected.length === 0) {
- // Select unassigned because there is no more selected users
- this.addInput($dropdown.data('fieldName'), 0, {});
- }
+ if (getSelected().find(u => u === gon.current_user_id)) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ }
- // User unselected
- emitSidebarEvent('sidebar.removeAssignee', user);
- }
+ const page = $('body').attr('data-page');
+ const isIssueIndex = page === 'projects:issues:index';
+ const isMRIndex = page === page && page === 'projects:merge_requests:index';
+ if (
+ $dropdown.hasClass('js-filter-bulk-update') ||
+ $dropdown.hasClass('js-issuable-form-dropdown')
+ ) {
+ e.preventDefault();
- if (getSelected().find(u => u === gon.current_user_id)) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- }
+ const isSelecting = user.id !== selectedId;
+ selectedId = isSelecting ? user.id : selectedIdDefault;
- const page = $('body').attr('data-page');
- const isIssueIndex = page === 'projects:issues:index';
- const isMRIndex = page === page && page === 'projects:merge_requests:index';
- if (
- $dropdown.hasClass('js-filter-bulk-update') ||
- $dropdown.hasClass('js-issuable-form-dropdown')
- ) {
- e.preventDefault();
-
- const isSelecting = user.id !== selectedId;
- selectedId = isSelecting ? user.id : selectedIdDefault;
-
- if (selectedId === gon.current_user_id) {
- $('.assign-to-me-link').hide();
- } else {
- $('.assign-to-me-link').show();
- }
- return;
- }
- if ($el.closest('.add-issues-modal').length) {
- ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
- } else if (handleClick) {
- e.preventDefault();
- handleClick(user, isMarking);
- } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
- return Issuable.filterResults($dropdown.closest('form'));
- } else if ($dropdown.hasClass('js-filter-submit')) {
- return $dropdown.closest('form').submit();
- } else if (!$dropdown.hasClass('js-multiselect')) {
- const selected = $dropdown
- .closest('.selectbox')
- .find(`input[name='${$dropdown.data('fieldName')}']`)
- .val();
- return assignTo(selected);
- }
+ if (selectedId === gon.current_user_id) {
+ $('.assign-to-me-link').hide();
+ } else {
+ $('.assign-to-me-link').show();
+ }
+ return;
+ }
+ if ($el.closest('.add-issues-modal').length) {
+ ModalStore.store.filter[$dropdown.data('fieldName')] = user.id;
+ } else if (handleClick) {
+ e.preventDefault();
+ handleClick(user, isMarking);
+ } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) {
+ return Issuable.filterResults($dropdown.closest('form'));
+ } else if ($dropdown.hasClass('js-filter-submit')) {
+ return $dropdown.closest('form').submit();
+ } else if (!$dropdown.hasClass('js-multiselect')) {
+ const selected = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${$dropdown.data('fieldName')}']`)
+ .val();
+ return assignTo(selected);
+ }
- // Automatically close dropdown after assignee is selected
- // since CE has no multiple assignees
- // EE does not have a max-select
- if (
- $dropdown.data('maxSelect') &&
- getSelected().length === $dropdown.data('maxSelect')
- ) {
- // Close the dropdown
- $dropdown.dropdown('toggle');
- }
- },
- id(user) {
- return user.id;
- },
- opened(e) {
- const $el = $(e.currentTarget);
- const selected = getSelected();
- if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
- this.addInput($dropdown.data('fieldName'), 0, {});
- }
- $el.find('.is-active').removeClass('is-active');
+ // Automatically close dropdown after assignee is selected
+ // since CE has no multiple assignees
+ // EE does not have a max-select
+ if ($dropdown.data('maxSelect') && getSelected().length === $dropdown.data('maxSelect')) {
+ // Close the dropdown
+ $dropdown.dropdown('toggle');
+ }
+ },
+ id(user) {
+ return user.id;
+ },
+ opened(e) {
+ const $el = $(e.currentTarget);
+ const selected = getSelected();
+ if ($dropdown.hasClass('js-issue-board-sidebar') && selected.length === 0) {
+ this.addInput($dropdown.data('fieldName'), 0, {});
+ }
+ $el.find('.is-active').removeClass('is-active');
- function highlightSelected(id) {
- $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
- }
+ function highlightSelected(id) {
+ $el.find(`li[data-user-id="${id}"] .dropdown-menu-user-link`).addClass('is-active');
+ }
- if (selected.length > 0) {
- getSelected().forEach(selectedId => highlightSelected(selectedId));
- } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
- highlightSelected(0);
- } else {
- highlightSelected(selectedId);
- }
- },
- updateLabel: $dropdown.data('dropdownTitle'),
- renderRow(user) {
- const username = user.username ? `@${user.username}` : '';
- const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
+ if (selected.length > 0) {
+ getSelected().forEach(selectedId => highlightSelected(selectedId));
+ } else if ($dropdown.hasClass('js-issue-board-sidebar')) {
+ highlightSelected(0);
+ } else {
+ highlightSelected(selectedId);
+ }
+ },
+ updateLabel: $dropdown.data('dropdownTitle'),
+ renderRow(user) {
+ const username = user.username ? `@${user.username}` : '';
+ const avatar = user.avatar_url ? user.avatar_url : gon.default_avatar_url;
- let selected = false;
+ let selected = false;
- if (this.multiSelect) {
- selected = getSelected().find(u => user.id === u);
+ if (this.multiSelect) {
+ selected = getSelected().find(u => user.id === u);
- const { fieldName } = this;
- const field = $dropdown
- .closest('.selectbox')
- .find(`input[name='${fieldName}'][value='${user.id}']`);
+ const { fieldName } = this;
+ const field = $dropdown
+ .closest('.selectbox')
+ .find(`input[name='${fieldName}'][value='${user.id}']`);
- if (field.length) {
- selected = true;
- }
- } else {
- selected = user.id === selectedId;
- }
+ if (field.length) {
+ selected = true;
+ }
+ } else {
+ selected = user.id === selectedId;
+ }
- let img = '';
- if (user.beforeDivider != null) {
- `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
- user.name,
- )}</a></li>`;
- } else {
- // 0 margin, because it's now handled by a wrapper
- img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
- }
+ let img = '';
+ if (user.beforeDivider != null) {
+ `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${_.escape(
+ user.name,
+ )}</a></li>`;
+ } else {
+ // 0 margin, because it's now handled by a wrapper
+ img = `<img src='${avatar}' class='avatar avatar-inline m-0' width='32' />`;
+ }
- return _this.renderRow(options.issuableType, user, selected, username, img);
- },
- });
- };
- })(this),
- );
+ return userSelect.renderRow(options.issuableType, user, selected, username, img);
+ },
+ });
+ });
import(/* webpackChunkName: 'select2' */ 'select2/select2')
.then(() => {
- $('.ajax-users-select').each(
- (function(_this) {
- return function(i, select) {
- const options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('projectId');
- options.groupId = $(select).data('groupId');
- options.showCurrentUser = $(select).data('currentUser');
- options.authorId = $(select).data('authorId');
- options.skipUsers = $(select).data('skipUsers');
- const showNullUser = $(select).data('nullUser');
- const showAnyUser = $(select).data('anyUser');
- const showEmailUser = $(select).data('emailUser');
- const firstUser = $(select).data('firstUser');
- return $(select).select2({
- placeholder: __('Search for a user'),
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query(query) {
- return _this.users(query.term, options, users => {
- let name;
- const data = {
- results: users,
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- const ref = data.results;
-
- for (let index = 0, len = ref.length; index < len; index += 1) {
- const obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
- }
- }
- }
- if (showNullUser) {
- const nullUser = {
- name: s__('UsersSelect|Unassigned'),
- id: 0,
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = s__('UsersSelect|Any User');
- }
- const anyUser = {
- name,
- id: null,
- };
- data.results.unshift(anyUser);
+ $('.ajax-users-select').each((i, select) => {
+ const options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('projectId');
+ options.groupId = $(select).data('groupId');
+ options.showCurrentUser = $(select).data('currentUser');
+ options.authorId = $(select).data('authorId');
+ options.skipUsers = $(select).data('skipUsers');
+ const showNullUser = $(select).data('nullUser');
+ const showAnyUser = $(select).data('anyUser');
+ const showEmailUser = $(select).data('emailUser');
+ const firstUser = $(select).data('firstUser');
+ return $(select).select2({
+ placeholder: __('Search for a user'),
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query(query) {
+ return userSelect.users(query.term, options, users => {
+ let name;
+ const data = {
+ results: users,
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ const ref = data.results;
+
+ for (let index = 0, len = ref.length; index < len; index += 1) {
+ const obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
}
}
- if (
- showEmailUser &&
- data.results.length === 0 &&
- query.term.match(/^[^@]+@[^@]+$/)
- ) {
- const trimmed = query.term.trim();
- const emailUser = {
- name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
- username: trimmed,
- id: trimmed,
- invite: true,
- };
- data.results.unshift(emailUser);
+ }
+ if (showNullUser) {
+ const nullUser = {
+ name: s__('UsersSelect|Unassigned'),
+ id: 0,
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = s__('UsersSelect|Any User');
}
- return query.callback(data);
- });
- },
- initSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection() {
- const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: 'ajax-users-dropdown',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
+ const anyUser = {
+ name,
+ id: null,
+ };
+ data.results.unshift(anyUser);
+ }
+ }
+ if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
+ const trimmed = query.term.trim();
+ const emailUser = {
+ name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
+ username: trimmed,
+ id: trimmed,
+ invite: true,
+ };
+ data.results.unshift(emailUser);
+ }
+ return query.callback(data);
});
- };
- })(this),
- );
+ },
+ initSelection() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.initSelection.apply(userSelect, args);
+ },
+ formatResult() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.formatResult.apply(userSelect, args);
+ },
+ formatSelection() {
+ const args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return userSelect.formatSelection.apply(userSelect, args);
+ },
+ dropdownCssClass: 'ajax-users-dropdown',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
+ });
})
.catch(() => {});
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
index e03b1e6d6a6..34866cdfa6f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import { __, s__ } from '~/locale';
import DeploymentInfo from './deployment_info.vue';
import DeploymentViewButton from './deployment_view_button.vue';
import DeploymentStopButton from './deployment_stop_button.vue';
@@ -14,9 +14,6 @@ export default {
DeploymentStopButton,
DeploymentViewButton,
},
- directives: {
- GlTooltip: GlTooltipDirective,
- },
props: {
deployment: {
type: Object,
@@ -43,6 +40,14 @@ export default {
},
},
computed: {
+ appButtonText() {
+ return {
+ text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'),
+ tooltip: this.isCurrent
+ ? ''
+ : __('View the latest successful deployment to this environment'),
+ };
+ },
canBeManuallyDeployed() {
return this.computedDeploymentStatus === MANUAL_DEPLOY;
},
@@ -55,9 +60,6 @@ export default {
hasExternalUrls() {
return Boolean(this.deployment.external_url && this.deployment.external_url_formatted);
},
- hasPreviousDeployment() {
- return Boolean(!this.isCurrent && this.deployment.deployed_at);
- },
isCurrent() {
return this.computedDeploymentStatus === SUCCESS;
},
@@ -89,7 +91,7 @@ export default {
<!-- show appropriate version of review app button -->
<deployment-view-button
v-if="hasExternalUrls"
- :is-current="isCurrent"
+ :app-button-text="appButtonText"
:deployment="deployment"
:show-visual-review-app="showVisualReviewApp"
:visual-review-app-metadata="visualReviewAppMeta"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
index 9965e3d5203..18d4073ecd4 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue
@@ -11,12 +11,12 @@ export default {
import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
},
props: {
- deployment: {
+ appButtonText: {
type: Object,
required: true,
},
- isCurrent: {
- type: Boolean,
+ deployment: {
+ type: Object,
required: true,
},
showVisualReviewApp: {
@@ -60,7 +60,7 @@ export default {
>
<template slot="mainAction" slot-scope="slotProps">
<review-app-link
- :is-current="isCurrent"
+ :display="appButtonText"
:link="deploymentExternalUrl"
:css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
/>
@@ -85,7 +85,7 @@ export default {
</filtered-search-dropdown>
<template v-else>
<review-app-link
- :is-current="isCurrent"
+ :display="appButtonText"
:link="deploymentExternalUrl"
css-class="js-deploy-url deploy-link btn btn-default btn-sm inline"
/>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
index 36f291e995c..96603d23374 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_collapsible_extension.vue
@@ -1,12 +1,11 @@
<script>
-import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlButton,
- GlLink,
GlLoadingIcon,
Icon,
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index 1550ec0f21e..c38c41f13b6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -1,18 +1,21 @@
<script>
-import { __ } from '~/locale';
+import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
cssClass: {
type: String,
required: true,
},
- isCurrent: {
- type: Boolean,
+ display: {
+ type: Object,
required: true,
},
link: {
@@ -20,15 +23,12 @@ export default {
required: true,
},
},
- computed: {
- linkText() {
- return this.isCurrent ? __('View app') : __('View previous app');
- },
- },
};
</script>
<template>
<a
+ v-gl-tooltip
+ :title="display.tooltip"
:href="link"
target="_blank"
rel="noopener noreferrer nofollow"
@@ -36,6 +36,6 @@ export default {
data-track-event="open_review_app"
data-track-label="review_app"
>
- {{ linkText }} <icon class="fgray" name="external-link" />
+ {{ display.text }} <icon class="fgray" name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 11bc8c73ee9..75d1e5865b0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -84,7 +84,12 @@ export default {
<span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
<span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</span>
- <button class="btn btn-default btn-sm js-refresh-button" type="button" @click="refresh">
+ <button
+ class="btn btn-default btn-sm js-refresh-button"
+ data-qa-selector="merge_request_error_content"
+ type="button"
+ @click="refresh"
+ >
{{ s__('mrWidget|Refresh now') }}
</button>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
index e9aac8b385c..8f38ca69453 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_missing_branch.vue
@@ -2,7 +2,6 @@
import { sprintf, s__ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import statusIcon from '../mr_widget_status_icon.vue';
-import mrWidgetMergeHelp from '../../components/mr_widget_merge_help.vue';
export default {
name: 'MRWidgetMissingBranch',
@@ -10,7 +9,6 @@ export default {
tooltip,
},
components: {
- mrWidgetMergeHelp,
statusIcon,
},
props: {
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index 75c3c544c77..09cffc57688 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -36,12 +36,17 @@ export default {
required: false,
default: true,
},
+ showChangedStatus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
changedIcon() {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
- const suffix = !this.file.changed && this.file.staged && this.showStagedIcon ? '-solid' : '';
+ const suffix = this.showStagedIcon ? '-solid' : '';
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
@@ -86,8 +91,8 @@ export default {
<span
v-gl-tooltip.right
:title="tooltipTitle"
- :class="{ 'ml-auto': isCentered }"
- class="file-changed-icon d-inline-block"
+ :class="[{ 'ml-auto': isCentered }, changedIconClass]"
+ class="file-changed-icon d-flex align-items-center "
>
<icon v-if="showIcon" :name="changedIcon" :size="size" :class="changedIconClass" />
</span>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 611001df32f..0c9f6ea94d5 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,5 +1,4 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
@@ -9,7 +8,6 @@ export default {
components: {
FileHeader,
FileIcon,
- Icon,
ChangedFileIcon,
},
props: {
@@ -26,6 +24,7 @@ export default {
required: false,
default: null,
},
+
hideExtraOnTree: {
type: Boolean,
required: false,
@@ -143,17 +142,17 @@ export default {
@mouseleave="toggleDropdown(false)"
>
<div class="file-row-name-container">
- <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
+ <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated d-flex">
<file-icon
v-if="!showChangedIcon || file.type === 'tree'"
- class="file-row-icon"
+ class="file-row-icon text-secondary mr-1"
:file-name="file.name"
:loading="file.loading"
:folder="isTree"
:opened="file.opened"
:size="16"
/>
- <changed-file-icon v-else :file="file" :size="16" class="append-right-5" />
+ <file-icon v-else :file-name="file.name" :size="16" css-classes="top mr-1" />
{{ file.name }}
</span>
<component
@@ -163,6 +162,7 @@ export default {
:dropdown-open="dropdownOpen"
@toggle="toggleDropdown($event)"
/>
+ <changed-file-icon :file="file" :size="16" class="append-right-5" />
</div>
</div>
<template v-if="file.opened || file.isHeader">
@@ -172,7 +172,6 @@ export default {
:file="childFile"
:level="childFilesLevel"
:hide-extra-on-tree="hideExtraOnTree"
- :extra-component="extraComponent"
:show-changed-icon="showChangedIcon"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="clickedFile"
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index c652a684d7c..dba4a9231a1 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -117,28 +117,7 @@ export default {
<section v-if="actions.length" class="header-action-buttons">
<template v-for="(action, i) in actions">
- <gl-link
- v-if="action.type === 'link'"
- :key="i"
- :href="action.path"
- :class="action.cssClass"
- >
- {{ action.label }}
- </gl-link>
-
- <gl-link
- v-else-if="action.type === 'ujs-link'"
- :key="i"
- :href="action.path"
- :class="action.cssClass"
- data-method="post"
- rel="nofollow"
- >
- {{ action.label }}
- </gl-link>
-
<loading-button
- v-else-if="action.type === 'button'"
:key="i"
:loading="action.isLoading"
:disabled="action.isLoading"
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
index d1aba99ac22..188ab1769a4 100644
--- a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
@@ -12,6 +12,7 @@ function cleanSuggestionLine(line = {}) {
return {
...line,
text: trimFirstCharOfLineContent(line.text),
+ rich_text: trimFirstCharOfLineContent(line.rich_text),
};
}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 326440f5013..4f5f3ee5cf9 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -20,6 +20,11 @@ export default {
Suggestions,
},
props: {
+ isSubmitting: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
markdownPreviewPath: {
type: String,
required: false,
@@ -133,6 +138,20 @@ export default {
);
},
},
+ watch: {
+ isSubmitting(isSubmitting) {
+ if (!isSubmitting || !this.$refs['markdown-preview'].querySelectorAll) {
+ return;
+ }
+ const mediaInPreview = this.$refs['markdown-preview'].querySelectorAll('video, audio');
+
+ if (mediaInPreview) {
+ mediaInPreview.forEach(media => {
+ media.pause();
+ });
+ }
+ },
+ },
mounted() {
/*
GLForm class handles all the toolbar buttons
@@ -177,7 +196,6 @@ export default {
this.renderMarkdown();
}
},
-
showWriteTab() {
this.markdownPreview = '';
this.previewMarkdown = false;
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index af4ac024e4f..fee5d6d5e3a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -136,7 +136,11 @@ export default {
>
<strong>{{ __('New! Suggest changes directly') }}</strong>
<p class="mb-2">
- {{ __('Suggest code changes which are immediately applied. Try it out!') }}
+ {{
+ __(
+ 'Suggest code changes which can be immediately applied in one click. Try it out!',
+ )
+ }}
</p>
<gl-button variant="primary" size="sm" @click="handleSuggestDismissed">
{{ __('Got it') }}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
index c09bdfec250..97d93eaaf3f 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -24,7 +24,8 @@ export default {
{{ line.new_line }}
</td>
<td class="line_content" :class="lineType">
- <span v-if="line.text">{{ line.text }}</span>
+ <span v-if="line.rich_text" v-html="line.rich_text"></span>
+ <span v-else-if="line.text">{{ line.text }}</span>
<!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE -->
<span v-else>&#8203;</span>
</td>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 7f0fcfac071..20a14d78f9b 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -5,7 +5,6 @@ import SuggestionDiff from './suggestion_diff.vue';
import Flash from '~/flash';
export default {
- components: { SuggestionDiff },
props: {
lineType: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
index e61d1fd2031..e75ac8c54bc 100644
--- a/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue
@@ -12,7 +12,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note note-wrapper">
+ <timeline-entry-item class="note note-wrapper" data-qa-selector="skeleton_note">
<div class="timeline-icon"></div>
<div class="timeline-content">
<div class="note-header"></div>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
index 282b181f11e..f519f90445e 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
@@ -1,10 +1,9 @@
<script>
-import { GlLink, GlTooltip } from '@gitlab/ui';
+import { GlTooltip } from '@gitlab/ui';
export default {
components: {
GlTooltip,
- GlLink,
},
props: {
label: {
diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue
index 9aacde49264..f02b412e8a1 100644
--- a/app/assets/javascripts/vue_shared/components/split_button.vue
+++ b/app/assets/javascripts/vue_shared/components/split_button.vue
@@ -49,6 +49,10 @@ export default {
triggerEvent() {
this.$emit(this.selectedItem.eventName);
},
+ changeSelectedItem(item) {
+ this.selectedItem = item;
+ this.$emit('change', item);
+ },
},
};
</script>
@@ -67,7 +71,7 @@ export default {
:key="item.eventName"
:active="selectedItem === item"
active-class="is-active"
- @click="selectedItem = item"
+ @click="changeSelectedItem(item)"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
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 4a72cca5f02..37e3643bf6c 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
@@ -30,11 +30,16 @@ export default {
},
computed: {
statusHtml() {
+ if (!this.user.status) {
+ return '';
+ }
+
if (this.user.status.emoji && this.user.status.message_html) {
return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`;
} else if (this.user.status.message_html) {
return this.user.status.message_html;
}
+
return '';
},
nameIsLoading() {
@@ -97,7 +102,9 @@ export default {
class="animation-container-small mb-1"
/>
</div>
- <div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div>
+ <div v-if="statusHtml" class="js-user-status mt-2">
+ <span v-html="statusHtml"></span>
+ </div>
</div>
</div>
</gl-popover>
diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js
new file mode 100644
index 00000000000..817a90f8149
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/bindings.js
@@ -0,0 +1,36 @@
+/**
+ * Returns computed properties two way bound to vuex
+ *
+ * @param {(string[]|Object[])} list - list of string matching state keys or list objects
+ * @param {string} list[].key - the key matching the key present in the vuex state
+ * @param {string} list[].getter - the name of the getter, leave it empty to not use a getter
+ * @param {string} list[].updateFn - the name of the action, leave it empty to use the default action
+ * @param {string} defaultUpdateFn - the default function to dispatch
+ * @param {string} root - the key of the state where to search fo they keys described in list
+ * @returns {Object} a dictionary with all the computed properties generated
+ */
+export const mapComputed = (list, defaultUpdateFn, root) => {
+ const result = {};
+ list.forEach(item => {
+ const [getter, key, updateFn] =
+ typeof item === 'string'
+ ? [false, item, defaultUpdateFn]
+ : [item.getter, item.key, item.updateFn || defaultUpdateFn];
+ result[key] = {
+ get() {
+ if (getter) {
+ return this.$store.getters[getter];
+ } else if (root) {
+ return this.$store.state[root][key];
+ }
+ return this.$store.state[key];
+ },
+ set(value) {
+ this.$store.dispatch(updateFn, { [key]: value });
+ },
+ };
+ });
+ return result;
+};
+
+export default () => {};
diff --git a/app/assets/stylesheets/test.scss b/app/assets/stylesheets/disable_animations.scss
index e65b49c36f3..e65b49c36f3 100644
--- a/app/assets/stylesheets/test.scss
+++ b/app/assets/stylesheets/disable_animations.scss
diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss
index d3e7d751e63..95ea3d90a0e 100644
--- a/app/assets/stylesheets/framework/broadcast_messages.scss
+++ b/app/assets/stylesheets/framework/broadcast_messages.scss
@@ -1,7 +1,5 @@
.broadcast-message {
- @extend .alert-warning;
- padding: 10px;
- text-align: center;
+ padding: $gl-padding-8;
div,
p {
@@ -15,9 +13,29 @@
}
}
-.broadcast-message-preview {
+.broadcast-banner-message {
+ @extend .broadcast-message;
+ @extend .alert-warning;
+ text-align: center;
+}
+
+.broadcast-notification-message {
@extend .broadcast-message;
- margin-bottom: 20px;
+
+ position: fixed;
+ bottom: $gl-padding;
+ right: $gl-padding;
+ max-width: 300px;
+ width: auto;
+ background: $white-light;
+ border: 1px solid $gray-200;
+ box-shadow: 0 1px 2px 0 rgba($black, 0.1);
+ border-radius: $border-radius-default;
+ z-index: 999;
+
+ &.preview {
+ position: static;
+ }
}
.toggle-colors {
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index 767832e242c..1b549c0a4f0 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -29,7 +29,7 @@
&:focus,
&:active {
background-color: $btn-active-gray;
- box-shadow: $gl-btn-active-background;
+ box-shadow: none;
}
}
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index 4b7dda3a2ff..dc119b52f4e 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -55,6 +55,10 @@
background-color: $gray-light;
}
+.bg-white {
+ background-color: $white;
+}
+
.bg-line-target-blue {
background: $line-target-blue;
}
@@ -456,6 +460,8 @@ img.emoji {
.w-8em { width: 8em; }
.w-3rem { width: 3rem; }
.w-15p { width: 15%; }
+.w-30p { width: 30%; }
+.w-60p { width: 60%; }
.w-70p { width: 70%; }
.h-12em { height: 12em; }
@@ -573,6 +579,7 @@ img.emoji {
.gl-font-size-large { font-size: $gl-font-size-large; }
.gl-line-height-24 { line-height: $gl-line-height-24; }
+.gl-line-height-14 { line-height: $gl-line-height-14; }
.gl-font-size-12 { font-size: $gl-font-size-12; }
.gl-font-size-14 { font-size: $gl-font-size-14; }
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 8e0314bc6da..1a017f03ebb 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -207,6 +207,14 @@
border-left-color: mix($blame-gray, $blame-cyan, $i / 4 * 100%);
}
}
+
+ .doc-versions {
+ color: $gray-600;
+
+ &:hover {
+ color: $gray-900;
+ }
+ }
}
&.logs {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 1c252584047..b5d1c3f6732 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -88,6 +88,7 @@
}
.name,
+ .operator,
.value {
display: inline-block;
padding: 2px 7px;
@@ -101,6 +102,12 @@
text-transform: capitalize;
}
+ .operator {
+ background-color: $white-normal;
+ color: $filter-value-text-color;
+ margin-right: 1px;
+ }
+
.value-container {
display: flex;
align-items: center;
@@ -147,6 +154,10 @@
background-color: $filter-name-selected-color;
}
+ .operator {
+ box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
+ }
+
.value-container {
box-shadow: inset 0 0 0 100px $filtered-search-term-shadow-color;
}
@@ -260,6 +271,11 @@
max-width: none;
min-width: 100%;
}
+
+ .btn-helptext {
+ margin-left: auto;
+ color: var(--gray);
+ }
}
.filtered-search-history-dropdown-wrapper {
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 58516cbd1a9..ee6e53adaf7 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -8,7 +8,7 @@
pre {
padding: 10px 0;
border: 0;
- border-radius: 0 0 $border-radius-default $border-radius-default;
+ border-radius: 0 0 $border-radius-default;
font-family: $monospace-font;
font-size: $code-font-size;
line-height: 1.5;
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index a53f5d85949..9ae313db4c1 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -20,6 +20,7 @@
}
.ci-status-icon-pending,
+.ci-status-icon-waiting-for-resource,
.ci-status-icon-failed-with-warnings,
.ci-status-icon-success-with-warnings {
svg {
diff --git a/app/assets/stylesheets/framework/job_log.scss b/app/assets/stylesheets/framework/job_log.scss
index 4a57a458c50..fefc51bf1f7 100644
--- a/app/assets/stylesheets/framework/job_log.scss
+++ b/app/assets/stylesheets/framework/job_log.scss
@@ -22,6 +22,7 @@
min-width: $job-line-number-width;
margin-left: -$job-line-number-margin;
padding-right: 1em;
+ user-select: none;
&:hover,
&:active,
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index 4aba633e182..738150dbd2e 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -101,6 +101,13 @@ ul.unstyled-list > li {
border-bottom: 0;
}
+ul.list-items-py-2 {
+ > li {
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ }
+}
+
// Generic content list
ul.content-list {
@include basic-list;
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index bf0f1da6aa3..d54648cc34b 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -271,18 +271,13 @@
}
.btn-scroll.animate {
- .first-triangle {
- animation: blinking-scroll-button 1s ease infinite;
- animation-delay: 0.3s;
- }
-
- .second-triangle {
- animation: blinking-scroll-button 1s ease infinite;
- animation-delay: 0.2s;
+ .scroll-arrow {
+ animation: blinking-scroll-button 1.5s ease-in-out infinite;
}
- .third-triangle {
- animation: blinking-scroll-button 1s ease infinite;
+ .scroll-dot {
+ animation: blinking-scroll-button 1.5s ease-in-out infinite;
+ animation-delay: 0.3s;
}
&:disabled {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 39e7e4bb7e5..a1bfa03a5ac 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -179,15 +179,15 @@
tbody {
background-color: $white-light;
+
+ td {
+ border-color: $gray-200;
+ }
}
tr {
th {
- border-bottom: solid 2px $gl-gray-200;
- }
-
- td {
- border-color: $gl-gray-200;
+ border-bottom: solid 2px $gray-300;
}
}
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index 604b48e11ab..7538459c97b 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -12,6 +12,7 @@ $font-family-sans-serif: $regular-font;
$font-family-monospace: $monospace-font;
$btn-line-height: 20px;
$table-accent-bg: $gray-light;
+$table-border-color: $gray-200;
$card-border-color: $border-color;
$card-cap-bg: $gray-light;
$success: $green-500;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 977fc8329b6..420271c9a1e 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -740,6 +740,7 @@ $ide-commit-header-height: 48px;
.ide-sidebar-link {
display: flex;
align-items: center;
+ justify-content: center;
position: relative;
height: 60px;
width: 100%;
@@ -1076,10 +1077,12 @@ $ide-commit-header-height: 48px;
}
}
-.ide-right-sidebar {
+.ide-sidebar {
width: auto;
min-width: 60px;
+}
+.ide-right-sidebar {
.ide-activity-bar {
border-left: 1px solid $white-dark;
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 90c2e369ccd..31e87d1a7cf 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -133,6 +133,7 @@
.issue-count-badge {
border: 0;
white-space: nowrap;
+ padding: 0;
}
.board-title-text > span,
@@ -385,22 +386,19 @@
margin: 5px;
}
-.issue-boards-sidebar {
+.right-sidebar.issue-boards-sidebar {
.gutter-toggle {
bottom: 15px;
width: 22px;
- color: $gray-darkest;
+ padding-left: $gl-padding-32;
svg {
position: absolute;
top: 50%;
+ right: 0;
margin-top: (-11px / 2);
- }
-
- &:hover {
- path {
- fill: $gray-darkest;
- }
+ height: $gl-font-size-12;
+ width: $gl-font-size-12;
}
}
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index c7d51a2093a..0db90fc88fc 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -32,16 +32,12 @@
opacity: 0.2;
}
- 25% {
- opacity: 0.5;
- }
-
50% {
- opacity: 0.7;
+ opacity: 1;
}
100% {
- opacity: 1;
+ opacity: 0.2;
}
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index f394e4ab58a..d1053570093 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -14,9 +14,9 @@
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
- // The `-1` below is to prevent two borders from clashing up against eachother -
+ // The `+11` is to ensure the file header border shows when scrolled -
// the bottom of the compare-versions header and the top of the file header
- $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1;
+ $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height + 11;
position: -webkit-sticky;
position: sticky;
@@ -552,7 +552,7 @@ table.code {
.diff-stats {
align-items: center;
- padding: 0 0.25rem;
+ padding: 0 1rem;
.diff-stats-group {
padding: 0 0.25rem;
@@ -564,7 +564,7 @@ table.code {
&.is-compare-versions-header {
.diff-stats-group {
- padding: 0 0.5rem;
+ padding: 0 0.25rem;
}
}
}
@@ -1059,8 +1059,8 @@ table.code {
.diff-tree-list {
position: -webkit-sticky;
position: sticky;
- $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
- top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
+ $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px;
+ top: $header-height + $mr-tabs-height + $mr-version-controls-height + 11px;
max-height: calc(100vh - #{$top-pos});
z-index: 202;
@@ -1097,10 +1097,7 @@ table.code {
.tree-list-scroll {
max-height: 100%;
- padding-top: $grid-size;
padding-bottom: $grid-size;
- border-top: 1px solid $border-color;
- border-bottom: 1px solid $border-color;
overflow-y: scroll;
overflow-x: auto;
}
diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss
index dcd25c126c4..61e2df7ea26 100644
--- a/app/assets/stylesheets/pages/error_details.scss
+++ b/app/assets/stylesheets/pages/error_details.scss
@@ -2,6 +2,11 @@
li {
@include gl-line-height-32;
}
+
+ .btn-outline-info {
+ color: $blue-500;
+ border-color: $blue-500;
+ }
}
.stacktrace {
diff --git a/app/assets/stylesheets/pages/error_list.scss b/app/assets/stylesheets/pages/error_list.scss
new file mode 100644
index 00000000000..f97953ce824
--- /dev/null
+++ b/app/assets/stylesheets/pages/error_list.scss
@@ -0,0 +1,69 @@
+$gray-border: 1px solid $border-color;
+
+.error-list {
+ .sort-control {
+ .btn {
+ padding-right: 2rem;
+ }
+
+ .gl-dropdown-caret {
+ position: absolute;
+ right: 0.5rem;
+ top: 0.5rem;
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
+ .row-top {
+ border: $gray-border;
+ background-color: $gray-50;
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .table-row {
+ border: $gray-border;
+ border-radius: 4px;
+ }
+
+ .search-box {
+ border-top: $gray-border;
+ border-bottom: $gray-border;
+ background-color: $gray-50;
+ }
+
+ .table-col {
+ min-height: 68px;
+
+ &::before {
+ text-align: left !important;
+ }
+
+ &:first-child {
+ div {
+ padding: 0 !important;
+ align-items: flex-end;
+ }
+ }
+
+ &:last-child {
+ height: 64px;
+ background-color: $gray-normal;
+
+ &::before {
+ content: none !important;
+ }
+
+ div {
+ width: 100% !important;
+ padding: 0 !important;
+
+ a {
+ color: $blue-500;
+ border-color: $blue-500;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 09b335f9ba2..43636f65eb8 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -173,6 +173,20 @@
margin-top: 7px;
}
+ .gutter-toggle {
+ margin-left: 20px;
+ padding-left: 10px;
+
+ &:hover {
+ color: $gl-text-color;
+ }
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+ }
+
.block {
@include clearfix;
padding: $gl-padding 0;
@@ -195,20 +209,6 @@
margin-top: 0;
}
- .gutter-toggle {
- margin-left: 20px;
- padding-left: 10px;
-
- &:hover {
- color: $gl-text-color;
- }
-
- &:hover,
- &:focus {
- text-decoration: none;
- }
- }
-
&.assignee {
.author-link {
display: block;
@@ -288,7 +288,7 @@
}
.issuable-sidebar {
- width: calc(100% + 100px);
+ width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: hidden;
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index ae92a2fbd7b..54bca80194f 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -3,7 +3,7 @@
border-bottom: 1px solid $border-color;
}
-.users-project-form {
+.invite-users-form {
.btn-success {
margin-right: 10px;
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index c023c9e5cbd..84daec4fb43 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -708,7 +708,7 @@
.mr-version-controls {
position: relative;
z-index: 203;
- background: $gray-light;
+ background: $white-light;
color: $gl-text-color;
margin-top: -1px;
@@ -732,7 +732,7 @@
}
.content-block {
- padding: $gl-padding-top $gl-padding;
+ padding: $gl-padding;
border-bottom: 0;
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index 364fe3da71e..82bef91230e 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -795,6 +795,7 @@
}
&.ci-status-icon-pending,
+ &.ci-status-icon-waiting-for-resource,
&.ci-status-icon-success-with-warnings {
@include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700);
}
@@ -1092,3 +1093,7 @@ button.mini-pipeline-graph-dropdown-toggle {
.progress-bar.bg-primary {
background-color: $blue-500 !important;
}
+
+.parent-child-label-container {
+ padding-top: $gl-padding-4;
+}
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 5d6a4b7cd13..4f3d6fb0d44 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -42,6 +42,7 @@
}
&.ci-pending,
+ &.ci-waiting-for-resource,
&.ci-failed-with-warnings,
&.ci-success-with-warnings {
@include status-color($orange-100, $orange-500, $orange-700);
diff --git a/app/assets/stylesheets/snippets.scss b/app/assets/stylesheets/snippets.scss
index bd777c66b56..0008a0e5c51 100644
--- a/app/assets/stylesheets/snippets.scss
+++ b/app/assets/stylesheets/snippets.scss
@@ -11,7 +11,7 @@
line-height: $code-line-height;
color: $gl-text-color;
margin: 20px;
- font-weight: 200;
+ font-weight: $gl-font-weight-normal;
.gl-snippet-icon {
display: inline-block;
@@ -34,7 +34,7 @@
.file-content.code {
border: $border-style;
- border-radius: 0 0 4px 4px;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
display: flex;
box-shadow: none;
margin: 0;
@@ -45,12 +45,10 @@
overflow-x: auto;
pre {
+ height: 100%;
padding: 10px;
border: 0;
border-radius: 0;
- font-family: $monospace-font;
- font-size: $code-font-size;
- line-height: $code-line-height;
margin: 0;
overflow: auto;
overflow-y: hidden;
@@ -58,6 +56,12 @@
word-wrap: normal;
border-left: $border-style;
}
+
+ code {
+ font-family: $monospace-font;
+ font-size: $code-font-size;
+ line-height: $code-line-height;
+ }
}
.line-numbers {
@@ -107,17 +111,13 @@
}
}
- .gitlab-logo {
- display: inline-block;
- padding-left: 5px;
- text-decoration: none;
- color: $gl-text-color-secondary;
+ .gitlab-logo-wrapper {
+ padding-left: $gl-padding-8;
+ position: relative;
+ top: 2px;
- .logo-text {
- background: image_url('ext_snippet_icons/logo.png') no-repeat left center;
- background-size: 18px;
- font-weight: $gl-font-weight-normal;
- padding-left: 24px;
+ .gitlab-logo {
+ height: 18px;
}
}
}
@@ -125,7 +125,7 @@
img,
.gl-snippet-icon {
display: inline-block;
- vertical-align: middle;
+ vertical-align: text-bottom;
}
}
@@ -133,7 +133,7 @@
a.btn {
background-color: $white-light;
text-decoration: none;
- padding: 7px 9px;
+ padding: 8px 9px;
border: $border-style;
border-right: 0;
@@ -144,11 +144,11 @@
}
&:first-child {
- border-radius: 3px 0 0 3px;
+ border-radius: $border-radius-default 0 0 $border-radius-default;
}
&:last-child {
- border-radius: 0 3px 3px 0;
+ border-radius: 0 $border-radius-default $border-radius-default 0;
border-right: $border-style;
}
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
index 1f4bba5fc33..1517015dda0 100644
--- a/app/assets/stylesheets/utilities.scss
+++ b/app/assets/stylesheets/utilities.scss
@@ -22,11 +22,20 @@
}
}
+@each $index, $size in $type-scale {
+ #{'.lh-#{$index}'} {
+ line-height: $size;
+ }
+}
+
.border-width-1px { border-width: 1px; }
+.border-bottom-width-1px { border-bottom-width: 1px; }
.border-style-dashed { border-style: dashed; }
.border-style-solid { border-style: solid; }
+.border-bottom-style-solid { border-bottom-style: solid; }
.border-color-blue-300 { border-color: $blue-300; }
.border-color-default { border-color: $border-color; }
+.border-bottom-color-default { border-bottom-color: $border-color; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
.mh-50vh { max-height: 50vh; }
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
index ccf3824ea56..37ef52f9573 100644
--- a/app/assets/stylesheets/vendors/atwho.scss
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -23,9 +23,9 @@
}
.has-warning {
- .name,
.description {
color: $orange-700;
+ background-color: $orange-100;
}
}
@@ -59,7 +59,6 @@
&.has-warning {
color: $orange-700;
- background-color: $orange-100;
}
}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 9d81d3fad07..3047ee02680 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -5,11 +5,28 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
before_action :set_application_setting
before_action :whitelist_query_limiting, only: [:usage_data]
+ before_action :validate_self_monitoring_feature_flag_enabled, only: [
+ :create_self_monitoring_project,
+ :status_create_self_monitoring_project,
+ :delete_self_monitoring_project,
+ :status_delete_self_monitoring_project
+ ]
+
+ before_action do
+ push_frontend_feature_flag(:self_monitoring_project)
+ end
VALID_SETTING_PANELS = %w(general integrations repository
ci_cd reporting metrics_and_profiling
network preferences).freeze
+ # The current size of a sidekiq job's jid is 24 characters. The size of the
+ # jid is an internal detail of Sidekiq, and they do not guarantee that it'll
+ # stay the same. We chose 50 to give us room in case the size of the jid
+ # increases. The jid is alphanumeric, so 50 is very generous. There is a spec
+ # that ensures that the constant value is more than the size of an actual jid.
+ PARAM_JOB_ID_MAX_SIZE = 50
+
VALID_SETTING_PANELS.each do |action|
define_method(action) { perform_update if submitted? }
end
@@ -62,8 +79,103 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
end
+ def create_self_monitoring_project
+ job_id = SelfMonitoringProjectCreateWorker.perform_async
+
+ render status: :accepted, json: {
+ job_id: job_id,
+ monitor_status: status_create_self_monitoring_project_admin_application_settings_path
+ }
+ end
+
+ def status_create_self_monitoring_project
+ job_id = params[:job_id].to_s
+
+ unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
+ return render status: :bad_request, json: {
+ message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' %
+ { job_id_max_size: PARAM_JOB_ID_MAX_SIZE })
+ }
+ end
+
+ if Gitlab::CurrentSettings.instance_administration_project_id.present?
+ return render status: :ok, json: self_monitoring_data
+
+ elsif SelfMonitoringProjectCreateWorker.in_progress?(job_id)
+ ::Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ return render status: :accepted, json: {
+ message: _('Job to create self-monitoring project is in progress')
+ }
+ end
+
+ render status: :bad_request, json: {
+ message: _('Self-monitoring project does not exist. Please check logs ' \
+ 'for any error messages')
+ }
+ end
+
+ def delete_self_monitoring_project
+ job_id = SelfMonitoringProjectDeleteWorker.perform_async
+
+ render status: :accepted, json: {
+ job_id: job_id,
+ monitor_status: status_delete_self_monitoring_project_admin_application_settings_path
+ }
+ end
+
+ def status_delete_self_monitoring_project
+ job_id = params[:job_id].to_s
+
+ unless job_id.length <= PARAM_JOB_ID_MAX_SIZE
+ return render status: :bad_request, json: {
+ message: _('Parameter "job_id" cannot exceed length of %{job_id_max_size}' %
+ { job_id_max_size: PARAM_JOB_ID_MAX_SIZE })
+ }
+ end
+
+ if Gitlab::CurrentSettings.instance_administration_project_id.nil?
+ return render status: :ok, json: {
+ message: _('Self-monitoring project has been successfully deleted')
+ }
+
+ elsif SelfMonitoringProjectDeleteWorker.in_progress?(job_id)
+ ::Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ return render status: :accepted, json: {
+ message: _('Job to delete self-monitoring project is in progress')
+ }
+ end
+
+ render status: :bad_request, json: {
+ message: _('Self-monitoring project was not deleted. Please check logs ' \
+ 'for any error messages')
+ }
+ end
+
private
+ def validate_self_monitoring_feature_flag_enabled
+ self_monitoring_project_not_implemented unless Feature.enabled?(:self_monitoring_project)
+ end
+
+ def self_monitoring_data
+ {
+ project_id: Gitlab::CurrentSettings.instance_administration_project_id,
+ project_full_path: Gitlab::CurrentSettings.instance_administration_project&.full_path
+ }
+ end
+
+ def self_monitoring_project_not_implemented
+ render(
+ status: :not_implemented,
+ json: {
+ message: _('Self-monitoring is not enabled on this GitLab server, contact your administrator.'),
+ documentation_url: help_page_path('administration/monitoring/gitlab_instance_administration_project/index')
+ }
+ )
+ end
+
def set_application_setting
@application_setting = ApplicationSetting.current_without_cache
end
diff --git a/app/controllers/admin/system_info_controller.rb b/app/controllers/admin/system_info_controller.rb
index 244fc2b31bb..657aa177ecf 100644
--- a/app/controllers/admin/system_info_controller.rb
+++ b/app/controllers/admin/system_info_controller.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
class Admin::SystemInfoController < Admin::ApplicationController
- EXCLUDED_MOUNT_OPTIONS = [
- 'nobrowse',
- 'read-only',
- 'ro'
+ EXCLUDED_MOUNT_OPTIONS = %w[
+ nobrowse
+ read-only
+ ro
].freeze
EXCLUDED_MOUNT_TYPES = [
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index f5306801c04..60b5d9b6da8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -33,6 +33,7 @@ class ApplicationController < ActionController::Base
before_action :check_impersonation_availability
before_action :required_signup_info
+ around_action :set_current_context
around_action :set_locale
around_action :set_session_storage
@@ -448,6 +449,14 @@ class ApplicationController < ActionController::Base
request.base_url
end
+ def set_current_context(&block)
+ Gitlab::ApplicationContext.with_context(
+ user: -> { auth_user },
+ project: -> { @project },
+ namespace: -> { @group },
+ &block)
+ end
+
def set_locale(&block)
Gitlab::I18n.with_user_locale(current_user, &block)
end
diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb
index 1298b33471b..1d6711e3c22 100644
--- a/app/controllers/boards/issues_controller.rb
+++ b/app/controllers/boards/issues_controller.rb
@@ -27,17 +27,7 @@ module Boards
issues = list_service.execute
issues = issues.page(params[:page]).per(params[:per] || 20).without_count
Issue.move_nulls_to_end(issues) if Gitlab::Database.read_write?
- issues = issues.preload(:milestone,
- :assignees,
- project: [
- :route,
- {
- namespace: [:route]
- }
- ],
- labels: [:priorities],
- notes: [:award_emoji, :author]
- )
+ issues = issues.preload(associations_to_preload)
render_issues(issues, list_service.metadata)
end
@@ -74,6 +64,21 @@ module Boards
private
+ def associations_to_preload
+ [
+ :milestone,
+ :assignees,
+ project: [
+ :route,
+ {
+ namespace: [:route]
+ }
+ ],
+ labels: [:priorities],
+ notes: [:award_emoji, :author]
+ ]
+ end
+
def can_move_issues?
head(:forbidden) unless can?(current_user, :admin_issue, board)
end
@@ -90,7 +95,7 @@ module Boards
end
def filter_params
- params.merge(board_id: params[:board_id], id: params[:list_id])
+ params.permit(*Boards::Issues::ListService.valid_params).merge(board_id: params[:board_id], id: params[:list_id])
.reject { |_, value| value.nil? }
end
@@ -139,3 +144,5 @@ module Boards
end
end
end
+
+Boards::IssuesController.prepend_if_ee('EE::Boards::IssuesController')
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index be68d0d0a1d..51ddbe2beb4 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController
end
def cluster_application_params
- params.permit(:application, :hostname, :kibana_hostname, :email, :stack)
+ params.permit(:application, :hostname, :email, :stack, :modsecurity_enabled)
end
def cluster_application_destroy_params
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index f4b74b14c0b..52a5f801bad 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -14,7 +14,6 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :update_applications_status, only: [:cluster_status]
before_action only: [:show] do
push_frontend_feature_flag(:enable_cluster_application_elastic_stack)
- push_frontend_feature_flag(:enable_cluster_application_crossplane)
end
helper_method :token_in_session
diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb
index a394ef9a2b8..4013596ba12 100644
--- a/app/controllers/concerns/record_user_last_activity.rb
+++ b/app/controllers/concerns/record_user_last_activity.rb
@@ -21,7 +21,7 @@ module RecordUserLastActivity
return if Gitlab::Database.read_only?
if current_user && current_user.last_activity_on != Date.today
- Users::ActivityService.new(current_user, "visited #{request.path}").execute
+ Users::ActivityService.new(current_user).execute
end
end
end
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
index 2e9905997db..c92b1cecaaa 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -18,7 +18,7 @@ module RequiresWhitelistedMonitoringClient
# debugging purposes
return true if Rails.env.development? && request.local?
- ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) }
+ ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.instance.client_ip) }
end
def ip_whitelist
diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_decorator.rb
index 01925cf9d4d..5ef09b9221f 100644
--- a/app/controllers/concerns/sourcegraph_gon.rb
+++ b/app/controllers/concerns/sourcegraph_decorator.rb
@@ -1,10 +1,19 @@
# frozen_string_literal: true
-module SourcegraphGon
+module SourcegraphDecorator
extend ActiveSupport::Concern
included do
before_action :push_sourcegraph_gon, if: :html_request?
+
+ content_security_policy do |p|
+ next if p.directives.blank?
+ next unless Gitlab::CurrentSettings.sourcegraph_enabled
+
+ default_connect_src = p.directives['connect-src'] || p.directives['default-src']
+ connect_src_values = Array.wrap(default_connect_src) | [Gitlab::CurrentSettings.sourcegraph_url]
+ p.connect_src(*connect_src_values)
+ end
end
private
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index a8ffa33f1c7..9ec8f930a78 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -11,7 +11,7 @@ module SpammableActions
end
def mark_as_spam
- if SpamService.new(spammable).mark_as_spam!
+ if Spam::MarkAsSpamService.new(spammable: spammable).execute
redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase }
else
redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.')
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 72d40f709e6..d7ff2ded5ae 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -3,6 +3,7 @@
class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
+ skip_around_action :set_session_storage
# Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing,
# the user won't be authenticated but can proceed as an anonymous user.
diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb
index 7965311c5f1..d3360acd245 100644
--- a/app/controllers/groups/group_links_controller.rb
+++ b/app/controllers/groups/group_links_controller.rb
@@ -3,6 +3,7 @@
class Groups::GroupLinksController < Groups::ApplicationController
before_action :check_feature_flag!
before_action :authorize_admin_group!
+ before_action :group_link, only: [:update, :destroy]
def create
shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present?
@@ -22,13 +23,36 @@ class Groups::GroupLinksController < Groups::ApplicationController
redirect_to group_group_members_path(group)
end
+ def update
+ @group_link.update(group_link_params)
+ end
+
+ def destroy
+ Groups::GroupLinks::DestroyService.new(nil, nil).execute(@group_link)
+
+ respond_to do |format|
+ format.html do
+ redirect_to group_group_members_path(group), status: :found
+ end
+ format.js { head :ok }
+ end
+ end
+
private
+ def group_link
+ @group_link ||= group.shared_with_group_links.find(params[:id])
+ end
+
def group_link_create_params
params.permit(:shared_group_access, :expires_at)
end
+ def group_link_params
+ params.require(:group_link).permit(:group_access, :expires_at)
+ end
+
def check_feature_flag!
- render_404 unless Feature.enabled?(:share_group_with_group)
+ render_404 unless Feature.enabled?(:share_group_with_group, default_enabled: true)
end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index dcdf9aced1a..d1eed85fde6 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -20,28 +20,17 @@ class Groups::GroupMembersController < Groups::ApplicationController
:override
def index
- can_manage_members = can?(current_user, :admin_group_member, @group)
-
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id]
@members = find_members
if can_manage_members
- @invited_members = @members.invite
- @invited_members = @invited_members.search_invite_email(params[:search_invited]) if params[:search_invited].present?
- @invited_members = present_members(@invited_members.page(params[:invited_members_page]).per(MEMBER_PER_PAGE_LIMIT))
+ @skip_groups = @group.related_group_ids
+ @invited_members = present_invited_members(@members)
end
@members = @members.non_invite
- @members = @members.search(params[:search]) if params[:search].present?
- @members = @members.sort_by_attribute(@sort)
-
- if can_manage_members && params[:two_factor].present?
- @members = @members.filter_by_2fa(params[:two_factor])
- end
-
- @members = @members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT)
- @members = present_members(@members)
+ @members = present_group_members(@members)
@requesters = present_members(
AccessRequestsFinder.new(@group).execute(current_user))
@@ -54,8 +43,30 @@ class Groups::GroupMembersController < Groups::ApplicationController
private
+ def present_invited_members(members)
+ invited_members = members.invite
+
+ if params[:search_invited].present?
+ invited_members = invited_members.search_invite_email(params[:search_invited])
+ end
+
+ present_members(invited_members
+ .page(params[:invited_members_page])
+ .per(MEMBER_PER_PAGE_LIMIT))
+ end
+
def find_members
- GroupMembersFinder.new(@group).execute(include_relations: requested_relations)
+ filter_params = params.slice(:two_factor, :search).merge(sort: @sort)
+ GroupMembersFinder.new(@group, current_user).execute(include_relations: requested_relations, params: filter_params)
+ end
+
+ def can_manage_members
+ can?(current_user, :admin_group_member, @group)
+ end
+
+ def present_group_members(original_members)
+ members = original_members.page(params[:page]).per(MEMBER_PER_PAGE_LIMIT)
+ present_members(members)
end
end
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index 1e9d51cf970..7eba73daa3c 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -119,7 +119,9 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def search_params
- params.permit(:state, :search_title).merge(group_ids: group.id)
+ groups = request.format.json? ? group.self_and_ancestors.select(:id) : group.id
+
+ params.permit(:state, :search_title).merge(group_ids: groups)
end
end
diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb
index 4c9aac9a327..ca35b07111c 100644
--- a/app/controllers/ide_controller.rb
+++ b/app/controllers/ide_controller.rb
@@ -3,6 +3,10 @@
class IdeController < ApplicationController
layout 'fullscreen'
+ before_action do
+ push_frontend_feature_flag(:stage_all_by_default, default_enabled: true)
+ end
+
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 92f36c031f1..bc3308fd6c6 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -31,7 +31,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
# Extend the standard message generation to accept our custom exception
def failure_message
exception = request.env["omniauth.error"]
- error = exception.error_reason if exception.respond_to?(:error_reason)
+ error = exception.error_reason if exception.respond_to?(:error_reason)
error ||= exception.error if exception.respond_to?(:error)
error ||= exception.message if exception.respond_to?(:message)
error ||= request.env["omniauth.error.type"].to_s
@@ -177,7 +177,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label }
end
- flash[:notice] = message.join(' ')
+ flash[:alert] = message.join(' ')
redirect_to new_user_session_path
end
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index c473023cacb..f409193aefc 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -4,4 +4,13 @@ class Profiles::ActiveSessionsController < Profiles::ApplicationController
def index
@sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
end
+
+ def destroy
+ ActiveSession.destroy_with_public_id(current_user, params[:id])
+
+ respond_to do |format|
+ format.html { redirect_to profile_active_sessions_url, status: :found }
+ format.js { head :ok }
+ end
+ end
end
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 214640a5295..2166dd7dad7 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -48,7 +48,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:time_display_relative,
:time_format_in_24h,
:show_whitespace_in_diffs,
- :sourcegraph_enabled
+ :sourcegraph_enabled,
+ :render_whitespace_in_code
]
end
end
diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb
index 92655d593dd..b62ce940e9c 100644
--- a/app/controllers/projects/blame_controller.rb
+++ b/app/controllers/projects/blame_controller.rb
@@ -17,6 +17,7 @@ class Projects::BlameController < Projects::ApplicationController
end
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
+ environment_params[:find_latest] = true
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
@blame_groups = Gitlab::Blame.new(@blob, @commit).groups
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 7c97f771a70..3cd14cf845f 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -8,7 +8,7 @@ class Projects::BlobController < Projects::ApplicationController
include NotesHelper
include ActionView::Helpers::SanitizeHelper
include RedirectsForMissingPathOnTree
- include SourcegraphGon
+ include SourcegraphDecorator
prepend_before_action :authenticate_user!, only: [:edit]
@@ -205,6 +205,7 @@ class Projects::BlobController < Projects::ApplicationController
def show_html
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
+ environment_params[:find_latest] = true
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
@last_commit = @repository.last_commit_for_path(@commit.id, @blob.path)
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 812420e9708..b50afa12da0 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -10,8 +10,8 @@ class Projects::Ci::LintsController < Projects::ApplicationController
@content = params[:content]
result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options)
- @error = result.errors.join(', ')
- @status = result.valid?
+ @status = result.valid?
+ @errors = result.errors
if result.valid?
@config_processor = result.content
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index afb670b687b..3f2dc9b09fa 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -8,7 +8,7 @@ class Projects::CommitController < Projects::ApplicationController
include CreatesCommit
include DiffForPath
include DiffHelper
- include SourcegraphGon
+ include SourcegraphDecorator
# Authorize
before_action :require_non_empty_project
@@ -151,7 +151,7 @@ class Projects::CommitController < Projects::ApplicationController
@diffs = commit.diffs(opts)
@notes_count = commit.notes.count
- @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit).execute.last
+ @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit, find_latest: true).execute.last
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 5586c2fc631..943277afe95 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -101,6 +101,7 @@ class Projects::CompareController < Projects::ApplicationController
def define_environment
if compare
environment_params = @repository.branch_exists?(head_ref) ? { ref: head_ref } : { commit: compare.commit }
+ environment_params[:find_latest] = true
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
end
end
diff --git a/app/controllers/projects/environments/sample_metrics_controller.rb b/app/controllers/projects/environments/sample_metrics_controller.rb
index 79a7eab150b..9176c7cbd56 100644
--- a/app/controllers/projects/environments/sample_metrics_controller.rb
+++ b/app/controllers/projects/environments/sample_metrics_controller.rb
@@ -2,7 +2,7 @@
class Projects::Environments::SampleMetricsController < Projects::ApplicationController
def query
- result = Metrics::SampleMetricsService.new(params[:identifier]).query
+ result = Metrics::SampleMetricsService.new(params[:identifier], range_start: params[:start], range_end: params[:end]).query
if result
render json: { "status": "success", "data": { "resultType": "matrix", "result": result } }
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index 1179782036d..70c4b536854 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -15,11 +15,15 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
push_frontend_feature_flag(:prometheus_computed_alerts)
end
+ before_action do
+ push_frontend_feature_flag(:auto_stop_environments)
+ end
after_action :expire_etag_cache, only: [:cancel_auto_stop]
def index
@environments = project.environments
.with_state(params[:scope] || :available)
+ @project = ProjectPresenter.new(project, current_user: current_user)
respond_to do |format|
format.html
@@ -28,6 +32,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
render json: {
environments: serialize_environments(request, response, params[:nested]),
+ review_app: serialize_review_app,
available_count: project.environments.available.count,
stopped_count: project.environments.stopped.count
}
@@ -217,7 +222,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def metrics_dashboard_params
params
- .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment)
+ .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment, :sample_metrics)
.merge(dashboard_path: params[:dashboard], environment: environment)
end
@@ -239,6 +244,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
.represent(@environments)
end
+ def serialize_review_app
+ ReviewAppSetupSerializer.new(current_user: @current_user).represent(@project)
+ end
+
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
diff --git a/app/controllers/projects/error_tracking/base_controller.rb b/app/controllers/projects/error_tracking/base_controller.rb
new file mode 100644
index 00000000000..6efc6d00702
--- /dev/null
+++ b/app/controllers/projects/error_tracking/base_controller.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class Projects::ErrorTracking::BaseController < Projects::ApplicationController
+ POLLING_INTERVAL = 1_000
+
+ def set_polling_interval
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
+ end
+end
diff --git a/app/controllers/projects/error_tracking/projects_controller.rb b/app/controllers/projects/error_tracking/projects_controller.rb
new file mode 100644
index 00000000000..75a2c976d8b
--- /dev/null
+++ b/app/controllers/projects/error_tracking/projects_controller.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Projects
+ module ErrorTracking
+ class ProjectsController < Projects::ApplicationController
+ respond_to :json
+
+ before_action :authorize_read_sentry_issue!
+
+ def index
+ service = ::ErrorTracking::ListProjectsService.new(
+ project,
+ current_user,
+ list_projects_params
+ )
+ result = service.execute
+
+ if result[:status] == :success
+ render json: { projects: serialize_projects(result[:projects]) }
+ else
+ render(
+ status: result[:http_status] || :bad_request,
+ json: { message: result[:message] }
+ )
+ end
+ end
+
+ private
+
+ def list_projects_params
+ { api_host: params[:api_host], token: params[:token] }
+ end
+
+ def serialize_projects(projects)
+ ::ErrorTracking::ProjectSerializer
+ .new(project: project, user: current_user)
+ .represent(projects)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/error_tracking/stack_traces_controller.rb b/app/controllers/projects/error_tracking/stack_traces_controller.rb
new file mode 100644
index 00000000000..c5d5d6da6a6
--- /dev/null
+++ b/app/controllers/projects/error_tracking/stack_traces_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Projects
+ module ErrorTracking
+ class StackTracesController < Projects::ErrorTracking::BaseController
+ respond_to :json
+
+ before_action :authorize_read_sentry_issue!, :set_polling_interval
+
+ def index
+ result = fetch_latest_event_issue
+
+ if result[:status] == :success
+ result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event])
+
+ render json: { error: serialize_error_event(result_with_syntax_highlight) }
+ else
+ render json: { message: result[:message] }, status: result.fetch(:http_status, :bad_request)
+ end
+ end
+
+ private
+
+ def fetch_latest_event_issue
+ ::ErrorTracking::IssueLatestEventService
+ .new(project, current_user, issue_id: params[:issue_id])
+ .execute
+ end
+
+ def serialize_error_event(event)
+ ::ErrorTracking::ErrorEventSerializer
+ .new(project: project, user: current_user)
+ .represent(event)
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
index ba21ccfb169..88f739ce29e 100644
--- a/app/controllers/projects/error_tracking_controller.rb
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-class Projects::ErrorTrackingController < Projects::ApplicationController
- before_action :authorize_read_sentry_issue!
- before_action :set_issue_id, only: [:details, :stack_trace]
+class Projects::ErrorTrackingController < Projects::ErrorTracking::BaseController
+ respond_to :json
- POLLING_INTERVAL = 10_000
+ before_action :authorize_read_sentry_issue!
+ before_action :set_issue_id, only: :details
def index
respond_to do |format|
@@ -20,25 +20,21 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
+ set_polling_interval
render_issue_detail_json
end
end
end
- def stack_trace
- respond_to do |format|
- format.json do
- render_issue_stack_trace_json
- end
- end
- end
+ def update
+ service = ErrorTracking::IssueUpdateService.new(project, current_user, issue_update_params)
+ result = service.execute
- def list_projects
- respond_to do |format|
- format.json do
- render_project_list_json
- end
- end
+ return if handle_errors(result)
+
+ render json: {
+ result: result
+ }
end
private
@@ -71,41 +67,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
}
end
- def render_issue_stack_trace_json
- service = ErrorTracking::IssueLatestEventService.new(project, current_user, issue_details_params)
- result = service.execute
-
- return if handle_errors(result)
-
- result_with_syntax_highlight = Gitlab::ErrorTracking::StackTraceHighlightDecorator.decorate(result[:latest_event])
-
- render json: {
- error: serialize_error_event(result_with_syntax_highlight)
- }
- end
-
- def render_project_list_json
- service = ErrorTracking::ListProjectsService.new(
- project,
- current_user,
- list_projects_params
- )
- result = service.execute
-
- if result[:status] == :success
- render json: {
- projects: serialize_projects(result[:projects])
- }
- else
- return render(
- status: result[:http_status] || :bad_request,
- json: {
- message: result[:message]
- }
- )
- end
- end
-
def handle_errors(result)
unless result[:status] == :success
render json: { message: result[:message] },
@@ -117,8 +78,8 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
params.permit(:search_term, :sort, :cursor)
end
- def list_projects_params
- params.require(:error_tracking_setting).permit([:api_host, :token])
+ def issue_update_params
+ params.permit(:issue_id, :status)
end
def issue_details_params
@@ -129,10 +90,6 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
@issue_id = issue_details_params[:issue_id]
end
- def set_polling_interval
- Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
- end
-
def serialize_errors(errors)
ErrorTracking::ErrorSerializer
.new(project: project, user: current_user)
@@ -144,16 +101,4 @@ class Projects::ErrorTrackingController < Projects::ApplicationController
.new(project: project, user: current_user)
.represent(error)
end
-
- def serialize_error_event(event)
- ErrorTracking::ErrorEventSerializer
- .new(project: project, user: current_user)
- .represent(event)
- end
-
- def serialize_projects(projects)
- ErrorTracking::ProjectSerializer
- .new(project: project, user: current_user)
- .represent(projects)
- end
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index cb6d9c2ba18..9806b91c7e8 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -9,6 +9,7 @@ class Projects::ForksController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :authorize_download_code!
before_action :authenticate_user!, only: [:new, :create]
+ before_action :authorize_fork_project!, only: [:new, :create]
# rubocop: disable CodeReuse/ActiveRecord
def index
@@ -61,6 +62,8 @@ class Projects::ForksController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ private
+
def whitelist_query_limiting
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42335')
end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index ccfc38d97b2..3f6e116a62b 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -3,6 +3,7 @@
class Projects::GitHttpClientController < Projects::ApplicationController
include ActionController::HttpAuthentication::Basic
include KerberosSpnegoHelper
+ include Gitlab::Utils::StrongMemoize
attr_reader :authentication_result, :redirected_path
@@ -47,7 +48,7 @@ class Projects::GitHttpClientController < Projects::ApplicationController
send_final_spnego_response
return # Allow access
end
- elsif project && download_request? && http_allowed? && Guest.can?(:download_code, project)
+ elsif http_download_allowed?
@authentication_result = Gitlab::Auth::Result.new(nil, project, :none, [:download_code])
@@ -89,11 +90,9 @@ class Projects::GitHttpClientController < Projects::ApplicationController
end
def repository
- repo_type.repository_for(project)
- end
-
- def wiki?
- repo_type.wiki?
+ strong_memoize(:repository) do
+ repo_type.repository_for(project)
+ end
end
def repo_type
@@ -113,8 +112,10 @@ class Projects::GitHttpClientController < Projects::ApplicationController
authentication_result.ci?(project)
end
- def http_allowed?
- Gitlab::ProtocolAccess.allowed?('http')
+ def http_download_allowed?
+ Gitlab::ProtocolAccess.allowed?('http') &&
+ download_request? &&
+ project && Guest.can?(:download_code, project)
end
end
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index 93f7ce73a51..236f1b967de 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -75,17 +75,20 @@ class Projects::GitHttpController < Projects::GitHttpClientController
end
def enqueue_fetch_statistics_update
- return if wiki?
- return unless project.daily_statistics_enabled?
+ return if Gitlab::Database.read_only?
+ return if repo_type.wiki?
+ return unless project&.daily_statistics_enabled?
ProjectDailyStatisticsWorker.perform_async(project.id)
end
def access
- @access ||= access_klass.new(access_actor, project,
- 'http', authentication_abilities: authentication_abilities,
- namespace_path: params[:namespace_id], project_path: project_path,
- redirected_path: redirected_path, auth_result_type: auth_result_type)
+ @access ||= access_klass.new(access_actor, project, 'http',
+ authentication_abilities: authentication_abilities,
+ namespace_path: params[:namespace_id],
+ project_path: project_path,
+ redirected_path: redirected_path,
+ auth_result_type: auth_result_type)
end
def access_actor
@@ -107,7 +110,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
end
def log_user_activity
- Users::ActivityService.new(user, 'pull').execute
+ Users::ActivityService.new(user).execute
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 229374c3929..0944d7b47bf 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -44,9 +44,11 @@ class Projects::IssuesController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
- push_frontend_feature_flag(:release_search_filter, project, default_enabled: true)
+ push_frontend_feature_flag(:issue_link_types, project)
end
+ around_action :allow_gitaly_ref_name_caching, only: [:discussions]
+
respond_to :html
alias_method :designs, :show
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index 796f3ff603f..cb473d6ee96 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -51,6 +51,8 @@ class Projects::JobsController < Projects::ApplicationController
build.trace.read do |stream|
respond_to do |format|
format.json do
+ build.trace.being_watched!
+
# TODO: when the feature flag is removed we should not pass
# content_format to serialize method.
content_format = Feature.enabled?(:job_log_json, @project, default_enabled: true) ? :json : :html
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 78dc196b08e..23222cbd37c 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -12,10 +12,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
before_action :build_merge_request, except: [:create]
def new
- # n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/40934
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- define_new_vars
- end
+ define_new_vars
end
def create
@@ -52,7 +49,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
@diff_notes_disabled = true
- @environment = @merge_request.environments_for(current_user).last
+ @environment = @merge_request.environments_for(current_user, latest: true).last
render json: { html: view_to_html_string('projects/merge_requests/creations/_diffs', diffs: @diffs, environment: @environment) }
end
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index 37d90ecdc00..c0c8474232a 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -51,7 +51,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
# Deprecated: https://gitlab.com/gitlab-org/gitlab/issues/37735
def render_diffs
diffs = @compare.diffs(diff_options)
- @environment = @merge_request.environments_for(current_user).last
+ @environment = @merge_request.environments_for(current_user, latest: true).last
diffs.unfold_diff_files(note_positions.unfoldable)
diffs.write_cache
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 69e3e7c7acb..17025670488 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -9,7 +9,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include ToggleAwardEmoji
include IssuableCollections
include RecordUserLastActivity
- include SourcegraphGon
+ include SourcegraphDecorator
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
@@ -25,7 +25,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
- push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true)
push_frontend_feature_flag(:async_mr_widget, @project)
end
@@ -222,11 +221,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
def ci_environments_status
environments =
if ci_environments_status_on_merge_result?
- if Feature.enabled?(:deployment_merge_requests_widget, @project)
- EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user)
- else
- EnvironmentStatus.after_merge_request(@merge_request, current_user)
- end
+ EnvironmentStatus.for_deployed_merge_request(@merge_request, current_user)
else
EnvironmentStatus.for_merge_request(@merge_request, current_user)
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index f1e591ea1ec..18a171700e9 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -34,7 +34,7 @@ class Projects::PagesController < Projects::ApplicationController
if result[:status] == :success
flash[:notice] = 'Your changes have been saved'
else
- flash[:alert] = 'Something went wrong on our end'
+ flash[:alert] = result[:message]
end
redirect_to project_pages_path(@project)
@@ -45,6 +45,12 @@ class Projects::PagesController < Projects::ApplicationController
private
def project_params
- params.require(:project).permit(:pages_https_only)
+ params.require(:project).permit(project_params_attributes)
+ end
+
+ def project_params_attributes
+ %i[pages_https_only]
end
end
+
+Projects::PagesController.prepend_if_ee('EE::Projects::PagesController')
diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
new file mode 100644
index 00000000000..2d872b78096
--- /dev/null
+++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Projects
+ module PerformanceMonitoring
+ class DashboardsController < ::Projects::ApplicationController
+ include BlobHelper
+
+ before_action :check_repository_available!
+ before_action :validate_required_params!
+
+ rescue_from ActionController::ParameterMissing do |exception|
+ respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param })
+ end
+
+ def create
+ result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute
+
+ if result[:status] == :success
+ respond_success(result)
+ else
+ respond_error(result)
+ end
+ end
+
+ private
+
+ def respond_success(result)
+ set_web_ide_link_notice(result.dig(:dashboard, :path))
+ respond_to do |format|
+ format.json { render status: result.delete(:http_status), json: result }
+ end
+ end
+
+ def respond_error(result)
+ respond_to do |format|
+ format.json { render json: { error: result[:message] }, status: result[:http_status] }
+ end
+ end
+
+ def set_web_ide_link_notice(new_dashboard_path)
+ web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">"
+ message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" }
+ flash[:notice] = message.html_safe
+ end
+
+ def validate_required_params!
+ params.require(%i(branch file_name dashboard commit_message))
+ end
+
+ def redirect_safe_branch_name
+ repository.find_branch(params[:branch]).name
+ end
+
+ def dashboard_params
+ params.permit(%i(branch file_name dashboard commit_message)).to_h
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index e3ef8f3f2ff..a62eb94a3e4 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -80,6 +80,12 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
+ def destroy
+ ::Ci::DestroyPipelineService.new(project, current_user).execute(pipeline)
+
+ redirect_to project_pipelines_path(project), status: :see_other
+ end
+
def builds
render_show
end
diff --git a/app/controllers/projects/prometheus/metrics_controller.rb b/app/controllers/projects/prometheus/metrics_controller.rb
index 267dca74f96..c9c7ba1253f 100644
--- a/app/controllers/projects/prometheus/metrics_controller.rb
+++ b/app/controllers/projects/prometheus/metrics_controller.rb
@@ -23,7 +23,7 @@ module Projects
private
def prometheus_adapter
- @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
+ @prometheus_adapter ||= ::Gitlab::Prometheus::Adapter.new(project, project.deployment_platform&.cluster).prometheus_adapter
end
def require_prometheus_metrics!
diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb
index f39d98be516..f7bc6898112 100644
--- a/app/controllers/projects/raw_controller.rb
+++ b/app/controllers/projects/raw_controller.rb
@@ -9,9 +9,9 @@ class Projects::RawController < Projects::ApplicationController
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:blob) }
before_action :require_non_empty_project
- before_action :assign_ref_vars
before_action :authorize_download_code!
before_action :show_rate_limit, only: [:show], unless: :external_storage_request?
+ before_action :assign_ref_vars
before_action :redirect_to_external_storage, only: :show, if: :static_objects_external_storage_enabled?
def show
@@ -23,11 +23,15 @@ class Projects::RawController < Projects::ApplicationController
private
def show_rate_limit
- if rate_limiter.throttled?(:show_raw_controller, scope: [@project, @commit, @path], threshold: raw_blob_request_limit)
+ # This bypasses assign_ref_vars to avoid a Gitaly FindCommit lookup.
+ # When rate limiting, we really don't care if a different commit is
+ # being requested.
+ _ref, path = extract_ref(get_id)
+
+ if rate_limiter.throttled?(:show_raw_controller, scope: [@project, path], threshold: raw_blob_request_limit)
rate_limiter.log_request(request, :raw_blob_request_limit, current_user)
- flash[:alert] = _('You cannot access the raw file. Please wait a minute.')
- redirect_to project_blob_path(@project, File.join(@ref, @path)), status: :too_many_requests
+ render plain: _('You cannot access the raw file. Please wait a minute.'), status: :too_many_requests
end
end
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index d6030a9e455..08a57a9b146 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -7,7 +7,7 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_read_release!
before_action do
push_frontend_feature_flag(:release_issue_summary, project)
- push_frontend_feature_flag(:release_evidence_collection, project)
+ push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_read_release_evidence!, only: [:evidence]
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index dbd11c8ddc8..daddd9dd485 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -46,8 +46,8 @@ class Projects::SnippetsController < Projects::ApplicationController
def create
create_params = snippet_params.merge(spammable_params)
-
- @snippet = CreateSnippetService.new(@project, current_user, create_params).execute
+ service_response = Snippets::CreateService.new(project, current_user, create_params).execute
+ @snippet = service_response.payload[:snippet]
recaptcha_check_with_fallback { render :new }
end
@@ -55,7 +55,8 @@ class Projects::SnippetsController < Projects::ApplicationController
def update
update_params = snippet_params.merge(spammable_params)
- UpdateSnippetService.new(project, current_user, @snippet, update_params).execute
+ service_response = Snippets::UpdateService.new(project, current_user, update_params).execute(@snippet)
+ @snippet = service_response.payload[:snippet]
recaptcha_check_with_fallback { render :edit }
end
@@ -89,11 +90,17 @@ class Projects::SnippetsController < Projects::ApplicationController
end
def destroy
- return access_denied! unless can?(current_user, :admin_project_snippet, @snippet)
-
- @snippet.destroy
-
- redirect_to project_snippets_path(@project), status: :found
+ service_response = Snippets::DestroyService.new(current_user, @snippet).execute
+
+ if service_response.success?
+ redirect_to project_snippets_path(project), status: :found
+ elsif service_response.http_status == 403
+ access_denied!
+ else
+ redirect_to project_snippet_path(project, @snippet),
+ status: :found,
+ alert: service_response.message
+ end
end
protected
diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb
index 4efe956e973..d9654f4f72a 100644
--- a/app/controllers/projects/starrers_controller.rb
+++ b/app/controllers/projects/starrers_controller.rb
@@ -7,8 +7,8 @@ class Projects::StarrersController < Projects::ApplicationController
@starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute
@sort = params[:sort].presence || sort_value_name
@starrers = @starrers.preload_users.sort_by_attribute(@sort).page(params[:page])
- @public_count = @project.starrers.with_public_profile.size
- @total_count = @project.starrers.size
+ @public_count = @project.starrers.with_public_profile.size
+ @total_count = @project.starrers.size
@private_count = @total_count - @public_count
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index eec89afe354..aba28e5c835 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -15,6 +15,10 @@ class Projects::TreeController < Projects::ApplicationController
before_action :authorize_download_code!
before_action :authorize_edit_tree!, only: [:create_dir]
+ before_action only: [:show] do
+ push_frontend_feature_flag(:vue_file_list_lfs_badge)
+ end
+
def show
return render_404 unless @repository.commit(@ref)
@@ -28,7 +32,8 @@ class Projects::TreeController < Projects::ApplicationController
respond_to do |format|
format.html do
- lfs_blob_ids
+ lfs_blob_ids if Feature.disabled?(:vue_file_list, @project)
+
@last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
end
end
diff --git a/app/controllers/projects/uploads_controller.rb b/app/controllers/projects/uploads_controller.rb
index 3e5a1cfc74d..72251988b5e 100644
--- a/app/controllers/projects/uploads_controller.rb
+++ b/app/controllers/projects/uploads_controller.rb
@@ -29,4 +29,14 @@ class Projects::UploadsController < Projects::ApplicationController
Project.find_by_full_path("#{namespace}/#{id}")
end
+
+ # Overrides ApplicationController#build_canonical_path since there are
+ # multiple routes that match project uploads:
+ # https://gitlab.com/gitlab-org/gitlab/issues/196396
+ def build_canonical_path(project)
+ return super unless action_name == 'show'
+ return super unless params[:secret] && params[:filename]
+
+ show_namespace_project_uploads_url(project.namespace.to_param, project.to_param, params[:secret], params[:filename])
+ end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index fb06299676c..cfc0925d9e1 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -39,6 +39,10 @@ class Projects::WikisController < Projects::ApplicationController
if @page
set_encoding_error unless valid_encoding?
+ # Assign vars expected by MarkupHelper
+ @ref = params[:version_id]
+ @path = @page.path
+
render 'show'
elsif file_blob
send_blob(@project_wiki.repository, file_blob)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 47d6fb67108..bf05defbc2e 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -21,8 +21,7 @@ class ProjectsController < Projects::ApplicationController
before_action :assign_ref_vars, if: -> { action_name == 'show' && repo_exists? }
before_action :tree,
if: -> { action_name == 'show' && repo_exists? && project_view_files? }
- before_action :lfs_blob_ids,
- if: -> { action_name == 'show' && repo_exists? && project_view_files? }
+ before_action :lfs_blob_ids, if: :show_blob_ids?, only: :show
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
before_action :authorize_download_code!, only: [:refs]
@@ -52,7 +51,7 @@ class ProjectsController < Projects::ApplicationController
def edit
@badge_api_endpoint = expose_url(api_v4_projects_badges_path(id: @project.id))
- render 'edit'
+ render_edit
end
def create
@@ -86,7 +85,7 @@ class ProjectsController < Projects::ApplicationController
else
flash.now[:alert] = result[:message]
- format.html { render 'edit' }
+ format.html { render_edit }
end
format.js
@@ -296,6 +295,10 @@ class ProjectsController < Projects::ApplicationController
private
+ def show_blob_ids?
+ repo_exists? && project_view_files? && Feature.disabled?(:vue_file_list, @project)
+ end
+
# Render project landing depending of which features are available
# So if page is not available in the list it renders the next page
#
@@ -383,10 +386,12 @@ class ProjectsController < Projects::ApplicationController
:template_project_id,
:merge_method,
:initialize_with_readme,
+ :autoclose_referenced_issues,
project_feature_attributes: %i[
builds_access_level
issues_access_level
+ forking_access_level
merge_requests_access_level
repository_access_level
snippets_access_level
@@ -483,6 +488,10 @@ class ProjectsController < Projects::ApplicationController
def rate_limiter
::Gitlab::ApplicationRateLimiter
end
+
+ def render_edit
+ render 'edit'
+ end
end
ProjectsController.prepend_if_ee('EE::ProjectsController')
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 5fc7f5c84f0..c0ba87bf3ed 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -60,7 +60,7 @@ class RegistrationsController < Devise::RegistrationsController
end
def update_registration
- user_params = params.require(:user).permit(:name, :role, :setup_for_company)
+ user_params = params.require(:user).permit(:role, :setup_for_company)
result = ::Users::SignupService.new(current_user, user_params).execute
if result[:status] == :success
@@ -152,13 +152,7 @@ class RegistrationsController < Devise::RegistrationsController
end
def sign_up_params
- clean_params = params.require(:user).permit(:username, :email, :email_confirmation, :name, :password)
-
- if experiment_enabled?(:signup_flow)
- clean_params[:name] = clean_params[:username]
- end
-
- clean_params
+ params.require(:user).permit(:username, :email, :email_confirmation, :name, :first_name, :last_name, :password)
end
def resource_name
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index e30935be4b6..04d2b3068da 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -21,6 +21,8 @@ class SearchController < ApplicationController
return if params[:search].blank?
+ return unless search_term_valid?
+
@search_term = params[:search]
@scope = search_service.scope
@@ -62,6 +64,20 @@ class SearchController < ApplicationController
private
+ def search_term_valid?
+ unless search_service.valid_query_length?
+ flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT)
+ return false
+ end
+
+ unless search_service.valid_terms_count?
+ flash[:alert] = t('errors.messages.search_terms_too_long', count: SearchService::SEARCH_TERM_LIMIT)
+ return false
+ end
+
+ true
+ end
+
def render_commits
@search_objects = prepare_commits_for_rendering(@search_objects)
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 0007d5826ba..c29e9d3843b 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -262,7 +262,7 @@ class SessionsController < Devise::SessionsController
def log_user_activity(user)
login_counter.increment
- Users::ActivityService.new(user, 'login').execute
+ Users::ActivityService.new(user).execute
end
def load_recaptcha
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 54774df5e76..fc073e47368 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -50,8 +50,8 @@ class SnippetsController < ApplicationController
def create
create_params = snippet_params.merge(spammable_params)
-
- @snippet = CreateSnippetService.new(nil, current_user, create_params).execute
+ service_response = Snippets::CreateService.new(nil, current_user, create_params).execute
+ @snippet = service_response.payload[:snippet]
move_temporary_files if @snippet.valid? && params[:files]
@@ -61,7 +61,8 @@ class SnippetsController < ApplicationController
def update
update_params = snippet_params.merge(spammable_params)
- UpdateSnippetService.new(nil, current_user, @snippet, update_params).execute
+ service_response = Snippets::UpdateService.new(nil, current_user, update_params).execute(@snippet)
+ @snippet = service_response.payload[:snippet]
recaptcha_check_with_fallback { render :edit }
end
@@ -96,11 +97,17 @@ class SnippetsController < ApplicationController
end
def destroy
- return access_denied! unless can?(current_user, :admin_personal_snippet, @snippet)
-
- @snippet.destroy
+ service_response = Snippets::DestroyService.new(current_user, @snippet).execute
- redirect_to snippets_path, status: :found
+ if service_response.success?
+ redirect_to dashboard_snippets_path, status: :found
+ elsif service_response.http_status == 403
+ access_denied!
+ else
+ redirect_to snippet_path(@snippet),
+ status: :found,
+ alert: service_response.message
+ end
end
protected
diff --git a/app/finders/deployments_finder.rb b/app/finders/deployments_finder.rb
index b718b55dd68..0bb8ce6b4da 100644
--- a/app/finders/deployments_finder.rb
+++ b/app/finders/deployments_finder.rb
@@ -17,6 +17,8 @@ class DeploymentsFinder
def execute
items = init_collection
items = by_updated_at(items)
+ items = by_environment(items)
+ items = by_status(items)
sort(items)
end
@@ -58,6 +60,24 @@ class DeploymentsFinder
items
end
+ def by_environment(items)
+ if params[:environment].present?
+ items.for_environment_name(params[:environment])
+ else
+ items
+ end
+ end
+
+ def by_status(items)
+ return items unless params[:status].present?
+
+ unless Deployment.statuses.key?(params[:status])
+ raise ArgumentError, "The deployment status #{params[:status]} is invalid"
+ end
+
+ items.for_status(params[:status])
+ end
+
def sort_params
order_by = ALLOWED_SORT_VALUES.include?(params[:order_by]) ? params[:order_by] : DEFAULT_SORT_VALUE
order_direction = ALLOWED_SORT_DIRECTIONS.include?(params[:sort]) ? params[:sort] : DEFAULT_SORT_DIRECTION
diff --git a/app/finders/environments_finder.rb b/app/finders/environments_finder.rb
index d4e803beb4e..32942c46208 100644
--- a/app/finders/environments_finder.rb
+++ b/app/finders/environments_finder.rb
@@ -25,25 +25,13 @@ class EnvironmentsFinder
.select(:environment_id)
environments = project.environments.available
- .where(id: environment_ids).order_by_last_deployed_at.to_a
+ .where(id: environment_ids)
- environments.select! do |environment|
- Ability.allowed?(current_user, :read_environment, environment)
- end
-
- if ref && commit
- environments.select! do |environment|
- environment.includes_commit?(commit)
- end
- end
-
- if ref && params[:recently_updated]
- environments.select! do |environment|
- environment.recently_updated_on_branch?(ref)
- end
+ if params[:find_latest]
+ find_one(environments.order_by_last_deployed_at_desc)
+ else
+ find_all(environments.order_by_last_deployed_at.to_a)
end
-
- environments
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -62,6 +50,24 @@ class EnvironmentsFinder
private
+ def find_one(environments)
+ [environments.find { |environment| valid_environment?(environment) }].compact
+ end
+
+ def find_all(environments)
+ environments.select { |environment| valid_environment?(environment) }
+ end
+
+ def valid_environment?(environment)
+ # Go in order of cost: SQL calls are cheaper than Gitaly calls
+ return false unless Ability.allowed?(current_user, :read_environment, environment)
+
+ return false if ref && params[:recently_updated] && !environment.recently_updated_on_branch?(ref)
+ return false if ref && commit && !environment.includes_commit?(commit)
+
+ true
+ end
+
def ref
params[:ref].try(:to_s)
end
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 234b7090fd9..6d059e10d05 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -6,7 +6,7 @@ class EventsFinder
MAX_PER_PAGE = 100
- attr_reader :source, :params, :current_user
+ attr_reader :source, :params, :current_user, :scope
requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event
@@ -15,6 +15,7 @@ class EventsFinder
# Arguments:
# source - which user or project to looks for events on
# current_user - only return events for projects visible to this user
+ # scope - return all events across a user's projects
# params:
# action: string
# target_type: string
@@ -27,11 +28,12 @@ class EventsFinder
def initialize(params = {})
@source = params.delete(:source)
@current_user = params.delete(:current_user)
+ @scope = params.delete(:scope)
@params = params
end
def execute
- events = source.events
+ events = get_events
events = by_current_user_access(events)
events = by_action(events)
@@ -47,6 +49,12 @@ class EventsFinder
private
+ def get_events
+ return EventCollection.new(current_user.authorized_projects).all_project_events if scope == 'all'
+
+ source.events
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events)
events.merge(Project.public_or_visible_to_user(current_user))
diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb
index d8739c350e4..ffa1552627a 100644
--- a/app/finders/group_members_finder.rb
+++ b/app/finders/group_members_finder.rb
@@ -1,38 +1,66 @@
# frozen_string_literal: true
class GroupMembersFinder < UnionFinder
- def initialize(group)
+ # Params can be any of the following:
+ # two_factor: string. 'enabled' or 'disabled' are returning different set of data, other values are not effective.
+ # sort: string
+ # search: string
+
+ def initialize(group, user = nil)
@group = group
+ @user = user
end
# rubocop: disable CodeReuse/ActiveRecord
- def execute(include_relations: [:inherited, :direct])
- group_members = @group.members
+ def execute(include_relations: [:inherited, :direct], params: {})
+ group_members = group.members
relations = []
return group_members if include_relations == [:direct]
relations << group_members if include_relations.include?(:direct)
- if include_relations.include?(:inherited) && @group.parent
+ if include_relations.include?(:inherited) && group.parent
parents_members = GroupMember.non_request
- .where(source_id: @group.ancestors.select(:id))
- .where.not(user_id: @group.users.select(:id))
+ .where(source_id: group.ancestors.select(:id))
+ .where.not(user_id: group.users.select(:id))
relations << parents_members
end
if include_relations.include?(:descendants)
descendant_members = GroupMember.non_request
- .where(source_id: @group.descendants.select(:id))
- .where.not(user_id: @group.users.select(:id))
+ .where(source_id: group.descendants.select(:id))
+ .where.not(user_id: group.users.select(:id))
relations << descendant_members
end
- find_union(relations, GroupMember)
+ return GroupMember.none if relations.empty?
+
+ members = find_union(relations, GroupMember)
+ filter_members(members, params)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ attr_reader :user, :group
+
+ def filter_members(members, params)
+ members = members.search(params[:search]) if params[:search].present?
+ members = members.sort_by_attribute(params[:sort]) if params[:sort].present?
+
+ if can_manage_members && params[:two_factor].present?
+ members = members.filter_by_2fa(params[:two_factor])
+ end
+
+ members
+ end
+
+ def can_manage_members
+ Ability.allowed?(user, :admin_group_member, group)
+ end
end
GroupMembersFinder.prepend_if_ee('EE::GroupMembersFinder')
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index e3ea81d5564..194d7da1cab 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -87,7 +87,7 @@ class IssuableFinder
end
def valid_params
- @valid_params ||= scalar_params + [array_params] + [{ not: [] }]
+ @valid_params ||= scalar_params + [array_params.merge(not: {})]
end
end
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index 275a01330bf..410ad645cd9 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -39,6 +39,7 @@ class MergeRequestsFinder < IssuableFinder
def filter_items(_items)
items = by_commit(super)
+ items = by_deployment(items)
items = by_source_branch(items)
items = by_wip(items)
items = by_target_branch(items)
@@ -101,6 +102,17 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('WIP %'))
.or(table[:title].matches('[WIP]%'))
end
+
+ def by_deployment(items)
+ return items unless deployment_id
+
+ items.includes(:deployment_merge_requests)
+ .where(deployment_merge_requests: { deployment_id: deployment_id })
+ end
+
+ def deployment_id
+ @deployment_id ||= params[:deployment_id].presence
+ end
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')
diff --git a/app/finders/pipelines_finder.rb b/app/finders/pipelines_finder.rb
index 5a0d53d9683..48da44123f6 100644
--- a/app/finders/pipelines_finder.rb
+++ b/app/finders/pipelines_finder.rb
@@ -17,7 +17,7 @@ class PipelinesFinder
return Ci::Pipeline.none
end
- items = pipelines
+ items = pipelines.no_child
items = by_scope(items)
items = by_status(items)
items = by_ref(items)
diff --git a/app/finders/sentry_issue_finder.rb b/app/finders/sentry_issue_finder.rb
new file mode 100644
index 00000000000..8b3e7105211
--- /dev/null
+++ b/app/finders/sentry_issue_finder.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class SentryIssueFinder
+ attr_accessor :project, :current_user
+
+ def initialize(project, current_user: nil)
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute(identifier)
+ return unless authorized?
+
+ SentryIssue
+ .for_project_and_identifier(project, identifier)
+ end
+
+ private
+
+ def authorized?
+ Ability.allowed?(current_user, :read_sentry_issue, project)
+ end
+end
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index a5ddf316572..ea5776534d5 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -57,13 +57,24 @@ class GitlabSchema < GraphQL::Schema
object.to_global_id
end
- def object_from_id(global_id, _ctx = nil)
- gid = GlobalID.parse(global_id)
-
- unless gid
- raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id."
- end
+ # Find an object by looking it up from its global ID, passed as a string.
+ #
+ # This is the composition of 'parse_gid' and 'find_by_gid', see these
+ # methods for further documentation.
+ def object_from_id(global_id, ctx = {})
+ gid = parse_gid(global_id, ctx)
+
+ find_by_gid(gid)
+ end
+ # Find an object by looking it up from its 'GlobalID'.
+ #
+ # * For `ApplicationRecord`s, this is equivalent to
+ # `global_id.model_class.find(gid.model_id)`, but more efficient.
+ # * For classes that implement `.lazy_find(global_id)`, this class method
+ # will be called.
+ # * All other classes will use `GlobalID#find`
+ def find_by_gid(gid)
if gid.model_class < ApplicationRecord
Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find
elsif gid.model_class.respond_to?(:lazy_find)
@@ -73,6 +84,38 @@ class GitlabSchema < GraphQL::Schema
end
end
+ # Parse a string to a GlobalID, raising ArgumentError if there are problems
+ # with it.
+ #
+ # Problems that may occur:
+ # * it may not be syntactically valid
+ # * it may not match the expected type (see below)
+ #
+ # Options:
+ # * :expected_type [Class] - the type of object this GlobalID should refer to.
+ #
+ # e.g.
+ #
+ # ```
+ # gid = GitlabSchema.parse_gid(my_string, expected_type: ::Project)
+ # project_id = gid.model_id
+ # gid.model_class == ::Project
+ # ```
+ def parse_gid(global_id, ctx = {})
+ expected_type = ctx[:expected_type]
+ gid = GlobalID.parse(global_id)
+
+ raise Gitlab::Graphql::Errors::ArgumentError, "#{global_id} is not a valid GitLab id." unless gid
+
+ if expected_type && !gid.model_class.ancestors.include?(expected_type)
+ vars = { global_id: global_id, expected_type: expected_type }
+ msg = _('%{global_id} is not a valid id for %{expected_type}.') % vars
+ raise Gitlab::Graphql::Errors::ArgumentError, msg
+ end
+
+ gid
+ end
+
private
def max_query_complexity(ctx)
diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb
index d822048f3a6..22eab4812a1 100644
--- a/app/graphql/mutations/award_emojis/toggle.rb
+++ b/app/graphql/mutations/award_emojis/toggle.rb
@@ -5,10 +5,9 @@ module Mutations
class Toggle < Base
graphql_name 'ToggleAwardEmoji'
- field :toggledOn,
- GraphQL::BOOLEAN_TYPE,
- null: false,
- description: 'True when the emoji was awarded, false when it was removed'
+ field :toggledOn, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates the status of the emoji. ' \
+ 'True if the toggle awarded the emoji, and false if the toggle removed the emoji.'
def resolve(args)
awardable = authorized_find!(id: args[:awardable_id])
diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb
index fe1f543ea1a..4e0e65d09a9 100644
--- a/app/graphql/mutations/snippets/create.rb
+++ b/app/graphql/mutations/snippets/create.rb
@@ -42,12 +42,13 @@ module Mutations
if project_path.present?
project = find_project!(project_path: project_path)
elsif !can_create_personal_snippet?
- raise_resource_not_avaiable_error!
+ raise_resource_not_available_error!
end
- snippet = CreateSnippetService.new(project,
+ service_response = ::Snippets::CreateService.new(project,
context[:current_user],
args).execute
+ snippet = service_response.payload[:snippet]
{
snippet: snippet.valid? ? snippet : nil,
diff --git a/app/graphql/mutations/snippets/destroy.rb b/app/graphql/mutations/snippets/destroy.rb
index 115fcfd6488..dc9a1e82575 100644
--- a/app/graphql/mutations/snippets/destroy.rb
+++ b/app/graphql/mutations/snippets/destroy.rb
@@ -15,8 +15,8 @@ module Mutations
def resolve(id:)
snippet = authorized_find!(id: id)
- result = snippet.destroy
- errors = result ? [] : [ERROR_MSG]
+ response = ::Snippets::DestroyService.new(current_user, snippet).execute
+ errors = response.success? ? [] : [ERROR_MSG]
{
errors: errors
diff --git a/app/graphql/mutations/snippets/mark_as_spam.rb b/app/graphql/mutations/snippets/mark_as_spam.rb
index 260a9753f76..8cfbbae7c08 100644
--- a/app/graphql/mutations/snippets/mark_as_spam.rb
+++ b/app/graphql/mutations/snippets/mark_as_spam.rb
@@ -24,7 +24,7 @@ module Mutations
private
def mark_as_spam(snippet)
- SpamService.new(snippet).mark_as_spam!
+ Spam::MarkAsSpamService.new(spammable: snippet).execute
end
def authorized_resource?(snippet)
diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb
index 27c232bc7f8..b6bdcb9b67b 100644
--- a/app/graphql/mutations/snippets/update.rb
+++ b/app/graphql/mutations/snippets/update.rb
@@ -33,13 +33,13 @@ module Mutations
def resolve(args)
snippet = authorized_find!(id: args.delete(:id))
- result = UpdateSnippetService.new(snippet.project,
+ result = ::Snippets::UpdateService.new(snippet.project,
context[:current_user],
- snippet,
- args).execute
+ args).execute(snippet)
+ snippet = result.payload[:snippet]
{
- snippet: result ? snippet : snippet.reset,
+ snippet: result.success? ? snippet : snippet.reset,
errors: errors_on_object(snippet)
}
end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 62dcc41dd9c..f2b015edfa1 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -9,6 +9,10 @@ module Resolvers
def resolve(**args)
super.first
end
+
+ def single?
+ true
+ end
end
end
@@ -17,6 +21,10 @@ module Resolvers
def resolve(**args)
super.last
end
+
+ def single?
+ true
+ end
end
end
@@ -42,9 +50,13 @@ module Resolvers
override :object
def object
super.tap do |obj|
- # If the field this resolver is used in is wrapped in a presenter, go back to it's subject
+ # If the field this resolver is used in is wrapped in a presenter, unwrap its subject
break obj.subject if obj.is_a?(Gitlab::View::Presenter::Base)
end
end
+
+ def single?
+ false
+ end
end
end
diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb
new file mode 100644
index 00000000000..868abef98eb
--- /dev/null
+++ b/app/graphql/resolvers/environments_resolver.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class EnvironmentsResolver < BaseResolver
+ argument :name, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Name of the environment'
+
+ argument :search, GraphQL::STRING_TYPE,
+ required: false,
+ description: 'Search query'
+
+ type Types::EnvironmentType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return unless project.present?
+
+ EnvironmentsFinder.new(project, context[:current_user], args).find
+ end
+ end
+end
diff --git a/app/graphql/resolvers/projects/grafana_integration_resolver.rb b/app/graphql/resolvers/projects/grafana_integration_resolver.rb
new file mode 100644
index 00000000000..030139734ed
--- /dev/null
+++ b/app/graphql/resolvers/projects/grafana_integration_resolver.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Projects
+ class GrafanaIntegrationResolver < BaseResolver
+ type Types::GrafanaIntegrationType, null: true
+
+ alias_method :project, :object
+
+ def resolve(**args)
+ return unless project.is_a? Project
+
+ project.grafana_integration
+ end
+ end
+ end
+end
diff --git a/app/graphql/types/award_emojis/award_emoji_type.rb b/app/graphql/types/award_emojis/award_emoji_type.rb
index 8daf699a112..0247ec767c8 100644
--- a/app/graphql/types/award_emojis/award_emoji_type.rb
+++ b/app/graphql/types/award_emojis/award_emoji_type.rb
@@ -4,6 +4,7 @@ module Types
module AwardEmojis
class AwardEmojiType < BaseObject
graphql_name 'AwardEmoji'
+ description 'An emoji awarded by a user.'
authorize :read_emoji
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index d2847641d91..90b5283fc9a 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -6,14 +6,24 @@ module Types
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
- field :group, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :icon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :favicon, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :details_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details? # rubocop:disable Graphql/Descriptions
- field :label, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :text, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip # rubocop:disable Graphql/Descriptions
+ field :group, GraphQL::STRING_TYPE, null: false,
+ description: 'Group of the pipeline status'
+ field :icon, GraphQL::STRING_TYPE, null: false,
+ description: 'Icon of the pipeline status'
+ field :favicon, GraphQL::STRING_TYPE, null: false,
+ description: 'Favicon of the pipeline status'
+ field :details_path, GraphQL::STRING_TYPE, null: false,
+ description: 'Path of the details for the pipeline status'
+ field :has_details, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates if the pipeline status has further details',
+ method: :has_details?
+ field :label, GraphQL::STRING_TYPE, null: false,
+ description: 'Label of the pipeline status'
+ field :text, GraphQL::STRING_TYPE, null: false,
+ description: 'Text of the pipeline status'
+ field :tooltip, GraphQL::STRING_TYPE, null: false,
+ description: 'Tooltip associated with the pipeline status',
+ method: :status_tooltip
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index dfcfd6211bc..d77b2a2ba32 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -9,29 +9,34 @@ module Types
expose_permissions Types::PermissionTypes::Ci::Pipeline
- field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :iid, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the pipeline'
+ field :iid, GraphQL::STRING_TYPE, null: false,
+ description: 'Internal ID of the pipeline'
- field :sha, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :before_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
- field :status, PipelineStatusEnum, null: false # rubocop:disable Graphql/Descriptions
- field :detailed_status, # rubocop:disable Graphql/Descriptions
- Types::Ci::DetailedStatusType,
- null: false,
+ field :sha, GraphQL::STRING_TYPE, null: false,
+ description: "SHA of the pipeline's commit"
+ field :before_sha, GraphQL::STRING_TYPE, null: true,
+ description: 'Base SHA of the source branch'
+ field :status, PipelineStatusEnum, null: false,
+ description: "Status of the pipeline (#{::Ci::Pipeline.all_state_names.compact.join(', ').upcase})"
+ field :detailed_status, Types::Ci::DetailedStatusType, null: false,
+ description: 'Detailed status of the pipeline',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
- field :duration,
- GraphQL::INT_TYPE,
- null: true,
- description: "Duration of the pipeline in seconds"
- field :coverage,
- GraphQL::FLOAT_TYPE,
- null: true,
- description: "Coverage percentage"
- field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
- field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions
- field :started_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions
- field :finished_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions
- field :committed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions
+ field :duration, GraphQL::INT_TYPE, null: true,
+ description: 'Duration of the pipeline in seconds'
+ field :coverage, GraphQL::FLOAT_TYPE, null: true,
+ description: 'Coverage percentage'
+ field :created_at, Types::TimeType, null: false,
+ description: "Timestamp of the pipeline's creation"
+ field :updated_at, Types::TimeType, null: false,
+ description: "Timestamp of the pipeline's last activity"
+ field :started_at, Types::TimeType, null: true,
+ description: 'Timestamp when the pipeline was started'
+ field :finished_at, Types::TimeType, null: true,
+ description: "Timestamp of the pipeline's completion"
+ field :committed_at, Types::TimeType, null: true,
+ description: "Timestamp of the pipeline's commit"
# TODO: Add triggering user as a type
end
diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb
new file mode 100644
index 00000000000..ad65caa24a6
--- /dev/null
+++ b/app/graphql/types/environment_type.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Types
+ class EnvironmentType < BaseObject
+ graphql_name 'Environment'
+ description 'Describes where code is deployed for a project'
+
+ authorize :read_environment
+
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Human-readable name of the environment'
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the environment'
+ end
+end
diff --git a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
index c680f387a9a..af6d8818d90 100644
--- a/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
+++ b/app/graphql/types/error_tracking/sentry_detailed_error_type.rb
@@ -76,6 +76,12 @@ module Types
field :last_release_short_version, GraphQL::STRING_TYPE,
null: true,
description: "Release version the error was last seen"
+ field :gitlab_commit, GraphQL::STRING_TYPE,
+ null: true,
+ description: "GitLab commit SHA attributed to the Error based on the release version"
+ field :gitlab_commit_path, GraphQL::STRING_TYPE,
+ null: true,
+ description: "Path to the GitLab page for the GitLab commit attributed to the error"
def first_seen
DateTime.parse(object.first_seen)
diff --git a/app/graphql/types/grafana_integration_type.rb b/app/graphql/types/grafana_integration_type.rb
new file mode 100644
index 00000000000..e6c865fea53
--- /dev/null
+++ b/app/graphql/types/grafana_integration_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ class GrafanaIntegrationType < ::Types::BaseObject
+ graphql_name 'GrafanaIntegration'
+
+ authorize :admin_operations
+
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'Internal ID of the Grafana integration'
+ field :grafana_url, GraphQL::STRING_TYPE, null: false,
+ description: 'Url for the Grafana host for the Grafana integration'
+ field :token, GraphQL::STRING_TYPE, null: false,
+ description: 'API token for the Grafana integration'
+ field :enabled, GraphQL::BOOLEAN_TYPE, null: false,
+ description: 'Indicates whether Grafana integration is enabled'
+
+ field :created_at, Types::TimeType, null: false,
+ description: 'Timestamp of the issue\'s creation'
+ field :updated_at, Types::TimeType, null: false,
+ description: 'Timestamp of the issue\'s last activity'
+ end
+end
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
index 386ae6ed4a3..393948fcede 100644
--- a/app/graphql/types/group_type.rb
+++ b/app/graphql/types/group_type.rb
@@ -17,6 +17,9 @@ module Types
group.avatar_url(only_path: false)
end
+ field :mentions_disabled, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if a group is disabled from getting mentioned'
+
field :parent, GroupType, null: true,
description: 'Parent group',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index 4cbb849da3a..11850e5865f 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -69,7 +69,7 @@ module Types
field :participants, Types::UserType.connection_type, null: true, complexity: 5,
description: 'List of participants in the issue'
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5,
- description: 'Boolean flag for whether the currently logged in user is subscribed to this issue'
+ description: 'Indicates the currently logged in user is subscribed to the issue'
field :time_estimate, GraphQL::INT_TYPE, null: false,
description: 'Time estimate of the issue'
field :total_time_spent, GraphQL::INT_TYPE, null: false,
diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb
index 73049ebed7b..deb8560bd79 100644
--- a/app/graphql/types/permission_types/base_permission_type.rb
+++ b/app/graphql/types/permission_types/base_permission_type.rb
@@ -25,7 +25,7 @@ module Types
kword_args = kword_args.reverse_merge(
name: name,
type: GraphQL::BOOLEAN_TYPE,
- description: "Whether or not a user can perform `#{name}` on this resource",
+ description: "Indicates the user can perform `#{name}` on this resource",
null: false)
field(**kword_args) # rubocop:disable Graphql/Descriptions
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index bd80ad7ff74..5ece4926951 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -46,7 +46,7 @@ module Types
description: 'Timestamp of the project last activity'
field :archived, GraphQL::BOOLEAN_TYPE, null: true,
- description: 'Archived status of the project'
+ description: 'Indicates the archived status of the project'
field :visibility, GraphQL::STRING_TYPE, null: true,
description: 'Visibility of the project'
@@ -102,6 +102,10 @@ module Types
description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line'
field :remove_source_branch_after_merge, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project'
+ field :autoclose_referenced_issues, GraphQL::BOOLEAN_TYPE, null: true,
+ description: 'Indicates if issues referenced by merge requests and commits within the default branch are closed automatically'
+ field :suggestion_commit_message, GraphQL::STRING_TYPE, null: true,
+ description: 'The commit message used to apply merge request suggestions'
field :namespace, Types::NamespaceType, null: true,
description: 'Namespace of the project'
@@ -134,6 +138,12 @@ module Types
description: 'Issues of the project',
resolver: Resolvers::IssuesResolver
+ field :environments,
+ Types::EnvironmentType.connection_type,
+ null: true,
+ description: 'Environments of the project',
+ resolver: Resolvers::EnvironmentsResolver
+
field :issue,
Types::IssueType,
null: true,
@@ -152,6 +162,12 @@ module Types
description: 'Detailed version of a Sentry error on the project',
resolver: Resolvers::ErrorTracking::SentryDetailedErrorResolver
+ field :grafana_integration,
+ Types::GrafanaIntegrationType,
+ null: true,
+ description: 'Grafana integration details for the project',
+ resolver: Resolvers::Projects::GrafanaIntegrationResolver
+
field :snippets,
Types::SnippetType.connection_type,
null: true,
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index 0886a0ba98e..22349203519 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -10,10 +10,13 @@ module Types
graphql_name 'Blob'
- field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
- field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do # rubocop:disable Graphql/Descriptions
- Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
- end
+ field :web_url, GraphQL::STRING_TYPE, null: true,
+ description: 'Web URL of the blob'
+ field :lfs_oid, GraphQL::STRING_TYPE, null: true,
+ description: 'LFS ID of the blob',
+ resolve: -> (blob, args, ctx) do
+ Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
+ end
# rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb
index 87a3eced896..b40e38ec9d1 100644
--- a/app/graphql/types/tree/entry_type.rb
+++ b/app/graphql/types/tree/entry_type.rb
@@ -4,12 +4,18 @@ module Types
module EntryType
include Types::BaseInterface
- field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :sha, GraphQL::STRING_TYPE, null: false, description: "Last commit sha for entry", method: :id
- field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :type, Tree::TypeEnum, null: false # rubocop:disable Graphql/Descriptions
- field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
- field :flat_path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions
+ field :id, GraphQL::ID_TYPE, null: false,
+ description: 'ID of the entry'
+ field :sha, GraphQL::STRING_TYPE, null: false,
+ description: 'Last commit sha for the entry', method: :id
+ field :name, GraphQL::STRING_TYPE, null: false,
+ description: 'Name of the entry'
+ field :type, Tree::TypeEnum, null: false,
+ description: 'Type of tree entry'
+ field :path, GraphQL::STRING_TYPE, null: false,
+ description: 'Path of the entry'
+ field :flat_path, GraphQL::STRING_TYPE, null: false,
+ description: 'Flat path of the entry'
end
end
end
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
index d8e2ab4dd68..d41fa4afd4b 100644
--- a/app/graphql/types/tree/submodule_type.rb
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -8,8 +8,10 @@ module Types
graphql_name 'Submodule'
- field :web_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
- field :tree_url, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
+ field :web_url, type: GraphQL::STRING_TYPE, null: true,
+ description: 'Web URL for the sub-module'
+ field :tree_url, type: GraphQL::STRING_TYPE, null: true,
+ description: 'Tree URL for the sub-module'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
index 904c7dfb795..81a7a7e66ae 100644
--- a/app/graphql/types/tree/tree_entry_type.rb
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -11,7 +11,8 @@ module Types
graphql_name 'TreeEntry'
description 'Represents a directory'
- field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions
+ field :web_url, GraphQL::STRING_TYPE, null: true,
+ description: 'Web URL for the tree entry (directory)'
end
# rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
index 56d544b5fd1..b9fb6b28e71 100644
--- a/app/graphql/types/tree/tree_type.rb
+++ b/app/graphql/types/tree/tree_type.rb
@@ -11,19 +11,23 @@ module Types
null: true, complexity: 10, calls_gitaly: true, resolver: Resolvers::LastCommitResolver,
description: 'Last commit for the tree'
- field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions
- Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
- end
+ field :trees, Types::Tree::TreeEntryType.connection_type, null: false,
+ description: 'Trees of the tree',
+ resolve: -> (obj, args, ctx) do
+ Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
+ end
- # rubocop:disable Graphql/Descriptions
- field :submodules, Types::Tree::SubmoduleType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do
- Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj)
- end
- # rubocop:enable Graphql/Descriptions
+ field :submodules, Types::Tree::SubmoduleType.connection_type, null: false,
+ description: 'Sub-modules of the tree',
+ calls_gitaly: true, resolve: -> (obj, args, ctx) do
+ Gitlab::Graphql::Representation::SubmoduleTreeEntry.decorate(obj.submodules, obj)
+ end
- field :blobs, Types::Tree::BlobType.connection_type, null: false, calls_gitaly: true, resolve: -> (obj, args, ctx) do # rubocop:disable Graphql/Descriptions
- Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
- end
+ field :blobs, Types::Tree::BlobType.connection_type, null: false,
+ description: 'Blobs of the tree',
+ calls_gitaly: true, resolve: -> (obj, args, ctx) do
+ Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
+ end
# rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 8389272fd35..8833b36c42d 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -198,7 +198,7 @@ module ApplicationHelper
end
def external_storage_url_or_path(path, project = @project)
- return path unless static_objects_external_storage_enabled?
+ return path if @snippet || !static_objects_external_storage_enabled?
uri = URI(Gitlab::CurrentSettings.static_objects_external_storage_url)
path = URI(path) # `path` could have query parameters, so we need to split query and path apart
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 71e4195c50f..0e14db6ddbf 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -202,6 +202,7 @@ module ApplicationSettingsHelper
:enabled_git_access_protocol,
:enforce_terms,
:first_day_of_week,
+ :force_pages_access_control,
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
@@ -334,6 +335,28 @@ module ApplicationSettingsHelper
def omnibus_protected_paths_throttle?
Rack::Attack.throttles.key?('protected paths')
end
+
+ def self_monitoring_project_data
+ {
+ 'create_self_monitoring_project_path' =>
+ create_self_monitoring_project_admin_application_settings_path,
+
+ 'status_create_self_monitoring_project_path' =>
+ status_create_self_monitoring_project_admin_application_settings_path,
+
+ 'delete_self_monitoring_project_path' =>
+ delete_self_monitoring_project_admin_application_settings_path,
+
+ 'status_delete_self_monitoring_project_path' =>
+ status_delete_self_monitoring_project_admin_application_settings_path,
+
+ 'self_monitoring_project_exists' =>
+ Gitlab::CurrentSettings.instance_administration_project.present?.to_s,
+
+ 'self_monitoring_project_full_path' =>
+ Gitlab::CurrentSettings.instance_administration_project&.full_path
+ }
+ end
end
ApplicationSettingsHelper.prepend_if_ee('EE::ApplicationSettingsHelper') # rubocop: disable Cop/InjectEnterpriseEditionModule
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 21e57a8d391..b95fd8800c0 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -1,19 +1,29 @@
# frozen_string_literal: true
module BroadcastMessagesHelper
- def current_broadcast_messages
- BroadcastMessage.current(request.path)
+ def current_broadcast_banner_messages
+ BroadcastMessage.current_banner_messages(request.path)
end
- def broadcast_message(message)
+ def current_broadcast_notification_message
+ BroadcastMessage.current_notification_messages(request.path).last
+ end
+
+ def broadcast_message(message, opts = {})
return unless message.present?
- content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do
- sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top mr-2') << ' ' << render_broadcast_message(message)
+ classes = "broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'}"
+
+ content_tag :div, dir: 'auto', class: classes, style: broadcast_message_style(message) do
+ concat sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top')
+ concat ' '
+ concat render_broadcast_message(message)
end
end
def broadcast_message_style(broadcast_message)
+ return '' if broadcast_message.notification?
+
style = []
if broadcast_message.color.present?
@@ -40,4 +50,8 @@ module BroadcastMessagesHelper
def render_broadcast_message(broadcast_message)
Banzai.render_field(broadcast_message, :message).html_safe
end
+
+ def broadcast_type_options
+ BroadcastMessage.broadcast_types.keys.map { |w| [w.humanize, w] }
+ end
end
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 4471d5b64b2..80d1b7e7edb 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -62,6 +62,7 @@ module CiStatusHelper
status.humanize
end
+ # rubocop:disable Metrics/CyclomaticComplexity
def ci_icon_for_status(status, size: 16)
if detailed_status?(status)
return sprite_icon(status.icon, size: size)
@@ -77,6 +78,8 @@ module CiStatusHelper
'status_failed'
when 'pending'
'status_pending'
+ when 'waiting_for_resource'
+ 'status_pending'
when 'preparing'
'status_preparing'
when 'running'
@@ -97,6 +100,7 @@ module CiStatusHelper
sprite_icon(icon_name, size: size)
end
+ # rubocop:enable Metrics/CyclomaticComplexity
def ci_icon_class_for_status(status)
group = detailed_status?(status) ? status.group : status.dasherize
diff --git a/app/helpers/container_expiration_policies_helper.rb b/app/helpers/container_expiration_policies_helper.rb
index 17791e7b0ff..5fb7b5afa6e 100644
--- a/app/helpers/container_expiration_policies_helper.rb
+++ b/app/helpers/container_expiration_policies_helper.rb
@@ -3,19 +3,25 @@
module ContainerExpirationPoliciesHelper
def cadence_options
ContainerExpirationPolicy.cadence_options.map do |key, val|
- { key: key.to_s, label: val }
+ { key: key.to_s, label: val }.tap do |base|
+ base[:default] = true if key.to_s == '1d'
+ end
end
end
def keep_n_options
ContainerExpirationPolicy.keep_n_options.map do |key, val|
- { key: key, label: val }
+ { key: key, label: val }.tap do |base|
+ base[:default] = true if key == 10
+ end
end
end
def older_than_options
ContainerExpirationPolicy.older_than_options.map do |key, val|
- { key: key.to_s, label: val }
+ { key: key.to_s, label: val }.tap do |base|
+ base[:default] = true if key.to_s == '30d'
+ end
end
end
end
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index 679622897aa..b38feb0fb6c 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -35,7 +35,7 @@ module DashboardHelper
tag.p(aria: { label: label }) do
concat(link_or_title)
- concat(tag.span(class: ['light', 'float-right']) do
+ concat(tag.span(class: %w[light float-right]) do
boolean_to_icon(enabled)
end)
@@ -58,6 +58,10 @@ module DashboardHelper
links += [:activity, :milestones]
end
+ if can?(current_user, :read_instance_statistics)
+ links << :analytics
+ end
+
links
end
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 59972118ae3..993c18f9229 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -29,8 +29,10 @@ module EnvironmentsHelper
"empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
+ "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json),
"dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
"deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json),
+ "default-branch" => project.default_branch,
"environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 78c41257404..1fb0b83b010 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -153,6 +153,29 @@ module GitlabRoutingHelper
# Artifacts
+ # Rails path generators are slow because they need to do large regex comparisons
+ # against the arguments. We can speed this up 10x by generating the strings directly.
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/download(.:format)
+ def fast_download_project_job_artifacts_path(project, job)
+ expose_fast_artifacts_path(project, job, :download)
+ end
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/keep(.:format)
+ def fast_keep_project_job_artifacts_path(project, job)
+ expose_fast_artifacts_path(project, job, :keep)
+ end
+
+ # /*namespace_id/:project_id/-/jobs/:job_id/artifacts/browse(/*path)
+ def fast_browse_project_job_artifacts_path(project, job)
+ expose_fast_artifacts_path(project, job, :browse)
+ end
+
+ def expose_fast_artifacts_path(project, job, action)
+ path = "#{project.full_path}/-/jobs/#{job.id}/artifacts/#{action}"
+ Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, path)
+ end
+
def artifacts_action_path(path, project, build)
action, path_params = path.split('/', 2)
args = [project, build, path_params]
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index a8f6c974bbd..1952325c504 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -4,6 +4,10 @@ module Groups::GroupMembersHelper
def group_member_select_options
{ multiple: true, class: 'input-clamp qa-member-select-field ', scope: :all, email_user: true }
end
+
+ def render_invite_member_for_group(group, default_access_level)
+ render 'shared/members/invite_member', submit_url: group_group_members_path(group), access_levels: GroupMember.access_level_roles, default_access_level: default_access_level
+ end
end
Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper')
diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb
index 8e50bbc6c04..e4d0e605254 100644
--- a/app/helpers/ide_helper.rb
+++ b/app/helpers/ide_helper.rb
@@ -10,7 +10,8 @@ module IdeHelper
"promotion-svg-path": image_path('illustrations/web-ide_promotion.svg'),
"ci-help-page-path" => help_page_path('ci/quick_start/README'),
"web-ide-help-page-path" => help_page_path('user/project/web_ide/index.html'),
- "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s
+ "clientside-preview-enabled": Gitlab::CurrentSettings.current_application_settings.web_ide_clientside_preview_enabled.to_s,
+ "render-whitespace-in-code": current_user.render_whitespace_in_code.to_s
}
end
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index e1e756c2f4c..d6e466d4678 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -132,6 +132,7 @@ module MarkupHelper
pipeline: :wiki,
project: @project,
project_wiki: @project_wiki,
+ repository: @project_wiki.repository,
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
)
@@ -153,7 +154,9 @@ module MarkupHelper
else
other_markup_unsafe(file_name, text, context)
end
- rescue RuntimeError
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e, project_id: @project&.id, file_name: file_name, context: context)
+
simple_format(text)
end
@@ -280,7 +283,7 @@ module MarkupHelper
context.reverse_merge!(
current_user: (current_user if defined?(current_user)),
- # RelativeLinkFilter
+ # RepositoryLinkFilter and UploadLinkFilter
commit: @commit,
project_wiki: @project_wiki,
ref: @ref,
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
index de21a78f5f0..ed5c7640ec1 100644
--- a/app/helpers/projects/error_tracking_helper.rb
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -10,6 +10,8 @@ module Projects::ErrorTrackingHelper
'user-can-enable-error-tracking' => can?(current_user, :admin_operations, project).to_s,
'enable-error-tracking-link' => project_settings_operations_path(project),
'error-tracking-enabled' => error_tracking_enabled.to_s,
+ 'project-path' => project.full_path,
+ 'list-path' => project_error_tracking_index_path(project),
'illustration-path' => image_path('illustrations/cluster_popover.svg')
}
end
@@ -18,8 +20,12 @@ module Projects::ErrorTrackingHelper
opts = [project, issue_id, { format: :json }]
{
- 'project-issues-path' => project_issues_path(project),
+ 'issue-id' => issue_id,
+ 'project-path' => project.full_path,
+ 'list-path' => project_error_tracking_index_path(project),
'issue-details-path' => details_project_error_tracking_index_path(*opts),
+ 'issue-update-path' => update_project_error_tracking_index_path(*opts),
+ 'project-issues-path' => project_issues_path(project),
'issue-stack-trace-path' => stack_trace_project_error_tracking_index_path(*opts)
}
end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index d683faf6a20..63f1f24b611 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -563,6 +563,7 @@ module ProjectsHelper
requestAccessEnabled: !!project.request_access_enabled,
issuesAccessLevel: feature.issues_access_level,
repositoryAccessLevel: feature.repository_access_level,
+ forkingAccessLevel: feature.forking_access_level,
mergeRequestsAccessLevel: feature.merge_requests_access_level,
buildsAccessLevel: feature.builds_access_level,
wikiAccessLevel: feature.wiki_access_level,
@@ -587,6 +588,7 @@ module ProjectsHelper
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'),
pagesAvailable: Gitlab.config.pages.enabled,
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
+ pagesAccessControlForced: ::Gitlab::Pages.access_control_is_forced?,
pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core')
}
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index a89fea4b7b8..9a5c5f274a0 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -143,7 +143,7 @@ module SearchHelper
# Autocomplete results for the current project, if it's defined
def project_autocomplete
- if @project && @project.repository.exists? && @project.repository.root_ref
+ if @project && @project.repository.root_ref
ref = @ref || @project.repository.root_ref
[
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index 90c54123597..4d0f9e530fb 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -85,7 +85,8 @@ module SelectsHelper
first_user: opts[:first_user] && current_user ? current_user.username : false,
current_user: opts[:current_user] || false,
author_id: opts[:author_id] || '',
- skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil
+ skip_users: opts[:skip_users] ? opts[:skip_users].map(&:id) : nil,
+ qa_selector: opts[:qa_selector] || ''
}
end
end
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index 1c7690f30d2..fd7e58826b5 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -127,7 +127,7 @@ module SnippetsHelper
return unless attrs = snippet_badge_attributes(snippet)
css_class, text = attrs
- tag.span(class: ['badge', 'badge-gray']) do
+ tag.span(class: %w[badge badge-gray]) do
concat(tag.i(class: ['fa', css_class]))
concat(' ')
concat(text)
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 11b78b8fd59..b3eee25674b 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -27,7 +27,7 @@ module UserCalloutsHelper
end
def show_tabs_feature_highlight?
- !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test?
+ current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test?
end
private
diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb
index 6274879ee99..5c957437039 100644
--- a/app/mailers/emails/projects.rb
+++ b/app/mailers/emails/projects.rb
@@ -51,7 +51,7 @@ module Emails
add_project_headers
headers['X-GitLab-Author'] = @message.author_username
- mail(from: sender(@message.author_id, @message.send_from_committer_email?),
+ mail(from: sender(@message.author_id, send_from_user_email: @message.send_from_committer_email?),
reply_to: @message.reply_to,
subject: @message.subject)
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index c7cfefeec9b..92939136de2 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -59,11 +59,11 @@ class Notify < BaseMailer
# Return an email address that displays the name of the sender.
# Only the displayed name changes; the actual email address is always the same.
- def sender(sender_id, send_from_user_email = false)
+ def sender(sender_id, send_from_user_email: false, sender_name: nil)
return unless sender = User.find(sender_id)
address = default_sender_address
- address.display_name = sender.name
+ address.display_name = sender_name.presence || sender.name
if send_from_user_email && can_send_from_user_email?(sender)
address.address = sender.email
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 3ecc3137157..f37da1b7f59 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -6,9 +6,11 @@ class ActiveSession
SESSION_BATCH_SIZE = 200
ALLOWED_NUMBER_OF_ACTIVE_SESSIONS = 100
+ attr_writer :session_id
+
attr_accessor :created_at, :updated_at,
- :session_id, :ip_address,
- :browser, :os, :device_name, :device_type,
+ :ip_address, :browser, :os,
+ :device_name, :device_type,
:is_impersonated
def current?(session)
@@ -21,6 +23,11 @@ class ActiveSession
device_type&.titleize
end
+ def public_id
+ encrypted_id = Gitlab::CryptoHelper.aes256_gcm_encrypt(session_id)
+ CGI.escape(encrypted_id)
+ end
+
def self.set(user, request)
Gitlab::Redis::SharedState.with do |redis|
session_id = request.session.id
@@ -70,6 +77,11 @@ class ActiveSession
end
end
+ def self.destroy_with_public_id(user, public_id)
+ session_id = decrypt_public_id(public_id)
+ destroy(user, session_id) unless session_id.nil?
+ end
+
def self.destroy_sessions(redis, user, session_ids)
key_names = session_ids.map {|session_id| key_name(user.id, session_id) }
session_names = session_ids.map {|session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
@@ -146,9 +158,9 @@ class ActiveSession
# remove sessions if there are more than ALLOWED_NUMBER_OF_ACTIVE_SESSIONS.
sessions = active_session_entries(session_ids, user.id, redis)
sessions.sort_by! {|session| session.updated_at }.reverse!
- sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
- sessions = sessions.map { |session| session.session_id }
- destroy_sessions(redis, user, sessions) if sessions.any?
+ destroyable_sessions = sessions.drop(ALLOWED_NUMBER_OF_ACTIVE_SESSIONS)
+ destroyable_session_ids = destroyable_sessions.map { |session| session.send :session_id } # rubocop:disable GitlabSecurity/PublicSend
+ destroy_sessions(redis, user, destroyable_session_ids) if destroyable_session_ids.any?
end
def self.cleaned_up_lookup_entries(redis, user)
@@ -167,4 +179,15 @@ class ActiveSession
entries.compact
end
+
+ private_class_method def self.decrypt_public_id(public_id)
+ decoded_id = CGI.unescape(public_id)
+ Gitlab::CryptoHelper.aes256_gcm_decrypt(decoded_id)
+ rescue
+ nil
+ end
+
+ private
+
+ attr_reader :session_id
end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 2815a117f7f..1104b676bc4 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -18,6 +18,11 @@ class Appearance < ApplicationRecord
validate :single_appearance_row, on: :create
+ default_value_for :title, ''
+ default_value_for :description, ''
+ default_value_for :new_project_guidelines, ''
+ default_value_for :header_message, ''
+ default_value_for :footer_message, ''
default_value_for :message_background_color, '#E75E40'
default_value_for :message_font_color, '#FFFFFF'
default_value_for :email_header_and_footer_enabled, false
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 456b6430088..10d15e84b8d 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord
add_authentication_token_field :static_objects_external_storage_auth_token
belongs_to :instance_administration_project, class_name: "Project"
+ belongs_to :instance_administrators_group, class_name: "Group"
# Include here so it can override methods from
# `add_authentication_token_field`
@@ -121,6 +122,11 @@ class ApplicationSetting < ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than: 0 }
+ validates :max_pages_size,
+ presence: true,
+ numericality: { only_integer: true, greater_than: 0,
+ less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte }
+
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_registry_token_expire_delay,
@@ -164,7 +170,11 @@ class ApplicationSetting < ApplicationRecord
validates :gitaly_timeout_default,
presence: true,
- numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ numericality: {
+ only_integer: true,
+ greater_than_or_equal_to: 0,
+ less_than_or_equal_to: Settings.gitlab.max_request_duration_seconds
+ }
validates :gitaly_timeout_medium,
presence: true,
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 0a425f2b961..42ee00bc196 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -51,6 +51,7 @@ class Blob < SimpleDelegator
BlobViewer::Contributing,
BlobViewer::Changelog,
+ BlobViewer::CargoToml,
BlobViewer::Cartfile,
BlobViewer::ComposerJson,
BlobViewer::Gemfile,
diff --git a/app/models/blob_viewer/cargo_toml.rb b/app/models/blob_viewer/cargo_toml.rb
new file mode 100644
index 00000000000..2f1ebd25b4f
--- /dev/null
+++ b/app/models/blob_viewer/cargo_toml.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module BlobViewer
+ class CargoToml < DependencyManager
+ include Static
+
+ self.file_types = %i(cargo_toml)
+
+ def manager_name
+ 'Cargo'
+ end
+
+ def manager_url
+ 'https://crates.io/'
+ end
+ end
+end
diff --git a/app/models/board.rb b/app/models/board.rb
index f3f938224a4..38bbb550044 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -11,6 +11,8 @@ class Board < ApplicationRecord
validates :group, presence: true, unless: :project
scope :with_associations, -> { preload(:destroyable_lists) }
+ scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc) }
+ scope :first_board, -> { where(id: self.order_by_name_asc.limit(1).select(:id)) }
def project_needed?
!group
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 6c51f650b6a..e6d41dd2779 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
module Ci
- class Bridge < CommitStatus
- include Ci::Processable
+ class Bridge < Ci::Processable
include Ci::Contextable
include Ci::PipelineDelegator
include Importable
@@ -54,6 +53,10 @@ module Ci
def to_partial_path
'projects/generic_commit_statuses/generic_commit_status'
end
+
+ def yaml_for_downstream
+ nil
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index 7e7c580a48e..369a793f3d5 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
module Ci
- class Build < CommitStatus
- include Ci::Processable
+ class Build < Ci::Processable
include Ci::Metadatable
include Ci::Contextable
include Ci::PipelineDelegator
@@ -23,6 +22,7 @@ module Ci
belongs_to :runner
belongs_to :trigger_request
belongs_to :erased_by, class_name: 'User'
+ belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :builds
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
@@ -34,6 +34,7 @@ module Ci
}.freeze
has_one :deployment, as: :deployable, class_name: 'Deployment'
+ has_one :resource, class_name: 'Ci::Resource', inverse_of: :build
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id
@@ -204,9 +205,25 @@ module Ci
state_machine :status do
event :enqueue do
+ transition [:created, :skipped, :manual, :scheduled] => :waiting_for_resource, if: :requires_resource?
transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites?
end
+ event :enqueue_scheduled do
+ transition scheduled: :waiting_for_resource, if: :requires_resource?
+ transition scheduled: :preparing, if: :any_unmet_prerequisites?
+ transition scheduled: :pending
+ end
+
+ event :enqueue_waiting_for_resource do
+ transition waiting_for_resource: :preparing, if: :any_unmet_prerequisites?
+ transition waiting_for_resource: :pending
+ end
+
+ event :enqueue_preparing do
+ transition preparing: :pending
+ end
+
event :actionize do
transition created: :manual
end
@@ -219,14 +236,8 @@ module Ci
transition scheduled: :manual
end
- event :enqueue_scheduled do
- transition scheduled: :preparing, if: ->(build) do
- build.scheduled_at&.past? && build.any_unmet_prerequisites?
- end
-
- transition scheduled: :pending, if: ->(build) do
- build.scheduled_at&.past? && !build.any_unmet_prerequisites?
- end
+ before_transition on: :enqueue_scheduled do |build|
+ build.scheduled_at.nil? || build.scheduled_at.past? # If false is returned, it stops the transition
end
before_transition scheduled: any do |build|
@@ -237,6 +248,27 @@ module Ci
build.scheduled_at = build.options_scheduled_at
end
+ before_transition any => :waiting_for_resource do |build|
+ build.waiting_for_resource_at = Time.now
+ end
+
+ before_transition on: :enqueue_waiting_for_resource do |build|
+ next unless build.requires_resource?
+
+ build.resource_group.assign_resource_to(build) # If false is returned, it stops the transition
+ end
+
+ after_transition any => :waiting_for_resource do |build|
+ build.run_after_commit do
+ Ci::ResourceGroups::AssignResourceFromResourceGroupWorker
+ .perform_async(build.resource_group_id)
+ end
+ end
+
+ before_transition on: :enqueue_preparing do |build|
+ !build.any_unmet_prerequisites? # If false is returned, it stops the transition
+ end
+
after_transition created: :scheduled do |build|
build.run_after_commit do
Ci::BuildScheduleWorker.perform_at(build.scheduled_at, build.id)
@@ -265,6 +297,16 @@ module Ci
end
end
+ after_transition any => ::Ci::Build.completed_statuses do |build|
+ next unless build.resource_group_id.present?
+ next unless build.resource_group.release_resource_from(build)
+
+ build.run_after_commit do
+ Ci::ResourceGroups::AssignResourceFromResourceGroupWorker
+ .perform_async(build.resource_group_id)
+ end
+ end
+
after_transition any => [:success, :failed, :canceled] do |build|
build.run_after_commit do
BuildFinishedWorker.perform_async(id)
@@ -405,10 +447,6 @@ module Ci
options_retry_when.include?('always')
end
- def latest?
- !retried?
- end
-
def any_unmet_prerequisites?
prerequisites.present?
end
@@ -437,6 +475,11 @@ module Ci
end
end
+ def requires_resource?
+ Feature.enabled?(:ci_resource_group, project, default_enabled: true) &&
+ self.resource_group_id.present?
+ end
+
def has_environment?
environment.present?
end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 62bf2c3ac9c..9eca324f0fc 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -16,6 +16,8 @@ module Ci
archive: nil,
metadata: nil,
trace: nil,
+ metrics_referee: nil,
+ network_referee: nil,
junit: 'junit.xml',
codequality: 'gl-code-quality-report.json',
sast: 'gl-sast-report.json',
@@ -23,6 +25,7 @@ module Ci
container_scanning: 'gl-container-scanning-report.json',
dast: 'gl-dast-report.json',
license_management: 'gl-license-management-report.json',
+ license_scanning: 'gl-license-scanning-report.json',
performance: 'performance.json',
metrics: 'metrics.txt'
}.freeze
@@ -36,6 +39,8 @@ module Ci
REPORT_TYPES = {
junit: :gzip,
metrics: :gzip,
+ metrics_referee: :gzip,
+ network_referee: :gzip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
@@ -46,6 +51,7 @@ module Ci
container_scanning: :raw,
dast: :raw,
license_management: :raw,
+ license_scanning: :raw,
performance: :raw
}.freeze
@@ -104,8 +110,11 @@ module Ci
dast: 8, ## EE-specific
codequality: 9, ## EE-specific
license_management: 10, ## EE-specific
+ license_scanning: 101, ## EE-specific till 13.0
performance: 11, ## EE-specific
- metrics: 12 ## EE-specific
+ metrics: 12, ## EE-specific
+ metrics_referee: 13, ## runner referees
+ network_referee: 14 ## runner referees
}
enum file_format: {
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 3710a0b914e..7e3ba98d86c 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -26,15 +26,14 @@ module Ci
belongs_to :merge_request, class_name: 'MergeRequest'
belongs_to :external_pull_request
- has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do
+ has_internal_id :iid, scope: :project, presence: false, track_if: -> { !importing? }, ensure_if: -> { !importing? }, init: ->(s) do
s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count
end
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :latest_statuses_ordered_by_stage, -> { latest.order(:stage_idx, :stage) }, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
- has_many :processables, -> { processables },
- class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :processables, class_name: 'Ci::Processable', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
@@ -61,9 +60,13 @@ module Ci
has_one :chat_data, class_name: 'Ci::PipelineChatData'
has_many :triggered_pipelines, through: :sourced_pipelines, source: :pipeline
+ has_many :child_pipelines, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :sourced_pipelines, source: :pipeline
has_one :triggered_by_pipeline, through: :source_pipeline, source: :source_pipeline
+ has_one :parent_pipeline, -> { merge(Ci::Sources::Pipeline.same_project) }, through: :source_pipeline, source: :source_pipeline
has_one :source_job, through: :source_pipeline, source: :source_job
+ has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
+
accepts_nested_attributes_for :variables, reject_if: :persisted?
delegate :id, to: :project, prefix: true
@@ -97,10 +100,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :preparing, :skipped, :scheduled] => :pending
+ transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running
end
+ event :request_resource do
+ transition any - [:waiting_for_resource] => :waiting_for_resource
+ end
+
event :prepare do
transition any - [:preparing] => :preparing
end
@@ -137,7 +144,7 @@ module Ci
# Do not add any operations to this state_machine
# Create a separate worker for each new operation
- before_transition [:created, :preparing, :pending] => :running do |pipeline|
+ before_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline|
pipeline.started_at = Time.now
end
@@ -160,7 +167,7 @@ module Ci
end
end
- after_transition [:created, :preparing, :pending] => :running do |pipeline|
+ after_transition [:created, :waiting_for_resource, :preparing, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
@@ -168,7 +175,7 @@ module Ci
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
- after_transition [:created, :preparing, :pending, :running] => :success do |pipeline|
+ after_transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success do |pipeline|
pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) }
end
@@ -190,6 +197,10 @@ module Ci
AutoMergeProcessWorker.perform_async(merge_request.id)
end
+
+ if pipeline.auto_devops_source?
+ self.class.auto_devops_pipelines_completed_total.increment(status: pipeline.status)
+ end
end
end
@@ -207,6 +218,7 @@ module Ci
end
scope :internal, -> { where(source: internal_sources) }
+ scope :no_child, -> { where.not(source: :parent_pipeline) }
scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) }
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
@@ -319,7 +331,11 @@ module Ci
end
def self.bridgeable_statuses
- ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending]
+ ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending]
+ end
+
+ def self.auto_devops_pipelines_completed_total
+ @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines')
end
def stages_count
@@ -499,11 +515,9 @@ module Ci
# rubocop: enable CodeReuse/ServiceClass
def mark_as_processable_after_stage(stage_idx)
- builds.skipped.after_stage(stage_idx).find_each(&:process)
- end
-
- def child?
- false
+ builds.skipped.after_stage(stage_idx).find_each do |build|
+ Gitlab::OptimisticLocking.retry_lock(build, &:process)
+ end
end
def latest?
@@ -542,6 +556,13 @@ module Ci
end
end
+ def needs_processing?
+ statuses
+ .where(processed: [false, nil])
+ .latest
+ .exists?
+ end
+
# TODO: this logic is duplicate with Pipeline::Chain::Config::Content
# we should persist this is `ci_pipelines.config_path`
def config_path
@@ -571,11 +592,11 @@ module Ci
project.notes.for_commit_id(sha)
end
- def update_status
+ def set_status(new_status)
retry_optimistic_lock(self) do
- new_status = latest_builds_status.to_s
case new_status
when 'created' then nil
+ when 'waiting_for_resource' then request_resource
when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
@@ -592,6 +613,10 @@ module Ci
end
end
+ def update_legacy_status
+ set_status(latest_builds_status.to_s)
+ end
+
def protected_ref?
strong_memoize(:protected_ref) { project.protected_for?(git_ref) }
end
@@ -687,6 +712,24 @@ module Ci
all_merge_requests.order(id: :desc)
end
+ # If pipeline is a child of another pipeline, include the parent
+ # and the siblings, otherwise return only itself.
+ def same_family_pipeline_ids
+ if (parent = parent_pipeline)
+ [parent.id] + parent.child_pipelines.pluck(:id)
+ else
+ [self.id]
+ end
+ end
+
+ def child?
+ parent_pipeline.present?
+ end
+
+ def parent?
+ child_pipelines.exists?
+ end
+
def detailed_status(current_user)
Gitlab::Ci::Status::Pipeline::Factory
.new(self, current_user)
diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb
new file mode 100644
index 00000000000..d5a8da2bc1e
--- /dev/null
+++ b/app/models/ci/pipeline_config.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineConfig < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ self.table_name = 'ci_pipelines_config'
+ self.primary_key = :pipeline_id
+
+ belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config
+ validates :pipeline, presence: true
+ validates :content, presence: true
+ end
+end
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 3cd88807969..fde169d2f03 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -23,10 +23,13 @@ module Ci
schedule: 4,
api: 5,
external: 6,
+ # TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0
+ # https://gitlab.com/gitlab-org/gitlab/issues/195991
pipeline: 7,
chat: 8,
merge_request_event: 10,
- external_pull_request_event: 11
+ external_pull_request_event: 11,
+ parent_pipeline: 12
}
end
@@ -38,7 +41,8 @@ module Ci
repository_source: 1,
auto_devops_source: 2,
remote_source: 4,
- external_project_source: 5
+ external_project_source: 5,
+ bridge_source: 6
}
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 946241b7d4c..9a1445e624c 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -5,6 +5,7 @@ module Ci
extend Gitlab::Ci::Model
include Importable
include StripAttribute
+ include Schedulable
belongs_to :project
belongs_to :owner, class_name: 'User'
@@ -18,13 +19,10 @@ module Ci
validates :description, presence: true
validates :variables, variable_duplicates: true
- before_save :set_next_run_at
-
strip_attributes :cron
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
- scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.now) }
scope :preloaded, -> { preload(:owner, :project) }
accepts_nested_attributes_for :variables, allow_destroy: true
@@ -62,12 +60,6 @@ module Ci
end
end
- def schedule_next_run!
- save! # with set_next_run_at
- rescue ActiveRecord::RecordInvalid
- update_column(:next_run_at, nil) # update without validation
- end
-
def job_variables
variables&.map(&:to_runner_variable) || []
end
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
new file mode 100644
index 00000000000..6c4b271cd2c
--- /dev/null
+++ b/app/models/ci/processable.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Ci
+ class Processable < ::CommitStatus
+ has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
+
+ accepts_nested_attributes_for :needs
+
+ scope :preload_needs, -> { preload(:needs) }
+
+ def self.select_with_aggregated_needs(project)
+ return all unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
+
+ aggregated_needs_names = Ci::BuildNeed
+ .scoped_build
+ .select("ARRAY_AGG(name)")
+ .to_sql
+
+ all.select(
+ '*',
+ "(#{aggregated_needs_names}) as aggregated_needs_names"
+ )
+ end
+
+ validates :type, presence: true
+
+ def aggregated_needs_names
+ read_attribute(:aggregated_needs_names)
+ end
+
+ def schedulable?
+ raise NotImplementedError
+ end
+
+ def action?
+ raise NotImplementedError
+ end
+
+ def when
+ read_attribute(:when) || 'on_success'
+ end
+
+ def expanded_environment_name
+ raise NotImplementedError
+ end
+
+ def scoped_variables_hash
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/models/ci/resource.rb b/app/models/ci/resource.rb
new file mode 100644
index 00000000000..ee5b6546165
--- /dev/null
+++ b/app/models/ci/resource.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class Resource < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :resources
+ belongs_to :build, class_name: 'Ci::Build', inverse_of: :resource
+
+ scope :free, -> { where(build: nil) }
+ scope :retained_by, -> (build) { where(build: build) }
+ end
+end
diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb
new file mode 100644
index 00000000000..eb18f3da0bf
--- /dev/null
+++ b/app/models/ci/resource_group.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Ci
+ class ResourceGroup < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ belongs_to :project, inverse_of: :resource_groups
+
+ has_many :resources, class_name: 'Ci::Resource', inverse_of: :resource_group
+ has_many :builds, class_name: 'Ci::Build', inverse_of: :resource_group
+
+ validates :key,
+ length: { maximum: 255 },
+ format: { with: Gitlab::Regex.environment_name_regex,
+ message: Gitlab::Regex.environment_name_regex_message }
+
+ before_create :ensure_resource
+
+ ##
+ # NOTE: This is concurrency-safe method that the subquery in the `UPDATE`
+ # works as explicit locking.
+ def assign_resource_to(build)
+ resources.free.limit(1).update_all(build_id: build.id) > 0
+ end
+
+ def release_resource_from(build)
+ resources.retained_by(build).update_all(build_id: nil) > 0
+ end
+
+ private
+
+ def ensure_resource
+ # Currently we only support one resource per group, which means
+ # maximum one build can be set to the resource group, thus builds
+ # belong to the same resource group are executed once at time.
+ self.resources.build if self.resources.empty?
+ end
+ end
+end
diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb
index feaec27281c..d71e3b55b9a 100644
--- a/app/models/ci/sources/pipeline.rb
+++ b/app/models/ci/sources/pipeline.rb
@@ -18,6 +18,8 @@ module Ci
validates :source_project, presence: true
validates :source_job, presence: true
validates :source_pipeline, presence: true
+
+ scope :same_project, -> { where(arel_table[:source_project_id].eq(arel_table[:project_id])) }
end
end
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 77ac8bfe875..75f73429c2a 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -13,9 +13,12 @@ module Ci
belongs_to :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
+ has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
has_many :bridges, foreign_key: :stage_id
+ scope :ordered, -> { order(position: :asc) }
+
with_options unless: :importing? do
validates :project, presence: true
validates :pipeline, presence: true
@@ -39,10 +42,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :preparing] => :pending
+ transition [:created, :waiting_for_resource, :preparing] => :pending
transition [:success, :failed, :canceled, :skipped] => :running
end
+ event :request_resource do
+ transition any - [:waiting_for_resource] => :waiting_for_resource
+ end
+
event :prepare do
transition any - [:preparing] => :preparing
end
@@ -76,11 +83,11 @@ module Ci
end
end
- def update_status
+ def set_status(new_status)
retry_optimistic_lock(self) do
- new_status = latest_stage_status.to_s
case new_status
when 'created' then nil
+ when 'waiting_for_resource' then request_resource
when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
@@ -97,6 +104,10 @@ module Ci
end
end
+ def update_legacy_status
+ set_status(latest_stage_status.to_s)
+ end
+
def groups
@groups ||= Ci::Group.fabricate(self)
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 68548bd2fdc..85cb3f5b46a 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -11,7 +11,7 @@ module Ci
has_many :trigger_requests
validates :token, presence: true, uniqueness: true
- validates :owner, presence: true, unless: :supports_legacy_tokens?
+ validates :owner, presence: true
before_validation :set_default_values
@@ -31,17 +31,8 @@ module Ci
token[0...4] if token.present?
end
- def legacy?
- self.owner_id.blank?
- end
-
- def supports_legacy_tokens?
- Feature.enabled?(:use_legacy_pipeline_triggers, project)
- end
-
def can_access_project?
- supports_legacy_tokens? && legacy? ||
- Ability.allowed?(self.owner, :create_build, project)
+ Ability.allowed?(self.owner, :create_build, project)
end
end
end
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index 9854ad2ea3e..e86a4597ed8 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -15,24 +15,15 @@ module Clusters
include ::Clusters::Concerns::ApplicationData
include ::Gitlab::Utils::StrongMemoize
- default_value_for :version, VERSION
-
- def set_initial_status
- return unless not_installable?
- return unless cluster&.application_ingress_available?
+ include IgnorableColumns
+ ignore_column :kibana_hostname, remove_with: '12.8', remove_after: '2020-01-22'
- ingress = cluster.application_ingress
- self.status = status_states[:installable] if ingress.external_ip_or_hostname?
- end
+ default_value_for :version, VERSION
def chart
'stable/elastic-stack'
end
- def values
- content_values.to_yaml
- end
-
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: 'elastic-stack',
@@ -78,24 +69,6 @@ module Clusters
private
- def specification
- {
- "kibana" => {
- "ingress" => {
- "hosts" => [kibana_hostname],
- "tls" => [{
- "hosts" => [kibana_hostname],
- "secretName" => "kibana-cert"
- }]
- }
- }
- }
- end
-
- def content_values
- YAML.load_file(chart_values_file).deep_merge!(specification)
- end
-
def post_delete_script
[
Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack")
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index d140649af3c..63f216c7af5 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -14,6 +14,7 @@ module Clusters
include AfterCommitQueue
default_value_for :ingress_type, :nginx
+ default_value_for :modsecurity_enabled, false
default_value_for :version, VERSION
enum ingress_type: {
@@ -41,7 +42,7 @@ module Clusters
end
def allowed_to_uninstall?
- external_ip_or_hostname? && application_jupyter_nil_or_installable? && application_elastic_stack_nil_or_installable?
+ external_ip_or_hostname? && application_jupyter_nil_or_installable?
end
def install_command
@@ -73,7 +74,7 @@ module Clusters
private
def specification
- return {} unless Feature.enabled?(:ingress_modsecurity)
+ return {} unless modsecurity_enabled
{
"controller" => {
@@ -154,10 +155,6 @@ module Clusters
def application_jupyter_nil_or_installable?
cluster.application_jupyter.nil? || cluster.application_jupyter&.installable?
end
-
- def application_elastic_stack_nil_or_installable?
- cluster.application_elastic_stack.nil? || cluster.application_elastic_stack&.installable?
- end
end
end
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index ca93bc15be0..42fa4a6f179 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -5,7 +5,7 @@ require 'securerandom'
module Clusters
module Applications
class Jupyter < ApplicationRecord
- VERSION = '0.9-174bbd5'
+ VERSION = '0.9.0-beta.2'
self.table_name = 'clusters_applications_jupyter'
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 4ac33d4e3be..d24a298b0a6 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -5,7 +5,7 @@ module Clusters
class Prometheus < ApplicationRecord
include PrometheusAdapter
- VERSION = '6.7.3'
+ VERSION = '9.5.2'
self.table_name = 'clusters_applications_prometheus'
@@ -90,7 +90,7 @@ module Clusters
# ensures headers containing auth data are appended to original k8s client options
options = kube_client.rest_client.options.merge(headers: kube_client.headers)
Gitlab::PrometheusClient.new(proxy_url, options)
- rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED
+ rescue Kubeclient::HttpError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::ENETUNREACH
# If users have mistakenly set parameters or removed the depended clusters,
# `proxy_url` could raise an exception because gitlab can not communicate with the cluster.
# Since `PrometheusAdapter#can_query?` is eargely loaded on environement pages in gitlab,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index fd05fd6bab9..a908ca28188 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.11.0'
+ VERSION = '0.12.0'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index f6431f5bac3..b94f2b15846 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -15,7 +15,7 @@ module Clusters
def set_initial_status
return unless not_installable?
- self.status = status_states[:installable] if cluster&.application_helm_available?
+ self.status = status_states[:installable] if cluster&.application_helm_available? || Feature.enabled?(:managed_apps_local_tiller)
end
def can_uninstall?
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 8d38835fb3b..f9101609f89 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -40,12 +40,12 @@ class CommitStatus < ApplicationRecord
scope :latest, -> { where(retried: [false, nil]) }
scope :retried, -> { where(retried: true) }
scope :ordered, -> { order(:name) }
+ scope :ordered_by_stage, -> { order(stage_idx: :asc) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :before_stage, -> (index) { where('stage_idx < ?', index) }
scope :for_stage, -> (index) { where(stage_idx: index) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
- scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) }
scope :for_ids, -> (ids) { where(id: ids) }
scope :for_ref, -> (ref) { where(ref: ref) }
scope :by_name, -> (name) { where(name: name) }
@@ -58,6 +58,10 @@ class CommitStatus < ApplicationRecord
preload(:project, :user)
end
+ scope :with_project_preload, -> do
+ preload(project: :namespace)
+ end
+
scope :with_needs, -> (names = nil) do
needs = Ci::BuildNeed.scoped_build.select(1)
needs = needs.where(name: names) if names
@@ -70,6 +74,15 @@ class CommitStatus < ApplicationRecord
where('NOT EXISTS (?)', needs)
end
+ scope :match_id_and_lock_version, -> (slice) do
+ # it expects that items are an array of attributes to match
+ # each hash needs to have `id` and `lock_version`
+ slice.inject(self) do |relation, item|
+ match = CommitStatus.where(item.slice(:id, :lock_version))
+ relation.or(match)
+ end
+ end
+
# We use `CommitStatusEnums.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
enum_with_nil failure_reason: ::CommitStatusEnums.failure_reasons
@@ -87,6 +100,16 @@ class CommitStatus < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
end
+ before_save if: :status_changed?, unless: :importing? do
+ if Feature.disabled?(:ci_atomic_processing, project)
+ self.processed = nil
+ elsif latest?
+ self.processed = false # force refresh of all dependent ones
+ elsif retried?
+ self.processed = true # retried are considered to be already processed
+ end
+ end
+
state_machine :status do
event :process do
transition [:skipped, :manual] => :created
@@ -96,7 +119,7 @@ class CommitStatus < ApplicationRecord
# A CommitStatus will never have prerequisites, but this event
# is shared by Ci::Build, which cannot progress unless prerequisites
# are satisfied.
- transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites?
+ transition [:created, :skipped, :manual, :scheduled] => :pending, if: :all_met_to_become_pending?
end
event :run do
@@ -104,22 +127,22 @@ class CommitStatus < ApplicationRecord
end
event :skip do
- transition [:created, :preparing, :pending] => :skipped
+ transition [:created, :waiting_for_resource, :preparing, :pending] => :skipped
end
event :drop do
- transition [:created, :preparing, :pending, :running, :scheduled] => :failed
+ transition [:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled] => :failed
end
event :success do
- transition [:created, :preparing, :pending, :running] => :success
+ transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success
end
event :cancel do
- transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled
+ transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :canceled
end
- before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status|
+ before_transition [:created, :waiting_for_resource, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status|
commit_status.queued_at = Time.now
end
@@ -137,19 +160,13 @@ class CommitStatus < ApplicationRecord
end
after_transition do |commit_status, transition|
- next unless commit_status.project
next if transition.loopback?
+ next if commit_status.processed?
+ next unless commit_status.project
commit_status.run_after_commit do
- if pipeline_id
- if complete? || manual?
- PipelineProcessWorker.perform_async(pipeline_id, [id])
- else
- PipelineUpdateWorker.perform_async(pipeline_id)
- end
- end
-
- StageUpdateWorker.perform_async(stage_id)
+ schedule_stage_and_pipeline_update
+
ExpireJobCacheWorker.perform_async(id)
end
end
@@ -178,6 +195,11 @@ class CommitStatus < ApplicationRecord
where(name: names).latest.slow_composite_status || 'success'
end
+ def self.update_as_processed!
+ # Marks items as processed, and increases `lock_version` (Optimisitc Locking)
+ update_all('processed=TRUE, lock_version=COALESCE(lock_version,0)+1')
+ end
+
def locking_enabled?
will_save_change_to_status?
end
@@ -194,6 +216,10 @@ class CommitStatus < ApplicationRecord
calculate_duration
end
+ def latest?
+ !retried?
+ end
+
def playable?
false
end
@@ -218,10 +244,18 @@ class CommitStatus < ApplicationRecord
false
end
+ def all_met_to_become_pending?
+ !any_unmet_prerequisites? && !requires_resource?
+ end
+
def any_unmet_prerequisites?
false
end
+ def requires_resource?
+ false
+ end
+
def auto_canceled?
canceled? && auto_canceled_by_id?
end
@@ -237,4 +271,21 @@ class CommitStatus < ApplicationRecord
v =~ /\d+/ ? v.to_i : v
end
end
+
+ private
+
+ def schedule_stage_and_pipeline_update
+ if Feature.enabled?(:ci_atomic_processing, project)
+ # Atomic Processing requires only single Worker
+ PipelineProcessWorker.perform_async(pipeline_id, [id])
+ else
+ if complete? || manual?
+ PipelineProcessWorker.perform_async(pipeline_id, [id])
+ else
+ PipelineUpdateWorker.perform_async(pipeline_id)
+ end
+
+ StageUpdateWorker.perform_async(stage_id)
+ end
+ end
end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 64df265dc25..3e9b084e784 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -27,13 +27,13 @@ module AtomicInternalId
extend ActiveSupport::Concern
class_methods do
- def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName
+ def has_internal_id(column, scope:, init:, ensure_if: nil, track_if: nil, presence: true) # rubocop:disable Naming/PredicateName
# We require init here to retain the ability to recalculate in the absence of a
- # InternaLId record (we may delete records in `internal_ids` for example).
+ # InternalId record (we may delete records in `internal_ids` for example).
raise "has_internal_id requires a init block, none given." unless init
raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
- before_validation :"track_#{scope}_#{column}!", on: :create
+ before_validation :"track_#{scope}_#{column}!", on: :create, if: track_if
before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if
validates column, presence: presence
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index f229b42ade6..0f2a389f0a3 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -67,7 +67,9 @@ module Awardable
)
).join_sources
- joins(join_clause).group(awardable_table[:id]).reorder("COUNT(award_emoji.id) #{direction}")
+ joins(join_clause).group(awardable_table[:id]).reorder(
+ Arel.sql("COUNT(award_emoji.id) #{direction}")
+ )
end
end
diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb
deleted file mode 100644
index c229358ad17..00000000000
--- a/app/models/concerns/ci/processable.rb
+++ /dev/null
@@ -1,41 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- ##
- # This module implements methods that need to be implemented by CI/CD
- # entities that are supposed to go through pipeline processing
- # services.
- #
- #
- module Processable
- extend ActiveSupport::Concern
-
- included do
- has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build
-
- accepts_nested_attributes_for :needs
-
- scope :preload_needs, -> { preload(:needs) }
- end
-
- def schedulable?
- raise NotImplementedError
- end
-
- def action?
- raise NotImplementedError
- end
-
- def when
- read_attribute(:when) || 'on_success'
- end
-
- def expanded_environment_name
- raise NotImplementedError
- end
-
- def scoped_variables_hash
- raise NotImplementedError
- end
- end
-end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index c01fb4740e5..e06dad38c32 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -5,16 +5,16 @@ module HasStatus
DEFAULT_STATUS = 'created'
BLOCKED_STATUS = %w[manual scheduled].freeze
- AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze
+ AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze
STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
- ACTIVE_STATUSES = %w[preparing pending running].freeze
+ ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
- ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze
+ ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
- scheduled: 8, preparing: 9 }.freeze
+ scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
UnknownStatusError = Class.new(StandardError)
@@ -29,6 +29,7 @@ module HasStatus
manual = scope_relevant.manual.select('count(*)').to_sql
scheduled = scope_relevant.scheduled.select('count(*)').to_sql
preparing = scope_relevant.preparing.select('count(*)').to_sql
+ waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql
pending = scope_relevant.pending.select('count(*)').to_sql
running = scope_relevant.running.select('count(*)').to_sql
skipped = scope_relevant.skipped.select('count(*)').to_sql
@@ -46,6 +47,7 @@ module HasStatus
WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
WHEN (#{running})+(#{pending})>0 THEN 'running'
+ WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource'
WHEN (#{manual})>0 THEN 'manual'
WHEN (#{scheduled})>0 THEN 'scheduled'
WHEN (#{preparing})>0 THEN 'preparing'
@@ -95,6 +97,7 @@ module HasStatus
state_machine :status, initial: :created do
state :created, value: 'created'
+ state :waiting_for_resource, value: 'waiting_for_resource'
state :preparing, value: 'preparing'
state :pending, value: 'pending'
state :running, value: 'running'
@@ -107,6 +110,7 @@ module HasStatus
end
scope :created, -> { with_status(:created) }
+ scope :waiting_for_resource, -> { with_status(:waiting_for_resource) }
scope :preparing, -> { with_status(:preparing) }
scope :relevant, -> { without_status(:created) }
scope :running, -> { with_status(:running) }
@@ -117,8 +121,8 @@ module HasStatus
scope :skipped, -> { with_status(:skipped) }
scope :manual, -> { with_status(:manual) }
scope :scheduled, -> { with_status(:scheduled) }
- scope :alive, -> { with_status(:created, :preparing, :pending, :running) }
- scope :alive_or_scheduled, -> { with_status(:created, :preparing, :pending, :running, :scheduled) }
+ scope :alive, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running) }
+ scope :alive_or_scheduled, -> { with_status(:created, :waiting_for_resource, :preparing, :pending, :running, :scheduled) }
scope :created_or_pending, -> { with_status(:created, :pending) }
scope :running_or_pending, -> { with_status(:running, :pending) }
scope :finished, -> { with_status(:success, :failed, :canceled) }
@@ -126,7 +130,7 @@ module HasStatus
scope :incomplete, -> { without_statuses(completed_statuses) }
scope :cancelable, -> do
- where(status: [:running, :preparing, :pending, :created, :scheduled])
+ where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
end
scope :without_statuses, -> (names) do
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 9e3fba139e3..fe0fad4b9d5 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -13,6 +13,7 @@ module Issuable
include CacheMarkdownField
include Participable
include Mentionable
+ include Milestoneable
include Subscribable
include StripAttribute
include Awardable
@@ -56,7 +57,6 @@ module Issuable
belongs_to :author, class_name: 'User'
belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User'
- belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded?
@@ -89,18 +89,12 @@ module Issuable
# to avoid breaking the existing Issuables which may have their descriptions longer
validates :description, length: { maximum: DESCRIPTION_LENGTH_MAX }, allow_blank: true, on: :create
validate :description_max_length_for_new_records_is_valid, on: :update
- validate :milestone_is_valid
before_validation :truncate_description_on_import!
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
scope :of_projects, ->(ids) { where(project_id: ids) }
- scope :of_milestones, ->(ids) { where(milestone_id: ids) }
- scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
- scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
- scope :any_release, -> { joins_milestone_releases }
- scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :opened, -> { with_state(:opened) }
scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) }
@@ -118,20 +112,6 @@ module Issuable
end
# rubocop:enable GitlabSecurity/SqlInjection
- scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
- scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
- scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
-
- scope :without_release, -> do
- joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id")
- .where('milestone_releases.release_id IS NULL')
- end
-
- scope :joins_milestone_releases, -> do
- joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id
- JOIN releases ON milestone_releases.release_id = releases.id").distinct
- end
-
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :any_label, -> { joins(:label_links).group(:id) }
scope :join_project, -> { joins(:project) }
@@ -164,10 +144,6 @@ module Issuable
private
- def milestone_is_valid
- errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
- end
-
def description_max_length_for_new_records_is_valid
if new_record? && description.length > Issuable::DESCRIPTION_LENGTH_MAX
errors.add(:description, :too_long, count: Issuable::DESCRIPTION_LENGTH_MAX)
@@ -332,10 +308,6 @@ module Issuable
project
end
- def milestone_available?
- project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
- end
-
def assignee_or_author?(user)
author_id == user.id || assignees.exists?(user.id)
end
@@ -482,13 +454,6 @@ module Issuable
def wipless_title_changed(old_title)
old_title != title
end
-
- ##
- # Overridden on EE module
- #
- def supports_milestone?
- respond_to?(:milestone_id)
- end
end
Issuable.prepend_if_ee('EE::Issuable') # rubocop: disable Cop/InjectEnterpriseEditionModule
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
new file mode 100644
index 00000000000..7fb3f95bf0a
--- /dev/null
+++ b/app/models/concerns/milestoneable.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+# == Milestoneable concern
+#
+# Contains functionality related to objects that can be assigned Milestones
+#
+# Used by Issuable
+#
+module Milestoneable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :milestone
+
+ validate :milestone_is_valid
+
+ after_save :write_to_new_milestone_relationship
+
+ scope :of_milestones, ->(ids) { where(milestone_id: ids) }
+ scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
+ scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
+ scope :any_release, -> { joins_milestone_releases }
+ scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
+
+ scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
+ scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
+ scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) }
+
+ scope :without_release, -> do
+ joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id")
+ .where('milestone_releases.release_id IS NULL')
+ end
+
+ scope :joins_milestone_releases, -> do
+ joins("JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id
+ JOIN releases ON milestone_releases.release_id = releases.id").distinct
+ end
+
+ private
+
+ def milestone_is_valid
+ errors.add(:milestone_id, message: "is invalid") if respond_to?(:milestone_id) && milestone_id.present? && !milestone_available?
+ end
+
+ def write_to_new_milestone_relationship
+ self.milestones = [milestone].compact if supports_milestone? && saved_change_to_milestone_id?
+ end
+ end
+
+ def milestone_available?
+ project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
+ end
+
+ ##
+ # Overridden on EE module
+ #
+ def supports_milestone?
+ respond_to?(:milestone_id)
+ end
+end
+
+Milestoneable.prepend_if_ee('EE::Milestoneable')
diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb
index 551a2e56ecf..eac676f30a5 100644
--- a/app/models/concerns/project_features_compatibility.rb
+++ b/app/models/concerns/project_features_compatibility.rb
@@ -50,6 +50,10 @@ module ProjectFeaturesCompatibility
write_feature_attribute_string(:merge_requests_access_level, value)
end
+ def forking_access_level=(value)
+ write_feature_attribute_string(:forking_access_level, value)
+ end
+
def issues_access_level=(value)
write_feature_attribute_string(:issues_access_level, value)
end
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
index d9a7f0a96dc..cddca72f91f 100644
--- a/app/models/concerns/protected_ref.rb
+++ b/app/models/concerns/protected_ref.rb
@@ -10,6 +10,8 @@ module ProtectedRef
validates :project, presence: true
delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+
+ scope :for_project, ->(project) { where(project: project) }
end
def commit
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 693f9ab8dc5..4b9896343c6 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -1,78 +1,7 @@
# frozen_string_literal: true
-# The ReactiveCaching concern is used to fetch some data in the background and
-# store it in the Rails cache, keeping it up-to-date for as long as it is being
-# requested. If the data hasn't been requested for +reactive_cache_lifetime+,
-# it stop being refreshed, and then be removed.
-#
-# Example of use:
-#
-# class Foo < ApplicationRecord
-# include ReactiveCaching
-#
-# after_save :clear_reactive_cache!
-#
-# def calculate_reactive_cache
-# # Expensive operation here. The return value of this method is cached
-# end
-#
-# def result
-# with_reactive_cache do |data|
-# # ...
-# end
-# end
-# end
-#
-# In this example, the first time `#result` is called, it will return `nil`.
-# However, it will enqueue a background worker to call `#calculate_reactive_cache`
-# and set an initial cache lifetime of ten minutes.
-#
-# The background worker needs to find or generate the object on which
-# `with_reactive_cache` was called.
-# The default behaviour can be overridden by defining a custom
-# `reactive_cache_worker_finder`.
-# Otherwise the background worker will use the class name and primary key to get
-# the object using the ActiveRecord find_by method.
-#
-# class Bar
-# include ReactiveCaching
-#
-# self.reactive_cache_key = ->() { ["bar", "thing"] }
-# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
-#
-# def self.from_cache(var1, var2)
-# # This method will be called by the background worker with "bar1" and
-# # "bar2" as arguments.
-# new(var1, var2)
-# end
-#
-# def initialize(var1, var2)
-# # ...
-# end
-#
-# def calculate_reactive_cache
-# # Expensive operation here. The return value of this method is cached
-# end
-#
-# def result
-# with_reactive_cache("bar1", "bar2") do |data|
-# # ...
-# end
-# end
-# end
-#
-# Each time the background job completes, it stores the return value of
-# `#calculate_reactive_cache`. It is also re-enqueued to run again after
-# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
-# Calculations are never run concurrently.
-#
-# Calling `#result` while a value is in the cache will call the block given to
-# `#with_reactive_cache`, yielding the cached value. It will also extend the
-# lifetime by `reactive_cache_lifetime`.
-#
-# Once the lifetime has expired, no more background jobs will be enqueued and
-# calling `#result` will again return `nil` - starting the process all over
-# again
+# The usage of the ReactiveCaching module is documented here:
+# https://docs.gitlab.com/ee/development/utilities.html#reactivecaching
module ReactiveCaching
extend ActiveSupport::Concern
@@ -122,6 +51,14 @@ module ReactiveCaching
end
end
+ # This method is used for debugging purposes and should not be used otherwise.
+ def without_reactive_cache(*args, &blk)
+ return with_reactive_cache(*args, &blk) unless Rails.env.development?
+
+ data = self.class.reactive_cache_worker_finder.call(id, *args).calculate_reactive_cache(*args)
+ yield data
+ end
+
def clear_reactive_cache!(*args)
Rails.cache.delete(full_reactive_cache_key(*args))
Rails.cache.delete(alive_reactive_cache_key(*args))
diff --git a/app/models/concerns/referable.rb b/app/models/concerns/referable.rb
index 4a506146de3..3b0606aa425 100644
--- a/app/models/concerns/referable.rb
+++ b/app/models/concerns/referable.rb
@@ -74,7 +74,7 @@ module Referable
#{Regexp.escape(Gitlab.config.gitlab.url)}
\/#{Project.reference_pattern}
(?:\/\-)?
- \/#{Regexp.escape(route)}
+ \/#{route.is_a?(Regexp) ? route : Regexp.escape(route)}
\/#{pattern}
(?<path>
(\/[a-z0-9_=-]+)*
diff --git a/app/models/concerns/schedulable.rb b/app/models/concerns/schedulable.rb
new file mode 100644
index 00000000000..6fdca4f50c3
--- /dev/null
+++ b/app/models/concerns/schedulable.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Schedulable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :runnable_schedules, -> { active.where("next_run_at < ?", Time.zone.now) }
+
+ before_save :set_next_run_at
+ end
+
+ def schedule_next_run!
+ save! # with set_next_run_at
+ rescue ActiveRecord::RecordInvalid
+ update_column(:next_run_at, nil) # update without validation
+ end
+
+ def set_next_run_at
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb
index 1bd1ad177a2..9dfe1b77829 100644
--- a/app/models/concerns/sha256_attribute.rb
+++ b/app/models/concerns/sha256_attribute.rb
@@ -39,11 +39,7 @@ module Sha256Attribute
end
def database_exists?
- ApplicationRecord.connection
-
- true
- rescue
- false
+ Gitlab::Database.exists?
end
end
end
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
index c5826f58966..c807dcbf418 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -39,11 +39,7 @@ module ShaAttribute
end
def database_exists?
- ApplicationRecord.connection
-
- true
- rescue
- false
+ Gitlab::Database.exists?
end
end
end
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 98842242eb6..5debfa6f834 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -15,11 +15,11 @@ module Taskable
INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze
ITEM_PATTERN = %r{
^
- (?:(?:>\s{0,4})*) # optional blockquote characters
- \s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
- \s+ # whitespace prefix has to be always presented for a list item
- (\[\s\]|\[[xX]\]) # checkbox
- (\s.+) # followed by whitespace and some text.
+ (?:(?:>\s{0,4})*) # optional blockquote characters
+ (?:\s*(?:[-+*]|(?:\d+\.)))+ # list prefix (one or more) required - task item has to be always in a list
+ \s+ # whitespace prefix has to be always presented for a list item
+ (\[\s\]|\[[xX]\]) # checkbox
+ (\s.+) # followed by whitespace and some text.
}x.freeze
def self.get_tasks(content)
diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb
index f60a0179c83..c929a78a7f9 100644
--- a/app/models/container_expiration_policy.rb
+++ b/app/models/container_expiration_policy.rb
@@ -1,14 +1,21 @@
# frozen_string_literal: true
class ContainerExpirationPolicy < ApplicationRecord
+ include Schedulable
+
belongs_to :project, inverse_of: :container_expiration_policy
+ delegate :container_repositories, to: :project
+
validates :project, presence: true
validates :enabled, inclusion: { in: [true, false] }
validates :cadence, presence: true, inclusion: { in: ->(_) { self.cadence_options.stringify_keys } }
validates :older_than, inclusion: { in: ->(_) { self.older_than_options.stringify_keys } }, allow_nil: true
validates :keep_n, inclusion: { in: ->(_) { self.keep_n_options.keys } }, allow_nil: true
+ scope :active, -> { where(enabled: true) }
+ scope :preloaded, -> { preload(:project) }
+
def self.keep_n_options
{
1 => _('%{tags} tag per image name') % { tags: 1 },
@@ -38,4 +45,8 @@ class ContainerExpirationPolicy < ApplicationRecord
'90d': _('%{days} days until tags are automatically removed') % { days: 90 }
}
end
+
+ def set_next_run_at
+ self.next_run_at = Time.zone.now + ChronicDuration.parse(cadence).seconds
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 994e69912b6..e0daf692665 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -5,6 +5,7 @@ class Deployment < ApplicationRecord
include IidRoutes
include AfterCommitQueue
include UpdatedAtFilterable
+ include Importable
include Gitlab::Utils::StrongMemoize
belongs_to :project, required: true
@@ -17,16 +18,23 @@ class Deployment < ApplicationRecord
has_many :merge_requests,
through: :deployment_merge_requests
- has_internal_id :iid, scope: :project, init: ->(s) do
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) do
Deployment.where(project: s.project).maximum(:iid) if s&.project
end
validates :sha, presence: true
validates :ref, presence: true
+ validate :valid_sha, on: :create
+ validate :valid_ref, on: :create
delegate :name, to: :environment, prefix: true
scope :for_environment, -> (environment) { where(environment_id: environment) }
+ scope :for_environment_name, -> (name) do
+ joins(:environment).where(environments: { name: name })
+ end
+
+ scope :for_status, -> (status) { where(status: status) }
scope :visible, -> { where(status: %i[running success failed canceled]) }
@@ -210,10 +218,14 @@ class Deployment < ApplicationRecord
# We don't use `Gitlab::Database.bulk_insert` here so that we don't need to
# first pluck lots of IDs into memory.
+ #
+ # We also ignore any duplicates so this method can be called multiple times
+ # for the same deployment, only inserting any missing merge requests.
DeploymentMergeRequest.connection.execute(<<~SQL)
INSERT INTO #{DeploymentMergeRequest.table_name}
(merge_request_id, deployment_id)
#{select}
+ ON CONFLICT DO NOTHING
SQL
end
@@ -234,6 +246,18 @@ class Deployment < ApplicationRecord
end
end
+ def valid_sha
+ return if project&.commit(sha)
+
+ errors.add(:sha, _('The commit does not exist'))
+ end
+
+ def valid_ref
+ return if project&.commit(ref)
+
+ errors.add(:ref, _('The branch or tag does not exist'))
+ end
+
private
def ref_path
diff --git a/app/models/deployment_metrics.rb b/app/models/deployment_metrics.rb
index 2056c8bc59c..c5f8b03f25b 100644
--- a/app/models/deployment_metrics.rb
+++ b/app/models/deployment_metrics.rb
@@ -13,18 +13,18 @@ class DeploymentMetrics
end
def has_metrics?
- deployment.success? && prometheus_adapter&.can_query?
+ deployment.success? && prometheus_adapter&.configured?
end
def metrics
- return {} unless has_metrics?
+ return {} unless has_metrics_and_can_query?
metrics = prometheus_adapter.query(:deployment, deployment)
metrics&.merge(deployment_time: deployment.finished_at.to_i) || {}
end
def additional_metrics
- return {} unless has_metrics?
+ return {} unless has_metrics_and_can_query?
metrics = prometheus_adapter.query(:additional_metrics_deployment, deployment)
metrics&.merge(deployment_time: deployment.finished_at.to_i) || {}
@@ -34,17 +34,11 @@ class DeploymentMetrics
def prometheus_adapter
strong_memoize(:prometheus_adapter) do
- service = project.find_or_initialize_service('prometheus')
-
- if service.can_query?
- service
- else
- cluster_prometheus
- end
+ Gitlab::Prometheus::Adapter.new(project, cluster).prometheus_adapter
end
end
- def cluster_prometheus
- cluster.application_prometheus if cluster&.application_prometheus_available?
+ def has_metrics_and_can_query?
+ has_metrics? && prometheus_adapter.can_query?
end
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 686d06d3ee0..939d8bc4bef 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -23,6 +23,12 @@ class DiffNote < Note
before_validation :set_line_code, if: :on_text?, unless: :importing?
after_save :keep_around_commits, unless: :importing?
+
+ NoteDiffFileCreationError = Class.new(StandardError)
+
+ DIFF_LINE_NOT_FOUND_MESSAGE = "Failed to find diff line for: %{file_path}, old_line: %{old_line}, new_line: %{new_line}"
+ DIFF_FILE_NOT_FOUND_MESSAGE = "Failed to find diff file"
+
after_commit :create_diff_file, on: :create
def discussion_class(*)
@@ -33,7 +39,16 @@ class DiffNote < Note
return unless should_create_diff_file?
diff_file = fetch_diff_file
+ raise NoteDiffFileCreationError, DIFF_FILE_NOT_FOUND_MESSAGE unless diff_file
+
diff_line = diff_file.line_for_position(self.original_position)
+ unless diff_line
+ raise NoteDiffFileCreationError, DIFF_LINE_NOT_FOUND_MESSAGE % {
+ file_path: diff_file.file_path,
+ old_line: original_position.old_line,
+ new_line: original_position.new_line
+ }
+ end
creation_params = diff_file.diff.to_hash
.except(:too_large)
@@ -110,19 +125,20 @@ class DiffNote < Note
def fetch_diff_file
return note_diff_file.raw_diff_file if note_diff_file
- file =
- if created_at_diff?(noteable.diff_refs)
- # We're able to use the already persisted diffs (Postgres) if we're
- # presenting a "current version" of the MR discussion diff.
- # So no need to make an extra Gitaly diff request for it.
- # As an extra benefit, the returned `diff_file` already
- # has `highlighted_diff_lines` data set from Redis on
- # `Diff::FileCollection::MergeRequestDiff`.
- noteable.diffs(original_position.diff_options).diff_files.first
- else
- original_position.diff_file(repository)
- end
+ if created_at_diff?(noteable.diff_refs)
+ # We're able to use the already persisted diffs (Postgres) if we're
+ # presenting a "current version" of the MR discussion diff.
+ # So no need to make an extra Gitaly diff request for it.
+ # As an extra benefit, the returned `diff_file` already
+ # has `highlighted_diff_lines` data set from Redis on
+ # `Diff::FileCollection::MergeRequestDiff`.
+ file = noteable.diffs(original_position.diff_options).diff_files.first
+ # if line is not found in persisted diffs, fallback and retrieve file from repository using gitaly
+ # This is required because of https://gitlab.com/gitlab-org/gitlab/issues/42676
+ file = nil if file&.line_for_position(original_position).nil? && importing?
+ end
+ file ||= original_position.diff_file(repository)
file&.unfold_diff_lines(position)
file
diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb
index 22c8fe73563..75aa51348c8 100644
--- a/app/models/diff_viewer/base.rb
+++ b/app/models/diff_viewer/base.rb
@@ -4,7 +4,7 @@ module DiffViewer
class Base
PARTIAL_PATH_PREFIX = 'projects/diffs/viewers'
- class_attribute :partial_name, :type, :extensions, :file_types, :binary, :switcher_icon, :switcher_title
+ class_attribute :partial_name, :type, :extensions, :binary, :switcher_icon, :switcher_title
# These limits relate to the sum of the old and new blob sizes.
# Limits related to the actual size of the diff are enforced in Gitlab::Diff::File.
@@ -50,7 +50,6 @@ module DiffViewer
return true if blob.nil?
return false if verify_binary && binary? != blob.binary_in_repo?
return true if extensions&.include?(blob.extension)
- return true if file_types&.include?(blob.file_type)
false
end
@@ -89,7 +88,7 @@ module DiffViewer
{
viewer: switcher_title,
reason: render_error_reason,
- options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or '))
+ options: Gitlab::Utils.to_exclusive_sentence(render_error_options)
}
end
diff --git a/app/models/diff_viewer/collapsed.rb b/app/models/diff_viewer/collapsed.rb
new file mode 100644
index 00000000000..b533bd8b88d
--- /dev/null
+++ b/app/models/diff_viewer/collapsed.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module DiffViewer
+ class Collapsed < Base
+ include Simple
+ include Static
+
+ self.partial_name = 'collapsed'
+ end
+end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index b928dcb21a6..2d480345b5a 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -48,13 +48,14 @@ class Environment < ApplicationRecord
scope :available, -> { with_state(:available) }
scope :stopped, -> { with_state(:stopped) }
+
scope :order_by_last_deployed_at, -> do
- max_deployment_id_sql =
- Deployment.select(Deployment.arel_table[:id].maximum)
- .where(Deployment.arel_table[:environment_id].eq(arel_table[:id]))
- .to_sql
order(Gitlab::Database.nulls_first_order("(#{max_deployment_id_sql})", 'ASC'))
end
+ scope :order_by_last_deployed_at_desc, -> do
+ order(Gitlab::Database.nulls_last_order("(#{max_deployment_id_sql})", 'DESC'))
+ end
+
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
scope :preload_cluster, -> { preload(last_deployment: :cluster) }
@@ -90,6 +91,12 @@ class Environment < ApplicationRecord
end
end
+ def self.max_deployment_id_sql
+ Deployment.select(Deployment.arel_table[:id].maximum)
+ .where(Deployment.arel_table[:environment_id].eq(arel_table[:id]))
+ .to_sql
+ end
+
def self.pluck_names
pluck(:name)
end
@@ -197,11 +204,15 @@ class Environment < ApplicationRecord
end
def has_metrics?
- available? && prometheus_adapter&.configured?
+ available? && (prometheus_adapter&.configured? || has_sample_metrics?)
+ end
+
+ def has_sample_metrics?
+ !!ENV['USE_SAMPLE_METRICS']
end
def metrics
- prometheus_adapter.query(:environment, self) if has_metrics? && prometheus_adapter.can_query?
+ prometheus_adapter.query(:environment, self) if has_metrics_and_can_query?
end
def prometheus_status
@@ -209,16 +220,14 @@ class Environment < ApplicationRecord
end
def additional_metrics(*args)
- return unless has_metrics?
+ return unless has_metrics_and_can_query?
prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f))
end
- # rubocop: disable CodeReuse/ServiceClass
def prometheus_adapter
- @prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).prometheus_adapter
+ @prometheus_adapter ||= Gitlab::Prometheus::Adapter.new(project, deployment_platform&.cluster).prometheus_adapter
end
- # rubocop: enable CodeReuse/ServiceClass
def slug
super.presence || generate_slug
@@ -278,6 +287,10 @@ class Environment < ApplicationRecord
private
+ def has_metrics_and_can_query?
+ has_metrics? && prometheus_adapter.can_query?
+ end
+
def generate_slug
self.slug = Gitlab::Slug::Environment.new(name).generate
end
diff --git a/app/models/epic.rb b/app/models/epic.rb
index 8222bbf9656..1203c6c1fc3 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -5,7 +5,7 @@
class Epic < ApplicationRecord
include IgnorableColumns
- ignore_column :milestone_id, remove_after: '2019-12-15', remove_with: '12.7'
+ ignore_column :milestone_id, remove_after: '2020-02-01', remove_with: '12.8'
def self.link_reference_pattern
nil
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 6a9986e806b..a904cf4ac46 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -4,6 +4,7 @@ module ErrorTracking
class ProjectErrorTrackingSetting < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
+ include Gitlab::Routing
SENTRY_API_ERROR_TYPE_BAD_REQUEST = 'bad_request_for_sentry_api'
SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response'
@@ -101,34 +102,33 @@ module ErrorTracking
end
end
+ def update_issue(opts = {} )
+ handle_exceptions do
+ { updated: sentry_client.update_issue(opts) }
+ end
+ end
+
def calculate_reactive_cache(request, opts)
- case request
- when 'list_issues'
- sentry_client.list_issues(**opts.symbolize_keys)
- when 'issue_details'
- {
- issue: sentry_client.issue_details(**opts.symbolize_keys)
- }
- when 'issue_latest_event'
- {
- latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys)
- }
+ handle_exceptions do
+ case request
+ when 'list_issues'
+ sentry_client.list_issues(**opts.symbolize_keys)
+ when 'issue_details'
+ issue = sentry_client.issue_details(**opts.symbolize_keys)
+ { issue: add_gitlab_issue_details(issue) }
+ when 'issue_latest_event'
+ {
+ latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys)
+ }
+ end
end
- rescue Sentry::Client::Error => e
- { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE }
- rescue Sentry::Client::MissingKeysError => e
- { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
- rescue Sentry::Client::ResponseInvalidSizeError => e
- { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE }
- rescue Sentry::Client::BadRequestError => e
- { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST }
end
# http://HOST/api/0/projects/ORG/PROJECT
# ->
# http://HOST/ORG/PROJECT
def self.extract_sentry_external_url(url)
- url.sub('api/0/projects/', '')
+ url&.sub('api/0/projects/', '')
end
def api_host
@@ -140,6 +140,36 @@ module ErrorTracking
private
+ def add_gitlab_issue_details(issue)
+ issue.gitlab_commit = match_gitlab_commit(issue.first_release_version)
+ issue.gitlab_commit_path = project_commit_path(project, issue.gitlab_commit) if issue.gitlab_commit
+
+ issue
+ end
+
+ def match_gitlab_commit(release_version)
+ return unless release_version
+
+ commit = project.repository.commit(release_version)
+
+ commit&.id
+ end
+
+ def handle_exceptions
+ yield
+ rescue Sentry::Client::Error => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE }
+ rescue Sentry::Client::MissingKeysError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
+ rescue Sentry::Client::ResponseInvalidSizeError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE }
+ rescue Sentry::Client::BadRequestError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_BAD_REQUEST }
+ rescue StandardError => e
+ Gitlab::ErrorTracking.track_exception(e)
+ { error: 'Unexpected Error' }
+ end
+
def project_name_from_slug
@project_name_from_slug ||= project_slug_from_api_url&.titleize
end
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index 4778f74568e..4768506b8fa 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -30,17 +30,24 @@ class EventCollection
relation = if groups
project_and_group_events
else
- relation_with_join_lateral('project_id', projects)
+ project_events
end
relation = paginate_events(relation)
relation.with_associations.to_a
end
+ def all_project_events
+ Event.from_union([project_events]).recent
+ end
+
private
+ def project_events
+ relation_with_join_lateral('project_id', projects)
+ end
+
def project_and_group_events
- project_events = relation_with_join_lateral('project_id', projects)
group_events = relation_with_join_lateral('group_id', groups)
Event.from_union([project_events, group_events]).recent
diff --git a/app/models/group.rb b/app/models/group.rb
index 8289d4f099c..b642b177df1 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -57,6 +57,8 @@ class Group < Namespace
has_one :import_export_upload
+ has_many :import_failures, inverse_of: :group
+
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
@@ -420,6 +422,12 @@ class Group < Namespace
GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last
end
+ def related_group_ids
+ [id,
+ *ancestors.pluck(:id),
+ *shared_with_group_links.pluck(:shared_with_group_id)]
+ end
+
def hashed_storage?(_feature)
false
end
@@ -490,7 +498,7 @@ class Group < Namespace
end
def max_member_access_for_user_from_shared_groups(user)
- return unless Feature.enabled?(:share_group_with_group)
+ return unless Feature.enabled?(:share_group_with_group, default_enabled: true)
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb
index 4b279b7af5b..5a0d9b08cb0 100644
--- a/app/models/group_group_link.rb
+++ b/app/models/group_group_link.rb
@@ -20,4 +20,8 @@ class GroupGroupLink < ApplicationRecord
def self.default_access
Gitlab::Access::DEVELOPER
end
+
+ def human_access
+ Gitlab::Access.human_access(self.group_access)
+ end
end
diff --git a/app/models/import_failure.rb b/app/models/import_failure.rb
index 998572853d3..a1e03218640 100644
--- a/app/models/import_failure.rb
+++ b/app/models/import_failure.rb
@@ -2,6 +2,8 @@
class ImportFailure < ApplicationRecord
belongs_to :project
+ belongs_to :group
- validates :project, presence: true
+ validates :project, presence: true, unless: :group
+ validates :group, presence: true, unless: :project
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 88df3baa809..bf600278162 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -31,7 +31,10 @@ class Issue < ApplicationRecord
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
- has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.issues&.maximum(:iid) }
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
+
+ has_many :issue_milestones
+ has_many :milestones, through: :issue_milestones
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
@@ -75,8 +78,8 @@ class Issue < ApplicationRecord
ignore_column :state, remove_with: '12.7', remove_after: '2019-12-22'
- after_commit :expire_etag_cache
- after_save :ensure_metrics, unless: :imported?
+ after_commit :expire_etag_cache, unless: :importing?
+ after_save :ensure_metrics, unless: :importing?
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
diff --git a/app/models/issue_milestone.rb b/app/models/issue_milestone.rb
new file mode 100644
index 00000000000..da030077d87
--- /dev/null
+++ b/app/models/issue_milestone.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class IssueMilestone < ApplicationRecord
+ belongs_to :milestone
+ belongs_to :issue
+end
diff --git a/app/models/key.rb b/app/models/key.rb
index e549c59b58f..71188f210bb 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -142,13 +142,9 @@ class Key < ApplicationRecord
end
def forbidden_key_type_message
- allowed_types =
- Gitlab::CurrentSettings
- .allowed_key_types
- .map(&:upcase)
- .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+ allowed_types = Gitlab::CurrentSettings.allowed_key_types.map(&:upcase)
- "type is forbidden. Must be #{allowed_types}"
+ "type is forbidden. Must be #{Gitlab::Utils.to_exclusive_sentence(allowed_types)}"
end
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 2280c5280d5..7162ba08a76 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -31,10 +31,13 @@ class MergeRequest < ApplicationRecord
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
- has_internal_id :iid, scope: :target_project, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
+ has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
has_many :merge_request_diffs
+ has_many :merge_request_milestones
+ has_many :milestones, through: :merge_request_milestones
+
has_one :merge_request_diff,
-> { order('merge_request_diffs.id DESC') }, inverse_of: :merge_request
@@ -94,8 +97,8 @@ class MergeRequest < ApplicationRecord
after_create :ensure_merge_request_diff
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
- after_save :ensure_metrics
- after_commit :expire_etag_cache
+ after_save :ensure_metrics, unless: :importing?
+ after_commit :expire_etag_cache, unless: :importing?
# When this attribute is true some MR validation is ignored
# It allows us to close or modify broken merge requests
@@ -255,12 +258,10 @@ class MergeRequest < ApplicationRecord
alias_method :issuing_parent, :target_project
delegate :active?, to: :head_pipeline, prefix: true, allow_nil: true
- delegate :success?, to: :actual_head_pipeline, prefix: true, allow_nil: true
+ delegate :success?, :active?, to: :actual_head_pipeline, prefix: true, allow_nil: true
RebaseLockTimeout = Class.new(StandardError)
- REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.")
-
def self.reference_prefix
'!'
end
@@ -448,7 +449,7 @@ class MergeRequest < ApplicationRecord
# Set off a rebase asynchronously, atomically updating the `rebase_jid` of
# the MR so that the status of the operation can be tracked.
- def rebase_async(user_id)
+ def rebase_async(user_id, skip_ci: false)
with_rebase_lock do
raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress?
@@ -457,7 +458,7 @@ class MergeRequest < ApplicationRecord
# attribute is set *and* that the sidekiq job is still running. So a JID
# for a completed RebaseWorker is equivalent to a nil JID.
jid = Sidekiq::Worker.skipping_transaction_check do
- RebaseWorker.perform_async(id, user_id)
+ RebaseWorker.perform_async(id, user_id, skip_ci)
end
update_column(:rebase_jid, jid)
@@ -1122,22 +1123,18 @@ class MergeRequest < ApplicationRecord
actual_head_pipeline.success?
end
- def environments_for(current_user)
+ def environments_for(current_user, latest: false)
return [] unless diff_head_commit
- @environments ||= Hash.new do |h, current_user|
- envs = EnvironmentsFinder.new(target_project, current_user,
- ref: target_branch, commit: diff_head_commit, with_tags: true).execute
+ envs = EnvironmentsFinder.new(target_project, current_user,
+ ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute
- if source_project
- envs.concat EnvironmentsFinder.new(source_project, current_user,
- ref: source_branch, commit: diff_head_commit).execute
- end
-
- h[current_user] = envs.uniq
+ if source_project
+ envs.concat EnvironmentsFinder.new(source_project, current_user,
+ ref: source_branch, commit: diff_head_commit, find_latest: latest).execute
end
- @environments[current_user]
+ envs.uniq
end
##
@@ -1515,7 +1512,7 @@ class MergeRequest < ApplicationRecord
end
rescue ActiveRecord::LockWaitTimeout => e
Gitlab::ErrorTracking.track_exception(e)
- raise RebaseLockTimeout, REBASE_LOCK_MESSAGE
+ raise RebaseLockTimeout, _('Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.')
end
def source_project_variables
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 71a344e69e3..fa633a1a725 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -138,7 +138,7 @@ class MergeRequestDiff < ApplicationRecord
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
- after_create_commit :set_as_latest_diff
+ after_create_commit :set_as_latest_diff, unless: :importing?
after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
diff --git a/app/models/merge_request_milestone.rb b/app/models/merge_request_milestone.rb
new file mode 100644
index 00000000000..4fa1d1dcb33
--- /dev/null
+++ b/app/models/merge_request_milestone.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class MergeRequestMilestone < ApplicationRecord
+ belongs_to :milestone
+ belongs_to :merge_request
+end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 987373aaf1b..5da92fc4bc5 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -17,6 +17,7 @@ class Milestone < ApplicationRecord
include StripAttribute
include Milestoneish
include FromUnion
+ include Importable
include Gitlab::SQL::Pattern
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
@@ -30,14 +31,17 @@ class Milestone < ApplicationRecord
has_many :milestone_releases
has_many :releases, through: :milestone_releases
- has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
- has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
+ has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
+ has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :issue_milestones
+ has_many :merge_request_milestones
+
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index d5a7c172fec..621a98e9ab6 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -46,6 +46,8 @@ class Namespace < ApplicationRecord
length: { maximum: 255 },
namespace_path: true
+ validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
+
validate :nesting_level_allowed
validates_associated :runners
@@ -184,7 +186,11 @@ class Namespace < ApplicationRecord
# any ancestor can disable emails for all descendants
def emails_disabled?
strong_memoize(:emails_disabled) do
- self_and_ancestors.where(emails_disabled: true).exists?
+ if parent_id
+ self_and_ancestors.where(emails_disabled: true).exists?
+ else
+ !!emails_disabled
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index cfa7ba98081..7731b477ad0 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -152,9 +152,7 @@ class Note < ApplicationRecord
scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }
- after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
- before_validation :set_discussion_id, on: :create
after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing?
after_save :expire_etag_cache, unless: :importing?
after_save :touch_noteable, unless: :importing?
@@ -394,7 +392,7 @@ class Note < ApplicationRecord
# See `Discussion.override_discussion_id` for details.
def discussion_id(noteable = nil)
- discussion_class(noteable).override_discussion_id(self) || super()
+ discussion_class(noteable).override_discussion_id(self) || super() || ensure_discussion_id
end
# Returns a discussion containing just this note.
@@ -533,17 +531,13 @@ class Note < ApplicationRecord
end
def ensure_discussion_id
- return unless self.persisted?
- # Needed in case the SELECT statement doesn't ask for `discussion_id`
- return unless self.has_attribute?(:discussion_id)
- return if self.discussion_id
+ return if self.attribute_present?(:discussion_id)
- set_discussion_id
- update_column(:discussion_id, self.discussion_id)
+ self.discussion_id = derive_discussion_id
end
- def set_discussion_id
- self.discussion_id ||= discussion_class.discussion_id(self)
+ def derive_discussion_id
+ discussion_class.discussion_id(self)
end
def all_referenced_mentionables_allowed?(user)
diff --git a/app/models/project.rb b/app/models/project.rb
index 3f6c2d6a448..c48360290c7 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -63,10 +63,6 @@ class Project < ApplicationRecord
cache_markdown_field :description, pipeline: :description
- # TODO: remove once GitLab 12.5 is released
- # https://gitlab.com/gitlab-org/gitlab/issues/34638
- ignore_column :merge_requests_require_code_owner_approval, remove_after: '2019-12-01', remove_with: '12.6'
-
default_value_for :archived, false
default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
@@ -79,6 +75,7 @@ class Project < ApplicationRecord
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
default_value_for :remove_source_branch_after_merge, true
+ default_value_for :autoclose_referenced_issues, true
default_value_for(:ci_config_path) { Gitlab::CurrentSettings.default_ci_config_path }
add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
@@ -285,6 +282,7 @@ class Project < ApplicationRecord
has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule'
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
+ has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project
has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
@@ -319,10 +317,12 @@ class Project < ApplicationRecord
accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
accepts_nested_attributes_for :grafana_integration, update_only: true, allow_destroy: true
- delegate :feature_available?, :builds_enabled?, :wiki_enabled?, :merge_requests_enabled?,
- :issues_enabled?, :pages_enabled?, :public_pages?, :private_pages?,
- :merge_requests_access_level, :issues_access_level, :wiki_access_level,
- :snippets_access_level, :builds_access_level, :repository_access_level,
+ delegate :feature_available?, :builds_enabled?, :wiki_enabled?,
+ :merge_requests_enabled?, :forking_enabled?, :issues_enabled?,
+ :pages_enabled?, :public_pages?, :private_pages?,
+ :merge_requests_access_level, :forking_access_level, :issues_access_level,
+ :wiki_access_level, :snippets_access_level, :builds_access_level,
+ :repository_access_level,
to: :project_feature, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
@@ -334,7 +334,7 @@ class Project < ApplicationRecord
delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
delegate :add_master, to: :team # @deprecated
delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
- delegate :root_ancestor, :actual_limits, to: :namespace, allow_nil: true
+ delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
@@ -374,6 +374,7 @@ class Project < ApplicationRecord
inclusion: { in: ->(_object) { Gitlab.config.repositories.storages.keys } }
validates :variables, variable_duplicates: { scope: :environment_scope }
validates :bfg_object_map, file_size: { maximum: :max_attachment_size }
+ validates :max_artifacts_size, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
@@ -681,6 +682,12 @@ class Project < ApplicationRecord
end
end
+ def autoclose_referenced_issues
+ return true if super.nil?
+
+ super
+ end
+
def preload_protected_branches
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels])
@@ -1320,7 +1327,7 @@ class Project < ApplicationRecord
end
def has_active_hooks?(hooks_scope = :push_hooks)
- hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::Plugin.any?
+ hooks.hooks_for(hooks_scope).any? || SystemHook.hooks_for(hooks_scope).any? || Gitlab::FileHook.any?
end
def has_active_services?(hooks_scope = :push_hooks)
@@ -1509,7 +1516,7 @@ class Project < ApplicationRecord
end
def default_branch
- @default_branch ||= repository.root_ref if repository.exists?
+ @default_branch ||= repository.root_ref
end
def reload_default_branch
@@ -1927,6 +1934,7 @@ class Project < ApplicationRecord
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI', value: 'true')
.append(key: 'GITLAB_CI', value: 'true')
+ .append(key: 'CI_SERVER_URL', value: Gitlab.config.gitlab.url)
.append(key: 'CI_SERVER_HOST', value: Gitlab.config.gitlab.host)
.append(key: 'CI_SERVER_NAME', value: 'GitLab')
.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index b292d39dae7..1dd65c76258 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -4,7 +4,6 @@ class ProjectCiCdSetting < ApplicationRecord
include IgnorableColumns
# https://gitlab.com/gitlab-org/gitlab/issues/36651
ignore_column :merge_trains_enabled, remove_with: '12.7', remove_after: '2019-12-22'
-
belongs_to :project, inverse_of: :ci_cd_settings
# The version of the schema that first introduced this model/table.
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 4973c7761c1..a9753c3c53a 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -22,7 +22,7 @@ class ProjectFeature < ApplicationRecord
ENABLED = 20
PUBLIC = 30
- FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze
+ FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages).freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
@@ -92,12 +92,19 @@ class ProjectFeature < ApplicationRecord
default_value_for :builds_access_level, value: ENABLED, allows_nil: false
default_value_for :issues_access_level, value: ENABLED, allows_nil: false
+ default_value_for :forking_access_level, value: ENABLED, allows_nil: false
default_value_for :merge_requests_access_level, value: ENABLED, allows_nil: false
default_value_for :snippets_access_level, value: ENABLED, allows_nil: false
default_value_for :wiki_access_level, value: ENABLED, allows_nil: false
default_value_for :repository_access_level, value: ENABLED, allows_nil: false
- default_value_for(:pages_access_level, allows_nil: false) { |feature| feature.project&.public? ? ENABLED : PRIVATE }
+ default_value_for(:pages_access_level, allows_nil: false) do |feature|
+ if ::Gitlab::Pages.access_control_is_forced?
+ PRIVATE
+ else
+ feature.project&.public? ? ENABLED : PRIVATE
+ end
+ end
def feature_available?(feature, user)
# This feature might not be behind a feature flag at all, so default to true
@@ -126,6 +133,10 @@ class ProjectFeature < ApplicationRecord
merge_requests_access_level > DISABLED
end
+ def forking_enabled?
+ forking_access_level > DISABLED
+ end
+
def issues_enabled?
issues_access_level > DISABLED
end
@@ -137,6 +148,8 @@ class ProjectFeature < ApplicationRecord
def public_pages?
return true unless Gitlab.config.pages.access_control
+ return false if ::Gitlab::Pages.access_control_is_forced?
+
pages_access_level == PUBLIC || pages_access_level == ENABLED && project.public?
end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index 0d3a2d4e398..b70c07a8386 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -21,6 +21,8 @@ class ProjectGroupLink < ApplicationRecord
after_commit :refresh_group_members_authorized_projects
+ alias_method :shared_with_group, :group
+
def self.access_options
Gitlab::Access.options
end
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index 6542112ba32..529af1277b0 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -4,6 +4,8 @@ require 'slack-notifier'
module ChatMessage
class BaseMessage
+ RELATIVE_LINK_REGEX = /!\[[^\]]*\]\((\/uploads\/[^\)]*)\)/.freeze
+
attr_reader :markdown
attr_reader :user_full_name
attr_reader :user_name
@@ -59,7 +61,11 @@ module ChatMessage
end
def format(string)
- Slack::Notifier::LinkFormatter.format(string)
+ Slack::Notifier::LinkFormatter.format(format_relative_links(string))
+ end
+
+ def format_relative_links(string)
+ string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1")
end
def attachment_color
diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb
index b605d289278..ebe7abb379f 100644
--- a/app/models/project_services/chat_message/wiki_page_message.rb
+++ b/app/models/project_services/chat_message/wiki_page_message.rb
@@ -14,7 +14,7 @@ module ChatMessage
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@title = obj_attr[:title]
@wiki_page_url = obj_attr[:url]
- @description = obj_attr[:content]
+ @description = obj_attr[:message]
@action =
case obj_attr[:action]
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index 8ca40138a8f..eb78938324d 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
class EmailsOnPushService < Service
+ include NotificationBranchSelection
+
boolean_accessor :send_from_committer_email
boolean_accessor :disable_diffs
- prop_accessor :recipients
+ prop_accessor :recipients, :branches_to_be_notified
validates :recipients, presence: true, if: :valid_recipients?
def title
@@ -22,9 +24,17 @@ class EmailsOnPushService < Service
%w(push tag_push)
end
+ def initialize_properties
+ if properties.nil?
+ self.properties = {}
+ self.branches_to_be_notified ||= "all"
+ end
+ end
+
def execute(push_data)
return unless supported_events.include?(push_data[:object_kind])
return if project.emails_disabled?
+ return unless notify_for_ref?(push_data)
EmailsOnPushWorker.perform_async(
project_id,
@@ -35,6 +45,13 @@ class EmailsOnPushService < Service
)
end
+ def notify_for_ref?(push_data)
+ return true if push_data[:object_kind] == 'tag_push'
+ return true if push_data.dig(:object_attributes, :tag)
+
+ notify_for_branch?(push_data)
+ end
+
def send_from_committer_email?
Gitlab::Utils.to_boolean(self.send_from_committer_email)
end
@@ -50,6 +67,7 @@ class EmailsOnPushService < Service
help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
{ type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
+ { type: 'select', name: 'branches_to_be_notified', choices: BRANCH_CHOICES },
{ type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
]
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index 593ce69b0fd..0a09000fff4 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -19,15 +19,20 @@ class ExternalWikiService < Service
def fields
[
- { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true }
+ {
+ type: 'text',
+ name: 'external_wiki_url',
+ placeholder: s_('ExternalWikiService|The URL of the external Wiki'),
+ required: true
+ }
]
end
def execute(_data)
- @response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) rescue nil
- if @response != 200
- nil
- end
+ response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true)
+ response.body if response.code == 200
+ rescue
+ nil
end
def self.supported_events
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 48c96203921..f4666197def 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -7,7 +7,8 @@ class ProjectWiki
MARKUPS = {
'Markdown' => :markdown,
'RDoc' => :rdoc,
- 'AsciiDoc' => :asciidoc
+ 'AsciiDoc' => :asciidoc,
+ 'Org' => :org
}.freeze unless defined?(MARKUPS)
CouldNotCreateWikiError = Class.new(StandardError)
diff --git a/app/models/release.rb b/app/models/release.rb
index 4fac64689ab..ecfae554fe0 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -3,6 +3,7 @@
class Release < ApplicationRecord
include Presentable
include CacheMarkdownField
+ include Importable
include Gitlab::Utils::StrongMemoize
cache_markdown_field :description
@@ -33,8 +34,8 @@ class Release < ApplicationRecord
delegate :repository, to: :project
- after_commit :create_evidence!, on: :create
- after_commit :notify_new_release, on: :create
+ after_commit :create_evidence!, on: :create, unless: :importing?
+ after_commit :notify_new_release, on: :create, unless: :importing?
MAX_NUMBER_TO_DISPLAY = 3
@@ -81,6 +82,10 @@ class Release < ApplicationRecord
evidence&.summary || {}
end
+ def milestone_titles
+ self.milestones.map {|m| m.title }.sort.join(", ")
+ end
+
private
def actual_sha
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 2a67c26d840..c53b2fc5340 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -95,7 +95,7 @@ class Repository
def path_to_repo
@path_to_repo ||=
begin
- storage = Gitlab.config.repositories.storages[@project.repository_storage]
+ storage = Gitlab.config.repositories.storages[project.repository_storage]
File.expand_path(
File.join(storage.legacy_disk_path, disk_path + '.git')
@@ -128,7 +128,7 @@ class Repository
commits = Gitlab::Git::Commit.batch_by_oid(raw_repository, oids)
if commits.present?
- Commit.decorate(commits, @project)
+ Commit.decorate(commits, project)
else
[]
end
@@ -159,14 +159,14 @@ class Repository
}
commits = Gitlab::Git::Commit.where(options)
- commits = Commit.decorate(commits, @project) if commits.present?
+ commits = Commit.decorate(commits, project) if commits.present?
CommitCollection.new(project, commits, ref)
end
def commits_between(from, to)
commits = Gitlab::Git::Commit.between(raw_repository, from, to)
- commits = Commit.decorate(commits, @project) if commits.present?
+ commits = Commit.decorate(commits, project) if commits.present?
commits
end
@@ -695,13 +695,13 @@ class Repository
commits = raw_repository.list_last_commits_for_tree(sha, path, offset: offset, limit: limit)
commits.each do |path, commit|
- commits[path] = ::Commit.new(commit, @project)
+ commits[path] = ::Commit.new(commit, project)
end
end
def last_commit_for_path(sha, path)
commit = raw_repository.last_commit_for_path(sha, path)
- ::Commit.new(commit, @project) if commit
+ ::Commit.new(commit, project) if commit
end
def last_commit_id_for_path(sha, path)
@@ -1062,18 +1062,22 @@ class Repository
rebase_sha
end
- def rebase(user, merge_request)
+ def rebase(user, merge_request, skip_ci: false)
if Feature.disabled?(:two_step_rebase, default_enabled: true)
return rebase_deprecated(user, merge_request)
end
+ push_options = []
+ push_options << Gitlab::PushOptions::CI_SKIP if skip_ci
+
raw.rebase(
user,
merge_request.id,
branch: merge_request.source_branch,
branch_sha: merge_request.source_branch_sha,
remote_repository: merge_request.target_project.repository.raw,
- remote_branch: merge_request.target_branch
+ remote_branch: merge_request.target_branch,
+ push_options: push_options
) do |commit_id|
merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil)
end
@@ -1127,8 +1131,8 @@ class Repository
private
- # TODO Generice finder, later split this on finders by Ref or Oid
- # https://gitlab.com/gitlab-org/gitlab-foss/issues/39239
+ # TODO Genericize finder, later split this on finders by Ref or Oid
+ # https://gitlab.com/gitlab-org/gitlab/issues/19877
def find_commit(oid_or_ref)
commit = if oid_or_ref.is_a?(Gitlab::Git::Commit)
oid_or_ref
@@ -1136,7 +1140,7 @@ class Repository
Gitlab::Git::Commit.find(raw_repository, oid_or_ref)
end
- ::Commit.new(commit, @project) if commit
+ ::Commit.new(commit, project) if commit
end
def cache
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
new file mode 100644
index 00000000000..ab288798aed
--- /dev/null
+++ b/app/models/resource_weight_event.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+class ResourceWeightEvent < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
+ validates :user, presence: true
+ validates :issue, presence: true
+
+ belongs_to :user
+ belongs_to :issue
+
+ scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+ scope :created_after, ->(time) { where('created_at > ?', time) }
+
+ def discussion_id(resource = nil)
+ strong_memoize(:discussion_id) do
+ Digest::SHA1.hexdigest(discussion_id_key.join("-"))
+ end
+ end
+
+ private
+
+ def discussion_id_key
+ [self.class.name, created_at, user_id]
+ end
+end
diff --git a/app/models/sentry_issue.rb b/app/models/sentry_issue.rb
index 6be52f99562..e60ad6015a5 100644
--- a/app/models/sentry_issue.rb
+++ b/app/models/sentry_issue.rb
@@ -4,7 +4,11 @@ class SentryIssue < ApplicationRecord
belongs_to :issue
validates :issue, uniqueness: true, presence: true
- validates :sentry_issue_identifier,
- uniqueness: true,
- presence: true
+ validates :sentry_issue_identifier, presence: true
+
+ def self.for_project_and_identifier(project, identifier)
+ joins(:issue)
+ .where(issues: { project_id: project.id })
+ .find_by_sentry_issue_identifier(identifier)
+ end
end
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 92746d28f05..b3b3de21dee 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -14,8 +14,12 @@ class Snippet < ApplicationRecord
include Editable
include Gitlab::SQL::Pattern
include FromUnion
+ include IgnorableColumns
+
extend ::Gitlab::Utils::Override
+ ignore_column :storage_version, remove_with: '12.9', remove_after: '2020-03-22'
+
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
cache_markdown_field :content
diff --git a/app/models/user.rb b/app/models/user.rb
index ee42a987939..df54f358ffa 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -20,6 +20,7 @@ class User < ApplicationRecord
include WithUploads
include OptionallySearch
include FromUnion
+ include BatchDestroyDependentAssociations
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -163,9 +164,9 @@ class User < ApplicationRecord
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
- validates :name, presence: true, length: { maximum: 128 }
- validates :first_name, length: { maximum: 255 }
- validates :last_name, length: { maximum: 255 }
+ validates :name, presence: true, length: { maximum: 255 }
+ validates :first_name, length: { maximum: 127 }
+ validates :last_name, length: { maximum: 127 }
validates :email, confirmation: true
validates :notification_email, presence: true
validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
@@ -246,6 +247,7 @@ class User < ApplicationRecord
delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference
delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference
delegate :setup_for_company, :setup_for_company=, to: :user_preference
+ delegate :render_whitespace_in_code, :render_whitespace_in_code=, to: :user_preference
accepts_nested_attributes_for :user_preference, update_only: true
@@ -285,6 +287,10 @@ class User < ApplicationRecord
end
end
+ before_transition do
+ !Gitlab::Database.read_only?
+ end
+
# rubocop: disable CodeReuse/ServiceClass
# Ideally we should not call a service object here but user.block
# is also bcalled by Users::MigrateToGhostUserService which references
@@ -301,6 +307,8 @@ class User < ApplicationRecord
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
scope :active, -> { with_state(:active).non_internal }
+ scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
+ scope :without_ghosts, -> { where('ghost IS NOT TRUE') }
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
@@ -464,7 +472,7 @@ class User < ApplicationRecord
when 'deactivated'
deactivated
else
- active
+ active_without_ghosts
end
end
@@ -608,7 +616,7 @@ class User < ApplicationRecord
end
def self.non_internal
- where('ghost IS NOT TRUE')
+ without_ghosts
end
#
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index a36f56089a0..713b0598029 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -13,6 +13,7 @@ class UserPreference < ApplicationRecord
default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
default_value_for :time_display_relative, value: true, allows_nil: false
default_value_for :time_format_in_24h, value: false, allows_nil: false
+ default_value_for :render_whitespace_in_code, value: false, allows_nil: false
class << self
def notes_filters
diff --git a/app/policies/ci/trigger_policy.rb b/app/policies/ci/trigger_policy.rb
index 578301d7f7e..e26f96a4b2b 100644
--- a/app/policies/ci/trigger_policy.rb
+++ b/app/policies/ci/trigger_policy.rb
@@ -5,13 +5,12 @@ module Ci
delegate { @subject.project }
with_options scope: :subject, score: 0
- condition(:legacy) { @subject.supports_legacy_tokens? && @subject.legacy? }
with_score 0
condition(:is_owner) { @user && @subject.owner_id == @user.id }
rule { ~can?(:admin_build) }.prevent :admin_trigger
- rule { legacy | is_owner }.enable :admin_trigger
+ rule { is_owner }.enable :admin_trigger
rule { can?(:admin_build) }.enable :manage_trigger
end
diff --git a/app/policies/grafana_integration_policy.rb b/app/policies/grafana_integration_policy.rb
new file mode 100644
index 00000000000..529a1fe0493
--- /dev/null
+++ b/app/policies/grafana_integration_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class GrafanaIntegrationPolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 7b0297ea81b..e38eef527be 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -83,6 +83,11 @@ class ProjectPolicy < BasePolicy
project.merge_requests_allowing_push_to_user(user).any?
end
+ with_scope :subject
+ condition(:forking_allowed) do
+ @subject.feature_available?(:forking, @user)
+ end
+
with_scope :global
condition(:mirror_available, score: 0) do
::Gitlab::CurrentSettings.current_application_settings.mirror_available
@@ -203,7 +208,6 @@ class ProjectPolicy < BasePolicy
enable :download_code
enable :read_statistics
enable :download_wiki_code
- enable :fork_project
enable :create_project_snippet
enable :update_issue
enable :reopen_issue
@@ -232,12 +236,15 @@ class ProjectPolicy < BasePolicy
enable :public_access
enable :guest_access
- enable :fork_project
enable :build_download_code
enable :build_read_container_image
enable :request_access
end
+ rule { (can?(:public_user_access) | can?(:reporter_access)) & forking_allowed }.policy do
+ enable :fork_project
+ end
+
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index d092a2de882..43f472b4c1d 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -25,3 +25,5 @@ class UserPolicy < BasePolicy
rule { default }.enable :read_user_profile
rule { (private_profile | blocked_user) & ~(user_is_self | admin) }.prevent :read_user_profile
end
+
+UserPolicy.prepend_if_ee('EE::UserPolicy')
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
index ee11cffe355..724e10c26c3 100644
--- a/app/presenters/ci/bridge_presenter.rb
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BridgePresenter < CommitStatusPresenter
+ class BridgePresenter < ProcessablePresenter
def detailed_status
@detailed_status ||= subject.detailed_status(user)
end
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index 33056a809b7..03cbb57eb84 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildPresenter < CommitStatusPresenter
+ class BuildPresenter < ProcessablePresenter
def erased_by_user?
# Build can be erased through API, therefore it does not have
# `erased_by` user assigned in that case.
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 8e469795581..33b7899f912 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -34,7 +34,7 @@ module Ci
def refspecs
specs = []
- specs << refspec_for_pipeline_ref if merge_request_ref?
+ specs << refspec_for_pipeline_ref if should_expose_merge_request_ref?
specs << refspec_for_persistent_ref if persistent_ref_exist?
if git_depth > 0
@@ -50,6 +50,19 @@ module Ci
private
+ # We will stop exposing merge request refs when we fully depend on persistent refs
+ # (i.e. remove `refspec_for_pipeline_ref` when we remove `depend_on_persistent_pipeline_ref` feature flag.)
+ # `ci_force_exposing_merge_request_refs` is an extra feature flag that allows us to
+ # forcibly expose MR refs even if the `depend_on_persistent_pipeline_ref` feature flag enabled.
+ # This is useful when we see an unexpected behaviors/reports from users.
+ # See https://gitlab.com/gitlab-org/gitlab/issues/35140.
+ def should_expose_merge_request_ref?
+ return false unless merge_request_ref?
+ return true if Feature.enabled?(:ci_force_exposing_merge_request_refs, project)
+
+ Feature.disabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
+ end
+
def create_archive(artifacts)
return unless artifacts[:untracked] || artifacts[:paths]
diff --git a/app/presenters/ci/processable_presenter.rb b/app/presenters/ci/processable_presenter.rb
new file mode 100644
index 00000000000..5a8a6649071
--- /dev/null
+++ b/app/presenters/ci/processable_presenter.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+module Ci
+ class ProcessablePresenter < CommitStatusPresenter
+ end
+end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 97771d84031..3ace27c72d5 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -10,11 +10,11 @@ module Clusters
# We do not want to show the group path for clusters belonging to the
# clusterable, only for the ancestor clusters.
- def item_link(clusterable_presenter)
+ def item_link(clusterable_presenter, *html_options)
if cluster.group_type? && clusterable != clusterable_presenter.subject
contracted_group_name(cluster.group) + ' / ' + link_to_cluster
else
- link_to_cluster
+ link_to_cluster(*html_options)
end
end
@@ -84,8 +84,8 @@ module Clusters
sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle')
end
- def link_to_cluster
- link_to_if(can_read_cluster?, cluster.name, show_path)
+ def link_to_cluster(html_options: {})
+ link_to_if(can_read_cluster?, cluster.name, show_path, html_options)
end
end
end
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 81018398d5d..8c24d07675a 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -24,7 +24,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
commits_anchor_data,
branches_anchor_data,
tags_anchor_data,
- files_anchor_data
+ files_anchor_data,
+ releases_anchor_data
].compact.select(&:is_link)
end
@@ -153,6 +154,22 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
empty_repo? ? nil : project_tree_path(project))
end
+ def releases_anchor_data
+ return unless can?(current_user, :read_release, project)
+
+ releases_count = project.releases.count
+ return if releases_count < 1
+
+ AnchorData.new(true,
+ statistic_icon('rocket') +
+ n_('%{strong_start}%{release_count}%{strong_end} Release', '%{strong_start}%{release_count}%{strong_end} Releases', releases_count).html_safe % {
+ release_count: number_with_delimiter(releases_count),
+ strong_start: '<strong class="project-stat-value">'.html_safe,
+ strong_end: '</strong>'.html_safe
+ },
+ project_releases_path(project))
+ end
+
def commits_anchor_data
AnchorData.new(true,
statistic_icon('commit') +
@@ -276,8 +293,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def kubernetes_cluster_anchor_data
- if current_user && can?(current_user, :create_cluster, project)
-
+ if can_instantiate_cluster?
if clusters.empty?
AnchorData.new(false,
statistic_icon + _('Add Kubernetes cluster'),
@@ -294,7 +310,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
def gitlab_ci_anchor_data
- if current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
+ if cicd_missing?
AnchorData.new(false,
statistic_icon + _('Set up CI/CD'),
add_ci_yml_path)
@@ -326,8 +342,28 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
count_of_extra_topics_not_shown > 0
end
+ def can_setup_review_app?
+ strong_memoize(:can_setup_review_app) do
+ (can_instantiate_cluster? && all_clusters_empty?) || cicd_missing?
+ end
+ end
+
+ def all_clusters_empty?
+ strong_memoize(:all_clusters_empty) do
+ project.all_clusters.empty?
+ end
+ end
+
private
+ def cicd_missing?
+ current_user && can_current_user_push_code? && repository.gitlab_ci_yml.blank? && !auto_devops_enabled?
+ end
+
+ def can_instantiate_cluster?
+ current_user && can?(current_user, :create_cluster, project)
+ end
+
def filename_path(filename)
if blob = repository.public_send(filename) # rubocop:disable GitlabSecurity/PublicSend
project_blob_path(
diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb
index 414f436e76e..d1750695523 100644
--- a/app/serializers/build_artifact_entity.rb
+++ b/app/serializers/build_artifact_entity.rb
@@ -2,6 +2,7 @@
class BuildArtifactEntity < Grape::Entity
include RequestAwareEntity
+ include GitlabRoutingHelper
expose :name do |job|
job.name
@@ -11,15 +12,15 @@ class BuildArtifactEntity < Grape::Entity
expose :artifacts_expire_at, as: :expire_at
expose :path do |job|
- download_project_job_artifacts_path(project, job)
+ fast_download_project_job_artifacts_path(project, job)
end
expose :keep_path, if: -> (*) { job.has_expiring_artifacts? } do |job|
- keep_project_job_artifacts_path(project, job)
+ fast_keep_project_job_artifacts_path(project, job)
end
expose :browse_path do |job|
- browse_project_job_artifacts_path(project, job)
+ fast_browse_project_job_artifacts_path(project, job)
end
private
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 218bdd21e37..632718df780 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -8,9 +8,9 @@ class ClusterApplicationEntity < Grape::Entity
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
- expose :kibana_hostname, if: -> (e, _) { e.respond_to?(:kibana_hostname) }
expose :email, if: -> (e, _) { e.respond_to?(:email) }
expose :stack, if: -> (e, _) { e.respond_to?(:stack) }
+ expose :modsecurity_enabled, if: -> (e, _) { e.respond_to?(:modsecurity_enabled) }
expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) }
expose :can_uninstall?, as: :can_uninstall
end
diff --git a/app/serializers/deploy_key_entity.rb b/app/serializers/deploy_key_entity.rb
index 2682a47fbaa..653316ce4d2 100644
--- a/app/serializers/deploy_key_entity.rb
+++ b/app/serializers/deploy_key_entity.rb
@@ -5,6 +5,7 @@ class DeployKeyEntity < Grape::Entity
expose :user_id
expose :title
expose :fingerprint
+ expose :fingerprint_sha256
expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
expose :almost_orphaned?, as: :almost_orphaned
expose :created_at
diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb
index dd0cac8e4cd..d3b38a24316 100644
--- a/app/serializers/error_tracking/detailed_error_entity.rb
+++ b/app/serializers/error_tracking/detailed_error_entity.rb
@@ -8,6 +8,8 @@ module ErrorTracking
:external_url,
:first_release_last_commit,
:first_release_short_version,
+ :gitlab_commit,
+ :gitlab_commit_path,
:first_seen,
:frequency,
:gitlab_issue,
@@ -21,6 +23,7 @@ module ErrorTracking
:project_slug,
:short_id,
:status,
+ :tags,
:title,
:type,
:user_count
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index 71589ac8315..a4ab1d399bc 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class PipelineDetailsEntity < PipelineEntity
+ expose :project, using: ProjectEntity
+
expose :flags do
expose :latest?, as: :latest
end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 6b2a1bfe666..ba8f4fffe02 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -77,6 +77,10 @@ class PipelineEntity < Grape::Entity
cancel_project_pipeline_path(pipeline.project, pipeline)
end
+ expose :delete_path, if: -> (*) { can_delete? } do |pipeline|
+ project_pipeline_path(pipeline.project, pipeline)
+ end
+
expose :failed_builds, if: -> (*) { can_retry? }, using: JobEntity do |pipeline|
pipeline.failed_builds
end
@@ -95,6 +99,10 @@ class PipelineEntity < Grape::Entity
pipeline.cancelable?
end
+ def can_delete?
+ can?(request.current_user, :destroy_pipeline, pipeline)
+ end
+
def has_presentable_merge_request?
pipeline.triggered_by_merge_request? &&
can?(request.current_user, :read_merge_request, pipeline.merge_request)
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index b25a1ea9209..be535a5d414 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -41,6 +41,7 @@ class PipelineSerializer < BaseSerializer
def preloaded_relations
[
:latest_statuses_ordered_by_stage,
+ :project,
:stages,
{
failed_builds: %i(project metadata)
diff --git a/app/serializers/review_app_setup_entity.rb b/app/serializers/review_app_setup_entity.rb
new file mode 100644
index 00000000000..3a21fe24d9e
--- /dev/null
+++ b/app/serializers/review_app_setup_entity.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ReviewAppSetupEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :can_setup_review_app?, as: :can_setup_review_app
+
+ expose :all_clusters_empty?, as: :all_clusters_empty, if: -> (_, _) { project.can_setup_review_app? } do |project|
+ project.all_clusters_empty?
+ end
+
+ expose :review_snippet, if: -> (_, _) { project.can_setup_review_app? } do |_|
+ YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+
+ def project
+ object
+ end
+end
diff --git a/app/serializers/review_app_setup_serializer.rb b/app/serializers/review_app_setup_serializer.rb
new file mode 100644
index 00000000000..4baec7679b0
--- /dev/null
+++ b/app/serializers/review_app_setup_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ReviewAppSetupSerializer < BaseSerializer
+ entity ReviewAppSetupEntity
+end
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
index 2dd62e19e29..4fb19fbc074 100644
--- a/app/serializers/suggestion_entity.rb
+++ b/app/serializers/suggestion_entity.rb
@@ -4,7 +4,9 @@ class SuggestionEntity < API::Entities::Suggestion
include RequestAwareEntity
unexpose :from_line, :to_line, :from_content, :to_content
- expose :diff_lines, using: DiffLineEntity
+ expose :diff_lines, using: DiffLineEntity do |suggestion|
+ Gitlab::Diff::Highlight.new(suggestion.diff_lines).highlight
+ end
expose :current_user do
expose :can_apply do |suggestion|
Ability.allowed?(current_user, :apply_suggestion, suggestion)
diff --git a/app/services/akismet_service.rb b/app/services/akismet_service.rb
index 63be3c371ec..d8098c4a8f5 100644
--- a/app/services/akismet_service.rb
+++ b/app/services/akismet_service.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
class AkismetService
- attr_accessor :owner, :text, :options
+ attr_accessor :text, :options
- def initialize(owner, text, options = {})
- @owner = owner
+ def initialize(owner_name, owner_email, text, options = {})
+ @owner_name = owner_name
+ @owner_email = owner_email
@text = text
@options = options
end
@@ -16,8 +17,8 @@ class AkismetService
type: 'comment',
text: text,
created_at: DateTime.now,
- author: owner.name,
- author_email: owner.email,
+ author: owner_name,
+ author_email: owner_email,
referrer: options[:referrer]
}
@@ -40,6 +41,8 @@ class AkismetService
private
+ attr_accessor :owner_name, :owner_email
+
def akismet_client
@akismet_client ||= ::Akismet::Client.new(Gitlab::CurrentSettings.akismet_api_key,
Gitlab.config.gitlab.url)
@@ -55,8 +58,8 @@ class AkismetService
params = {
type: 'comment',
text: text,
- author: owner.name,
- author_email: owner.email
+ author: owner_name,
+ author_email: owner_email
}
begin
diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb
index 37a74cd1b00..a9240e1d8a0 100644
--- a/app/services/boards/issues/list_service.rb
+++ b/app/services/boards/issues/list_service.rb
@@ -5,6 +5,10 @@ module Boards
class ListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
+ def self.valid_params
+ IssuesFinder.valid_params
+ end
+
def execute
fetch_issues.order_by_position_and_priority
end
diff --git a/app/services/boards/list_service.rb b/app/services/boards/list_service.rb
index 44d5a21b15f..8258d5d07d3 100644
--- a/app/services/boards/list_service.rb
+++ b/app/services/boards/list_service.rb
@@ -4,13 +4,24 @@ module Boards
class ListService < Boards::BaseService
def execute
create_board! if parent.boards.empty?
- boards
+
+ if parent.multiple_issue_boards_available?
+ boards
+ else
+ # When multiple issue boards are not available
+ # a user is only allowed to view the default shown board
+ first_board
+ end
end
private
def boards
- parent.boards
+ parent.boards.order_by_name_asc
+ end
+
+ def first_board
+ parent.boards.first_board
end
def create_board!
@@ -18,5 +29,3 @@ module Boards
end
end
end
-
-Boards::ListService.prepend_if_ee('EE::Boards::ListService')
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index ce3a9eb0772..2daf3a51235 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -23,7 +23,7 @@ module Ci
Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze
# rubocop: disable Metrics/ParameterLists
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, **options, &block)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, external_pull_request: nil, bridge: nil, **options, &block)
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -46,6 +46,7 @@ module Ci
current_user: current_user,
push_options: params[:push_options] || {},
chat_data: params[:chat_data],
+ bridge: bridge,
**extra_options(options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence
@@ -104,14 +105,14 @@ module Ci
if Feature.enabled?(:ci_support_interruptible_pipelines, project, default_enabled: true)
project.ci_pipelines
.where(ref: pipeline.ref)
- .where.not(id: pipeline.id)
+ .where.not(id: pipeline.same_family_pipeline_ids)
.where.not(sha: project.commit(pipeline.ref).try(:id))
.alive_or_scheduled
.with_only_interruptible_builds
else
project.ci_pipelines
.where(ref: pipeline.ref)
- .where.not(id: pipeline.id)
+ .where.not(id: pipeline.same_family_pipeline_ids)
.where.not(sha: project.commit(pipeline.ref).try(:id))
.created_or_pending
end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service.rb b/app/services/ci/pipeline_processing/atomic_processing_service.rb
new file mode 100644
index 00000000000..1ed295f5950
--- /dev/null
+++ b/app/services/ci/pipeline_processing/atomic_processing_service.rb
@@ -0,0 +1,118 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineProcessing
+ class AtomicProcessingService
+ include Gitlab::Utils::StrongMemoize
+ include ExclusiveLeaseGuard
+
+ attr_reader :pipeline
+
+ DEFAULT_LEASE_TIMEOUT = 1.minute
+ BATCH_SIZE = 20
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+ @collection = AtomicProcessingService::StatusCollection.new(pipeline)
+ end
+
+ def execute
+ return unless pipeline.needs_processing?
+
+ success = try_obtain_lease { process! }
+
+ # re-schedule if we need further processing
+ if success && pipeline.needs_processing?
+ PipelineProcessWorker.perform_async(pipeline.id)
+ end
+
+ success
+ end
+
+ private
+
+ def process!
+ update_stages!
+ update_pipeline!
+ update_statuses_processed!
+
+ true
+ end
+
+ def update_stages!
+ pipeline.stages.ordered.each(&method(:update_stage!))
+ end
+
+ def update_stage!(stage)
+ # Update processables for a given stage in bulk/slices
+ ids = @collection.created_processable_ids_for_stage_position(stage.position)
+ ids.in_groups_of(BATCH_SIZE, false, &method(:update_processables!))
+
+ status = @collection.status_for_stage_position(stage.position)
+ stage.set_status(status)
+ end
+
+ def update_processables!(ids)
+ created_processables = pipeline.processables.for_ids(ids)
+ .with_project_preload
+ .created
+ .latest
+ .ordered_by_stage
+ .select_with_aggregated_needs(project)
+
+ created_processables.each(&method(:update_processable!))
+ end
+
+ def update_pipeline!
+ pipeline.set_status(@collection.status_of_all)
+ end
+
+ def update_statuses_processed!
+ processing = @collection.processing_processables
+ processing.each_slice(BATCH_SIZE) do |slice|
+ pipeline.statuses.match_id_and_lock_version(slice)
+ .update_as_processed!
+ end
+ end
+
+ def update_processable!(processable)
+ status = processable_status(processable)
+ return unless HasStatus::COMPLETED_STATUSES.include?(status)
+
+ # transition status if possible
+ Gitlab::OptimisticLocking.retry_lock(processable) do |subject|
+ Ci::ProcessBuildService.new(project, subject.user)
+ .execute(subject, status)
+
+ # update internal representation of status
+ # to make the status change of processable
+ # to be taken into account during further processing
+ @collection.set_processable_status(
+ processable.id, processable.status, processable.lock_version)
+ end
+ end
+
+ def processable_status(processable)
+ if needs_names = processable.aggregated_needs_names
+ # Processable uses DAG, get status of all dependent needs
+ @collection.status_for_names(needs_names)
+ else
+ # Processable uses Stages, get status of prior stage
+ @collection.status_for_prior_stage_position(processable.stage_idx.to_i)
+ end
+ end
+
+ def project
+ pipeline.project
+ end
+
+ def lease_key
+ "#{super}::pipeline_id:#{pipeline.id}"
+ end
+
+ def lease_timeout
+ DEFAULT_LEASE_TIMEOUT
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
new file mode 100644
index 00000000000..42e38a5c80f
--- /dev/null
+++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb
@@ -0,0 +1,135 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineProcessing
+ class AtomicProcessingService
+ class StatusCollection
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :pipeline
+
+ # We use these columns to perform an efficient
+ # calculation of a status
+ STATUSES_COLUMNS = [
+ :id, :name, :status, :allow_failure,
+ :stage_idx, :processed, :lock_version
+ ].freeze
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+ @stage_statuses = {}
+ @prior_stage_statuses = {}
+ end
+
+ # This method updates internal status for given ID
+ def set_processable_status(id, status, lock_version)
+ processable = all_statuses_by_id[id]
+ return unless processable
+
+ processable[:status] = status
+ processable[:lock_version] = lock_version
+ end
+
+ # This methods gets composite status of all processables
+ def status_of_all
+ status_for_array(all_statuses)
+ end
+
+ # This methods gets composite status for processables with given names
+ def status_for_names(names)
+ name_statuses = all_statuses_by_name.slice(*names)
+
+ status_for_array(name_statuses.values)
+ end
+
+ # This methods gets composite status for processables before given stage
+ def status_for_prior_stage_position(position)
+ strong_memoize("status_for_prior_stage_position_#{position}") do
+ stage_statuses = all_statuses_grouped_by_stage_position
+ .select { |stage_position, _| stage_position < position }
+
+ status_for_array(stage_statuses.values.flatten)
+ end
+ end
+
+ # This methods gets a list of processables for a given stage
+ def created_processable_ids_for_stage_position(current_position)
+ all_statuses_grouped_by_stage_position[current_position]
+ .to_a
+ .select { |processable| processable[:status] == 'created' }
+ .map { |processable| processable[:id] }
+ end
+
+ # This methods gets composite status for processables at a given stage
+ def status_for_stage_position(current_position)
+ strong_memoize("status_for_stage_position_#{current_position}") do
+ stage_statuses = all_statuses_grouped_by_stage_position[current_position].to_a
+
+ status_for_array(stage_statuses.flatten)
+ end
+ end
+
+ # This method returns a list of all processable, that are to be processed
+ def processing_processables
+ all_statuses.lazy.reject { |status| status[:processed] }
+ end
+
+ private
+
+ def status_for_array(statuses)
+ result = Gitlab::Ci::Status::Composite
+ .new(statuses)
+ .status
+ result || 'success'
+ end
+
+ def all_statuses_grouped_by_stage_position
+ strong_memoize(:all_statuses_by_order) do
+ all_statuses.group_by { |status| status[:stage_idx].to_i }
+ end
+ end
+
+ def all_statuses_by_id
+ strong_memoize(:all_statuses_by_id) do
+ all_statuses.map do |row|
+ [row[:id], row]
+ end.to_h
+ end
+ end
+
+ def all_statuses_by_name
+ strong_memoize(:statuses_by_name) do
+ all_statuses.map do |row|
+ [row[:name], row]
+ end.to_h
+ end
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def all_statuses
+ # We fetch all relevant data in one go.
+ #
+ # This is more efficient than relying
+ # on PostgreSQL to calculate composite status
+ # for us
+ #
+ # Since we need to reprocess everything
+ # we can fetch all of them and do processing
+ # ourselves.
+ strong_memoize(:all_statuses) do
+ raw_statuses = pipeline
+ .statuses
+ .latest
+ .ordered_by_stage
+ .pluck(*STATUSES_COLUMNS)
+
+ raw_statuses.map do |row|
+ STATUSES_COLUMNS.zip(row).to_h
+ end
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb
new file mode 100644
index 00000000000..400dc9f0abb
--- /dev/null
+++ b/app/services/ci/pipeline_processing/legacy_processing_service.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+module Ci
+ module PipelineProcessing
+ class LegacyProcessingService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :pipeline
+
+ def initialize(pipeline)
+ @pipeline = pipeline
+ end
+
+ def execute(trigger_build_ids = nil)
+ success = process_stages_without_needs
+
+ # we evaluate dependent needs,
+ # only when the another job has finished
+ success = process_builds_with_needs(trigger_build_ids) || success
+
+ @pipeline.update_legacy_status
+
+ success
+ end
+
+ private
+
+ def process_stages_without_needs
+ stage_indexes_of_created_processables_without_needs.flat_map do |index|
+ process_stage_without_needs(index)
+ end.any?
+ end
+
+ def process_stage_without_needs(index)
+ current_status = status_for_prior_stages(index)
+
+ return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
+
+ created_processables_in_stage_without_needs(index).find_each.select do |build|
+ process_build(build, current_status)
+ end.any?
+ end
+
+ def process_builds_with_needs(trigger_build_ids)
+ return false unless trigger_build_ids.present?
+ return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
+
+ # we find processables that are dependent:
+ # 1. because of current dependency,
+ trigger_build_names = pipeline.processables.latest
+ .for_ids(trigger_build_ids).names
+
+ # 2. does not have builds that not yet complete
+ incomplete_build_names = pipeline.processables.latest
+ .incomplete.names
+
+ # Each found processable is guaranteed here to have completed status
+ created_processables
+ .with_needs(trigger_build_names)
+ .without_needs(incomplete_build_names)
+ .find_each
+ .map(&method(:process_build_with_needs))
+ .any?
+ end
+
+ def process_build_with_needs(build)
+ current_status = status_for_build_needs(build.needs.map(&:name))
+
+ return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
+
+ process_build(build, current_status)
+ end
+
+ def process_build(build, current_status)
+ Gitlab::OptimisticLocking.retry_lock(build) do |subject|
+ Ci::ProcessBuildService.new(project, subject.user)
+ .execute(subject, current_status)
+ end
+ end
+
+ def status_for_prior_stages(index)
+ pipeline.processables.status_for_prior_stages(index)
+ end
+
+ def status_for_build_needs(needs)
+ pipeline.processables.status_for_names(needs)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def stage_indexes_of_created_processables_without_needs
+ created_processables_without_needs.order(:stage_idx)
+ .pluck(Arel.sql('DISTINCT stage_idx'))
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def created_processables_in_stage_without_needs(index)
+ created_processables_without_needs
+ .with_preloads
+ .for_stage(index)
+ end
+
+ def created_processables_without_needs
+ if Feature.enabled?(:ci_dag_support, project, default_enabled: true)
+ pipeline.processables.created.without_needs
+ else
+ pipeline.processables.created
+ end
+ end
+
+ def created_processables
+ pipeline.processables.created
+ end
+
+ def project
+ pipeline.project
+ end
+ end
+ end
+end
diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb
index 5d024c45e5f..3f87c711270 100644
--- a/app/services/ci/prepare_build_service.rb
+++ b/app/services/ci/prepare_build_service.rb
@@ -11,7 +11,7 @@ module Ci
def execute
prerequisites.each(&:complete!)
- build.enqueue!
+ build.enqueue_preparing!
rescue => e
Gitlab::ErrorTracking.track_exception(e, build_id: build.id)
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index f33cbf7ab29..1ecef256233 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -2,8 +2,6 @@
module Ci
class ProcessPipelineService
- include Gitlab::Utils::StrongMemoize
-
attr_reader :pipeline
def initialize(pipeline)
@@ -13,104 +11,18 @@ module Ci
def execute(trigger_build_ids = nil)
update_retried
- success = process_stages_without_needs
-
- # we evaluate dependent needs,
- # only when the another job has finished
- success = process_builds_with_needs(trigger_build_ids) || success
-
- @pipeline.update_status
-
- success
- end
-
- private
-
- def process_stages_without_needs
- stage_indexes_of_created_processables_without_needs.flat_map do |index|
- process_stage_without_needs(index)
- end.any?
- end
-
- def process_stage_without_needs(index)
- current_status = status_for_prior_stages(index)
-
- return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
-
- created_processables_in_stage_without_needs(index).find_each.select do |build|
- process_build(build, current_status)
- end.any?
- end
-
- def process_builds_with_needs(trigger_build_ids)
- return false unless trigger_build_ids.present?
- return false unless Feature.enabled?(:ci_dag_support, project, default_enabled: true)
-
- # we find processables that are dependent:
- # 1. because of current dependency,
- trigger_build_names = pipeline.processables.latest
- .for_ids(trigger_build_ids).names
-
- # 2. does not have builds that not yet complete
- incomplete_build_names = pipeline.processables.latest
- .incomplete.names
-
- # Each found processable is guaranteed here to have completed status
- created_processables
- .with_needs(trigger_build_names)
- .without_needs(incomplete_build_names)
- .find_each
- .map(&method(:process_build_with_needs))
- .any?
- end
-
- def process_build_with_needs(build)
- current_status = status_for_build_needs(build.needs.map(&:name))
-
- return unless HasStatus::COMPLETED_STATUSES.include?(current_status)
-
- process_build(build, current_status)
- end
-
- def process_build(build, current_status)
- Gitlab::OptimisticLocking.retry_lock(build) do |subject|
- Ci::ProcessBuildService.new(project, build.user)
- .execute(subject, current_status)
- end
- end
-
- def status_for_prior_stages(index)
- pipeline.processables.status_for_prior_stages(index)
- end
-
- def status_for_build_needs(needs)
- pipeline.processables.status_for_names(needs)
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def stage_indexes_of_created_processables_without_needs
- created_processables_without_needs.order(:stage_idx)
- .pluck(Arel.sql('DISTINCT stage_idx'))
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def created_processables_in_stage_without_needs(index)
- created_processables_without_needs
- .with_preloads
- .for_stage(index)
- end
-
- def created_processables_without_needs
- if Feature.enabled?(:ci_dag_support, project, default_enabled: true)
- pipeline.processables.created.without_needs
+ if Feature.enabled?(:ci_atomic_processing, pipeline.project)
+ Ci::PipelineProcessing::AtomicProcessingService
+ .new(pipeline)
+ .execute
else
- pipeline.processables.created
+ Ci::PipelineProcessing::LegacyProcessingService
+ .new(pipeline)
+ .execute(trigger_build_ids)
end
end
- def created_processables
- pipeline.processables.created
- end
+ private
# This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
# This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb
@@ -131,9 +43,5 @@ module Ci
.update_all(retried: true) if latest_statuses.any?
end
# rubocop: enable CodeReuse/ActiveRecord
-
- def project
- pipeline.project
- end
end
end
diff --git a/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
new file mode 100644
index 00000000000..a4bcca8e8b3
--- /dev/null
+++ b/app/services/ci/resource_groups/assign_resource_from_resource_group_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Ci
+ module ResourceGroups
+ class AssignResourceFromResourceGroupService < ::BaseService
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute(resource_group)
+ free_resources = resource_group.resources.free.count
+
+ resource_group.builds.waiting_for_resource.take(free_resources).each do |build|
+ build.enqueue_waiting_for_resource
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 7a5e33c61ba..1f00d54b6a7 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -5,13 +5,13 @@ module Ci
CLONE_ACCESSORS = %i[pipeline project ref tag options name
allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
- description tag_list protected needs].freeze
+ description tag_list protected needs resource_group].freeze
def execute(build)
reprocess!(build).tap do |new_build|
build.pipeline.mark_as_processable_after_stage(build.stage_idx)
- new_build.enqueue!
+ Gitlab::OptimisticLocking.retry_lock(new_build, &:enqueue)
MergeRequests::AddTodoWhenBuildFailsService
.new(project, current_user)
@@ -31,15 +31,17 @@ module Ci
attributes.push([:user, current_user])
- build.retried = true
-
Ci::Build.transaction do
# mark all other builds of that name as retried
build.pipeline.builds.latest
.where(name: build.name)
- .update_all(retried: true)
+ .update_all(retried: true, processed: true)
- create_build!(attributes)
+ create_build!(attributes).tap do
+ # mark existing object as retried/processed without a reload
+ build.retried = true
+ build.processed = true
+ end
end
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -49,6 +51,7 @@ module Ci
def create_build!(attributes)
build = project.builds.new(Hash[attributes])
build.deployment = ::Gitlab::Ci::Pipeline::Seed::Deployment.new(build).to_resource
+ build.retried = false
build.save!
build
end
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
index c9f7917938f..844da11e5cb 100644
--- a/app/services/clusters/applications/base_service.rb
+++ b/app/services/clusters/applications/base_service.rb
@@ -19,10 +19,6 @@ module Clusters
application.hostname = params[:hostname]
end
- if application.has_attribute?(:kibana_hostname)
- application.kibana_hostname = params[:kibana_hostname]
- end
-
if application.has_attribute?(:email)
application.email = params[:email]
end
@@ -31,6 +27,10 @@ module Clusters
application.stack = params[:stack]
end
+ if application.has_attribute?(:modsecurity_enabled)
+ application.modsecurity_enabled = params[:modsecurity_enabled] || false
+ end
+
if application.respond_to?(:oauth_application)
application.oauth_application = create_oauth_application(application, request)
end
@@ -68,7 +68,7 @@ module Clusters
end
def invalid_application?
- unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) || (application_name == Applications::Crossplane.application_name && !Feature.enabled?(:enable_cluster_application_crossplane))
+ unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack))
end
def unknown_application?
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index 1ce6e0c1cb0..7d064abfaa3 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -11,6 +11,8 @@ module Clusters
def on_success
app.make_installed!
+
+ Gitlab::Tracking.event('cluster:applications', "cluster_application_#{app.name}_installed")
ensure
remove_installation_pod
end
diff --git a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb
index 15be8446cc0..c6c7eb99bf3 100644
--- a/app/services/clusters/kubernetes/create_or_update_namespace_service.rb
+++ b/app/services/clusters/kubernetes/create_or_update_namespace_service.rb
@@ -21,10 +21,15 @@ module Clusters
attr_reader :cluster, :kubernetes_namespace, :platform
def create_project_service_account
+ environment_slug = kubernetes_namespace.environment&.slug
+ namespace_labels = { 'app.gitlab.com/app' => kubernetes_namespace.project.full_path_slug }
+ namespace_labels['app.gitlab.com/env'] = environment_slug if environment_slug
+
Clusters::Kubernetes::CreateOrUpdateServiceAccountService.namespace_creator(
platform.kubeclient,
service_account_name: kubernetes_namespace.service_account_name,
service_account_namespace: kubernetes_namespace.namespace,
+ service_account_namespace_labels: namespace_labels,
rbac: platform.rbac?
).execute
end
diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
index d798dcdcfd3..b1820474c9d 100644
--- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
+++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb
@@ -3,10 +3,11 @@
module Clusters
module Kubernetes
class CreateOrUpdateServiceAccountService
- def initialize(kubeclient, service_account_name:, service_account_namespace:, token_name:, rbac:, namespace_creator: false, role_binding_name: nil)
+ def initialize(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels: nil, token_name:, rbac:, namespace_creator: false, role_binding_name: nil)
@kubeclient = kubeclient
@service_account_name = service_account_name
@service_account_namespace = service_account_namespace
+ @service_account_namespace_labels = service_account_namespace_labels
@token_name = token_name
@rbac = rbac
@namespace_creator = namespace_creator
@@ -23,11 +24,12 @@ module Clusters
)
end
- def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, rbac:)
+ def self.namespace_creator(kubeclient, service_account_name:, service_account_namespace:, service_account_namespace_labels:, rbac:)
self.new(
kubeclient,
service_account_name: service_account_name,
service_account_namespace: service_account_namespace,
+ service_account_namespace_labels: service_account_namespace_labels,
token_name: "#{service_account_namespace}-token",
rbac: rbac,
namespace_creator: true,
@@ -55,12 +57,13 @@ module Clusters
private
- attr_reader :kubeclient, :service_account_name, :service_account_namespace, :token_name, :rbac, :namespace_creator, :role_binding_name
+ attr_reader :kubeclient, :service_account_name, :service_account_namespace, :service_account_namespace_labels, :token_name, :rbac, :namespace_creator, :role_binding_name
def ensure_project_namespace_exists
Gitlab::Kubernetes::Namespace.new(
service_account_namespace,
- kubeclient
+ kubeclient,
+ labels: service_account_namespace_labels
).ensure_exists!
end
diff --git a/app/services/concerns/akismet_methods.rb b/app/services/concerns/akismet_methods.rb
new file mode 100644
index 00000000000..1cbcf0d47b9
--- /dev/null
+++ b/app/services/concerns/akismet_methods.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module AkismetMethods
+ def spammable_owner
+ @user ||= User.find(spammable_owner_id)
+ end
+
+ def spammable_owner_id
+ @owner_id ||=
+ if spammable.respond_to?(:author_id)
+ spammable.author_id
+ elsif spammable.respond_to?(:creator_id)
+ spammable.creator_id
+ end
+ end
+
+ def akismet
+ @akismet ||= AkismetService.new(
+ spammable_owner.name,
+ spammable_owner.email,
+ spammable.spammable_text,
+ options
+ )
+ end
+end
diff --git a/app/services/spam_check_service.rb b/app/services/concerns/spam_check_methods.rb
index 51d300d4f1d..75d9759f1d1 100644
--- a/app/services/spam_check_service.rb
+++ b/app/services/concerns/spam_check_methods.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
-# SpamCheckService
+# SpamCheckMethods
#
# Provide helper methods for checking if a given spammable object has
# potential spam data.
#
# Dependencies:
# - params with :request
-#
-module SpamCheckService
+
+module SpamCheckMethods
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def filter_spam_check_params
@request = params.delete(:request)
@@ -24,7 +24,7 @@ module SpamCheckService
# rubocop:disable Gitlab/ModuleWithInstanceVariables
# rubocop: disable CodeReuse/ActiveRecord
def spam_check(spammable, user)
- spam_service = SpamService.new(spammable, @request)
+ spam_service = SpamService.new(spammable: spammable, request: @request)
spam_service.when_recaptcha_verified(@recaptcha_verified, @api) do
user.spam_logs.find_by(id: @spam_log_id)&.update!(recaptcha_verified: true)
diff --git a/app/services/container_expiration_policy_service.rb b/app/services/container_expiration_policy_service.rb
new file mode 100644
index 00000000000..5d141d4d64d
--- /dev/null
+++ b/app/services/container_expiration_policy_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ContainerExpirationPolicyService < BaseService
+ def execute(container_expiration_policy)
+ container_expiration_policy.schedule_next_run!
+
+ container_expiration_policy.container_repositories.find_each do |container_repository|
+ CleanupContainerRepositoryWorker.perform_async(
+ current_user.id,
+ container_repository.id,
+ container_expiration_policy.attributes.except("created_at", "updated_at")
+ )
+ end
+ end
+end
diff --git a/app/services/create_snippet_service.rb b/app/services/create_snippet_service.rb
deleted file mode 100644
index eacea7d94c7..00000000000
--- a/app/services/create_snippet_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-class CreateSnippetService < BaseService
- include SpamCheckService
-
- def execute
- filter_spam_check_params
-
- snippet = if project
- project.snippets.build(params)
- else
- PersonalSnippet.new(params)
- end
-
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level)
- deny_visibility_level(snippet)
- return snippet
- end
-
- snippet.author = current_user
-
- spam_check(snippet, current_user)
-
- snippet_saved = snippet.with_transaction_returning_status do
- snippet.save && snippet.store_mentions!
- end
-
- if snippet_saved
- UserAgentDetailService.new(snippet, @request).create
- Gitlab::UsageDataCounters::SnippetCounter.count(:create)
- end
-
- snippet
- end
-end
diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb
index 1d9cb666cff..3560f9c983b 100644
--- a/app/services/deployments/after_create_service.rb
+++ b/app/services/deployments/after_create_service.rb
@@ -34,21 +34,12 @@ module Deployments
if environment.save && !environment.stopped?
deployment.update_merge_request_metrics!
- link_merge_requests(deployment)
end
end
end
private
- def link_merge_requests(deployment)
- unless Feature.enabled?(:deployment_merge_requests, deployment.project)
- return
- end
-
- LinkMergeRequestsService.new(deployment).execute
- end
-
def environment_options
options&.dig(:environment) || {}
end
diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb
index 71186659290..a1d6d50bbb4 100644
--- a/app/services/deployments/link_merge_requests_service.rb
+++ b/app/services/deployments/link_merge_requests_service.rb
@@ -13,7 +13,10 @@ module Deployments
end
def execute
- return unless deployment.success?
+ # Review apps have the environment type set (e.g. to `review`, though the
+ # exact value may differ). We don't want to link merge requests to review
+ # app deployments, as this is not useful.
+ return if deployment.environment.environment_type
if (prev = deployment.previous_environment_deployment)
link_merge_requests_for_range(prev.sha, deployment.sha)
diff --git a/app/services/error_tracking/issue_update_service.rb b/app/services/error_tracking/issue_update_service.rb
new file mode 100644
index 00000000000..e433b4a11f2
--- /dev/null
+++ b/app/services/error_tracking/issue_update_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class IssueUpdateService < ErrorTracking::BaseService
+ private
+
+ def fetch
+ project_error_tracking_setting.update_issue(
+ issue_id: params[:issue_id],
+ params: update_params
+ )
+ end
+
+ def update_params
+ params.except(:issue_id)
+ end
+
+ def parse_response(response)
+ { updated: response[:updated].present? }
+ end
+ end
+end
diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb
index f7282c22a52..7460f0df535 100644
--- a/app/services/event_create_service.rb
+++ b/app/services/event_create_service.rb
@@ -101,7 +101,7 @@ class EventCreateService
Users::LastPushEventService.new(current_user)
.cache_last_push_event(event)
- Users::ActivityService.new(current_user, 'push').execute
+ Users::ActivityService.new(current_user).execute
end
def create_event(resource_parent, current_user, status, attributes = {})
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
index d935d9e8cdc..a49983a84fc 100644
--- a/app/services/git/base_hooks_service.rb
+++ b/app/services/git/base_hooks_service.rb
@@ -163,7 +163,7 @@ module Git
end
def logger
- if Sidekiq.server?
+ if Gitlab::Runtime.sidekiq?
Sidekiq.logger
else
# This service runs in Sidekiq, so this shouldn't ever be
diff --git a/app/services/ham_service.rb b/app/services/ham_service.rb
index 794eb34d9ca..0bbdaa47a1b 100644
--- a/app/services/ham_service.rb
+++ b/app/services/ham_service.rb
@@ -18,8 +18,10 @@ class HamService
private
def akismet
+ user = spam_log.user
@akismet ||= AkismetService.new(
- spam_log.user,
+ user.name,
+ user.email,
spam_log.text,
ip_address: spam_log.source_ip,
user_agent: spam_log.user_agent
diff --git a/app/services/issuable/clone/attributes_rewriter.rb b/app/services/issuable/clone/attributes_rewriter.rb
index 1f5d83917cc..334e50c0be5 100644
--- a/app/services/issuable/clone/attributes_rewriter.rb
+++ b/app/services/issuable/clone/attributes_rewriter.rb
@@ -18,6 +18,7 @@ module Issuable
new_entity.update(update_attributes)
copy_resource_label_events
+ copy_resource_weight_events
end
private
@@ -60,6 +61,20 @@ module Issuable
end
end
+ def copy_resource_weight_events
+ return unless original_entity.respond_to?(:resource_weight_events)
+
+ original_entity.resource_weight_events.find_in_batches do |batch|
+ events = batch.map do |event|
+ event.attributes
+ .except('id', 'reference', 'reference_html')
+ .merge('issue_id' => new_entity.id)
+ end
+
+ Gitlab::Database.bulk_insert(ResourceWeightEvent.table_name, events)
+ end
+ end
+
def entity_key
new_entity.class.name.parameterize('_').foreign_key
end
diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb
index 8d1df0d87a7..e8879d4df66 100644
--- a/app/services/issues/create_service.rb
+++ b/app/services/issues/create_service.rb
@@ -2,7 +2,7 @@
module Issues
class CreateService < Issues::BaseService
- include SpamCheckService
+ include SpamCheckMethods
include ResolveDiscussions
def execute
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b98a4d2567f..68d1657d881 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -2,7 +2,7 @@
module Issues
class UpdateService < Issues::BaseService
- include SpamCheckService
+ include SpamCheckMethods
def execute(issue)
handle_move_between_ids(issue)
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index fdd2c62a452..b5c27caafa2 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -7,9 +7,10 @@ module Members
raise Gitlab::Access::AccessDeniedError unless can?(current_user, action_member_permission(permission, member), member)
old_access_level = member.human_access
+ old_expiry = member.expires_at
if member.update(params)
- after_execute(action: permission, old_access_level: old_access_level, member: member)
+ after_execute(action: permission, old_access_level: old_access_level, old_expiry: old_expiry, member: member)
# Deletes only confidential issues todos for guests
enqueue_delete_todos(member) if downgrading_to_guest?
diff --git a/app/services/merge_requests/get_urls_service.rb b/app/services/merge_requests/get_urls_service.rb
index 7c88c9abb41..de3f2acdf63 100644
--- a/app/services/merge_requests/get_urls_service.rb
+++ b/app/services/merge_requests/get_urls_service.rb
@@ -9,7 +9,7 @@ module MergeRequests
end
def execute(changes)
- return [] unless project.printing_merge_request_link_enabled
+ return [] unless project&.printing_merge_request_link_enabled
branches = get_branches(changes)
merge_requests_map = opened_merge_requests_from_source_branches(branches)
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 7e9442c0c7c..bc1e97088af 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -8,8 +8,9 @@ module MergeRequests
attr_reader :merge_request
- def execute(merge_request)
+ def execute(merge_request, skip_ci: false)
@merge_request = merge_request
+ @skip_ci = skip_ci
if rebase
success
@@ -25,7 +26,7 @@ module MergeRequests
return false
end
- repository.rebase(current_user, merge_request)
+ repository.rebase(current_user, merge_request, skip_ci: @skip_ci)
true
rescue => e
diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb
new file mode 100644
index 00000000000..b2ec44cb814
--- /dev/null
+++ b/app/services/metrics/dashboard/clone_dashboard_service.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+# Copies system dashboard definition in .yml file into designated
+# .yml file inside `.gitlab/dashboards`
+module Metrics
+ module Dashboard
+ class CloneDashboardService < ::BaseService
+ ALLOWED_FILE_TYPE = '.yml'
+ USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT
+
+ def self.allowed_dashboard_templates
+ @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze
+ end
+
+ def execute
+ catch(:error) do
+ throw(:error, error(_(%q(You can't commit to this project)), :forbidden)) unless push_authorized?
+
+ result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute
+ throw(:error, wrap_error(result)) unless result[:status] == :success
+
+ repository.refresh_method_caches([:metrics_dashboard])
+ success(result.merge(http_status: :created, dashboard: dashboard_details))
+ end
+ end
+
+ private
+
+ def dashboard_attrs
+ {
+ commit_message: params[:commit_message],
+ file_path: new_dashboard_path,
+ file_content: new_dashboard_content,
+ encoding: 'text',
+ branch_name: branch,
+ start_branch: repository.branch_exists?(branch) ? branch : project.default_branch
+ }
+ end
+
+ def dashboard_details
+ {
+ path: new_dashboard_path,
+ display_name: ::Metrics::Dashboard::ProjectDashboardService.name_for_path(new_dashboard_path),
+ default: false,
+ system_dashboard: false
+ }
+ end
+
+ def push_authorized?
+ Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch)
+ end
+
+ def dashboard_template
+ @dashboard_template ||= begin
+ throw(:error, error(_('Not found.'), :not_found)) unless self.class.allowed_dashboard_templates.include?(params[:dashboard])
+
+ params[:dashboard]
+ end
+ end
+
+ def branch
+ @branch ||= begin
+ throw(:error, error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request)) unless valid_branch_name?
+ throw(:error, error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request)) unless new_or_default_branch? # temporary validation for first UI iteration
+
+ params[:branch]
+ end
+ end
+
+ def new_or_default_branch?
+ !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch]
+ end
+
+ def valid_branch_name?
+ Gitlab::GitRefValidator.validate(params[:branch])
+ end
+
+ def new_dashboard_path
+ @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name)
+ end
+
+ def file_name
+ @file_name ||= begin
+ throw(:error, error(_('The file name should have a .yml extension'), :bad_request)) unless target_file_type_valid?
+
+ File.basename(params[:file_name])
+ end
+ end
+
+ def target_file_type_valid?
+ File.extname(params[:file_name]) == ALLOWED_FILE_TYPE
+ end
+
+ def new_dashboard_content
+ File.read(Rails.root.join(dashboard_template))
+ end
+
+ def repository
+ @repository ||= project.repository
+ end
+
+ def wrap_error(result)
+ if result[:message] == 'A file with this name already exists'
+ error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request)
+ else
+ result
+ end
+ end
+ end
+ end
+end
+
+Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService')
diff --git a/app/services/metrics/sample_metrics_service.rb b/app/services/metrics/sample_metrics_service.rb
index 719bc6614e4..9bf32b295e2 100644
--- a/app/services/metrics/sample_metrics_service.rb
+++ b/app/services/metrics/sample_metrics_service.rb
@@ -4,16 +4,17 @@ module Metrics
class SampleMetricsService
DIRECTORY = "sample_metrics"
- attr_reader :identifier
+ attr_reader :identifier, :range_minutes
- def initialize(identifier)
+ def initialize(identifier, range_start:, range_end:)
@identifier = identifier
+ @range_minutes = convert_range_minutes(range_start, range_end)
end
def query
return unless identifier && File.exist?(file_location)
- YAML.load_file(File.expand_path(file_location, __dir__))
+ query_interval
end
private
@@ -22,5 +23,14 @@ module Metrics
sanitized_string = identifier.gsub(/[^0-9A-Za-z_]/, '')
File.join(Rails.root, DIRECTORY, "#{sanitized_string}.yml")
end
+
+ def query_interval
+ result = YAML.load_file(File.expand_path(file_location, __dir__))
+ result[range_minutes]
+ end
+
+ def convert_range_minutes(range_start, range_end)
+ ((range_end.to_time - range_start.to_time) / 1.minute).to_i
+ end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index accfdb5b863..50dc98b88e9 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -4,9 +4,7 @@ module Notes
class CreateService < ::Notes::BaseService
# rubocop:disable Metrics/CyclomaticComplexity
def execute
- merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
-
- note = Notes::BuildService.new(project, current_user, params).execute
+ note = Notes::BuildService.new(project, current_user, params.except(:merge_request_diff_head_sha)).execute
# n+1: https://gitlab.com/gitlab-org/gitlab-foss/issues/37440
note_valid = Gitlab::GitalyClient.allow_n_plus_1_calls do
@@ -23,8 +21,7 @@ module Notes
quick_actions_service = QuickActionsService.new(project, current_user)
if quick_actions_service.supported?(note)
- options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
- content, update_params, message = quick_actions_service.execute(note, options)
+ content, update_params, message = quick_actions_service.execute(note, quick_action_options)
only_commands = content.empty?
@@ -74,6 +71,11 @@ module Notes
private
+ # EE::Notes::CreateService would override this method
+ def quick_action_options
+ { merge_request_diff_head_sha: params[:merge_request_diff_head_sha] }
+ end
+
def tracking_data_for(note)
label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note'
@@ -84,3 +86,5 @@ module Notes
end
end
end
+
+Notes::CreateService.prepend_if_ee('EE::Notes::CreateService')
diff --git a/app/services/notes/destroy_service.rb b/app/services/notes/destroy_service.rb
index fa0c2c5c86b..ee8a680fcb4 100644
--- a/app/services/notes/destroy_service.rb
+++ b/app/services/notes/destroy_service.rb
@@ -11,3 +11,5 @@ module Notes
end
end
end
+
+Notes::DestroyService.prepend_if_ee('EE::Notes::DestroyService')
diff --git a/app/services/pages_domains/create_acme_order_service.rb b/app/services/pages_domains/create_acme_order_service.rb
index c600f497fa5..8eab5c52432 100644
--- a/app/services/pages_domains/create_acme_order_service.rb
+++ b/app/services/pages_domains/create_acme_order_service.rb
@@ -3,6 +3,9 @@
module PagesDomains
class CreateAcmeOrderService
attr_reader :pages_domain
+ # TODO: remove this hack after https://gitlab.com/gitlab-org/gitlab/issues/30146 is implemented
+ # This makes GitLab automatically retry the certificate obtaining process every 2 hours if process wasn't finished
+ SHORT_EXPIRATION_DELAY = 2.hours
def initialize(pages_domain)
@pages_domain = pages_domain
@@ -17,7 +20,7 @@ module PagesDomains
private_key = OpenSSL::PKey::RSA.new(4096)
saved_order = pages_domain.acme_orders.create!(
url: order.url,
- expires_at: order.expires,
+ expires_at: [order.expires, SHORT_EXPIRATION_DELAY.from_now].min,
private_key: private_key.to_pem,
challenge_token: challenge.token,
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
index 0ca89664304..706a6f01a75 100644
--- a/app/services/projects/operations/update_service.rb
+++ b/app/services/projects/operations/update_service.rb
@@ -30,7 +30,7 @@ module Projects
settings = params[:error_tracking_setting_attributes]
return {} if settings.blank?
- api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
+ api_url = ::ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
api_host: settings[:api_host],
project_slug: settings.dig(:project, :slug),
organization_slug: settings.dig(:project, :organization_slug)
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index e8a87fc4320..8b23f610ad1 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -6,7 +6,6 @@ module Projects
FailedToExtractError = Class.new(StandardError)
BLOCK_SIZE = 32.kilobytes
- MAX_SIZE = 1.terabyte
PUBLIC_DIR = 'public'
# this has to be invalid group name,
@@ -130,12 +129,16 @@ module Projects
1 + max_size / BLOCK_SIZE
end
+ def max_size_from_settings
+ Gitlab::CurrentSettings.max_pages_size.megabytes
+ end
+
def max_size
- max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes
+ max_pages_size = max_size_from_settings
- return MAX_SIZE if max_pages_size.zero?
+ return ::Gitlab::Pages::MAX_SIZE if max_pages_size.zero?
- [max_pages_size, MAX_SIZE].min
+ max_pages_size
end
def tmp_path
@@ -200,3 +203,5 @@ module Projects
end
end
end
+
+Projects::UpdatePagesService.prepend_if_ee('EE::Projects::UpdatePagesService')
diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb
deleted file mode 100644
index 399f4c35d66..00000000000
--- a/app/services/prometheus/adapter_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-module Prometheus
- class AdapterService
- def initialize(project, deployment_platform = nil)
- @project = project
-
- @deployment_platform = if deployment_platform
- deployment_platform
- else
- project.deployment_platform
- end
- end
-
- attr_reader :deployment_platform, :project
-
- def prometheus_adapter
- @prometheus_adapter ||= if service_prometheus_adapter.can_query?
- service_prometheus_adapter
- else
- cluster_prometheus_adapter
- end
- end
-
- def service_prometheus_adapter
- project.find_or_initialize_service('prometheus')
- end
-
- def cluster_prometheus_adapter
- application = deployment_platform&.cluster&.application_prometheus
-
- application if application&.available?
- end
- end
-end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
index a62eb76b8ce..3585c90fc8f 100644
--- a/app/services/prometheus/proxy_service.rb
+++ b/app/services/prometheus/proxy_service.rb
@@ -5,9 +5,17 @@ module Prometheus
include ReactiveCaching
include Gitlab::Utils::StrongMemoize
- self.reactive_cache_key = ->(service) { service.cache_key }
+ self.reactive_cache_key = ->(service) { [] }
self.reactive_cache_lease_timeout = 30.seconds
- self.reactive_cache_refresh_interval = 30.seconds
+
+ # reactive_cache_refresh_interval should be set to a value higher than
+ # reactive_cache_lifetime. If the refresh_interval is less than lifetime
+ # then the ReactiveCachingWorker will re-query prometheus for this
+ # PromQL query even though it's (probably) already been picked up by
+ # the frontend
+ # refresh_interval should be set less than lifetime only if this data
+ # is expected to change *and* be fetched again by the frontend
+ self.reactive_cache_refresh_interval = 90.seconds
self.reactive_cache_lifetime = 1.minute
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb
index ca56292e9d6..b34afaf80b8 100644
--- a/app/services/prometheus/proxy_variable_substitution_service.rb
+++ b/app/services/prometheus/proxy_variable_substitution_service.rb
@@ -4,7 +4,10 @@ module Prometheus
class ProxyVariableSubstitutionService < BaseService
include Stepable
- steps :add_params_to_result, :substitute_ruby_variables
+ steps :validate_variables,
+ :add_params_to_result,
+ :substitute_ruby_variables,
+ :substitute_liquid_variables
def initialize(environment, params = {})
@environment, @params = environment, params.deep_dup
@@ -16,24 +19,45 @@ module Prometheus
private
+ def validate_variables(_result)
+ return success unless variables
+
+ unless variables.is_a?(Array) && variables.size.even?
+ return error(_('Optional parameter "variables" must be an array of keys and values. Ex: [key1, value1, key2, value2]'))
+ end
+
+ success
+ end
+
def add_params_to_result(result)
result[:params] = params
success(result)
end
+ def substitute_liquid_variables(result)
+ return success(result) unless query(result)
+
+ result[:params][:query] =
+ TemplateEngines::LiquidService.new(query(result)).render(full_context)
+
+ success(result)
+ rescue TemplateEngines::LiquidService::RenderError => e
+ error(e.message)
+ end
+
def substitute_ruby_variables(result)
- return success(result) unless query
+ return success(result) unless query(result)
# The % operator doesn't replace variables if the hash contains string
# keys.
- result[:params][:query] = query % predefined_context.symbolize_keys
+ result[:params][:query] = query(result) % predefined_context.symbolize_keys
success(result)
rescue TypeError, ArgumentError => exception
log_error(exception.message)
- Gitlab::ErrorTracking.track_exception(exception, extra: {
- template_string: query,
+ Gitlab::ErrorTracking.track_exception(exception, {
+ template_string: query(result),
variables: predefined_context
})
@@ -44,8 +68,25 @@ module Prometheus
@predefined_context ||= Gitlab::Prometheus::QueryVariables.call(@environment)
end
- def query
- params[:query]
+ def full_context
+ @full_context ||= predefined_context.reverse_merge(variables_hash)
+ end
+
+ def variables
+ params[:variables]
+ end
+
+ def variables_hash
+ # .each_slice(2) converts ['key1', 'value1', 'key2', 'value2'] into
+ # [['key1', 'value1'], ['key2', 'value2']] which is then converted into
+ # a hash by to_h: {'key1' => 'value1', 'key2' => 'value2'}
+ # to_h will raise an ArgumentError if the number of elements in the original
+ # array is not even.
+ variables&.each_slice(2).to_h
+ end
+
+ def query(result)
+ result[:params][:query]
end
end
end
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index a14e0515a1f..a781eacc40e 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -84,7 +84,9 @@ module QuickActions
# rubocop: enable CodeReuse/ActiveRecord
def find_milestones(project, params = {})
- MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute
+ group_ids = project.group.self_and_ancestors.select(:id) if project.group
+
+ MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: group_ids)).execute
end
def parent
diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb
index 6ba8dac21f0..a452f7aa17a 100644
--- a/app/services/releases/update_service.rb
+++ b/app/services/releases/update_service.rb
@@ -11,10 +11,13 @@ module Releases
return error('params is empty', 400) if empty_params?
return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
- params[:milestones] = milestones if param_for_milestone_titles_provided?
+ if param_for_milestone_titles_provided?
+ previous_milestones = release.milestones.map(&:title)
+ params[:milestones] = milestones
+ end
if release.update(params)
- success(tag: existing_tag, release: release)
+ success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones))
else
error(release.errors.messages || '400 Bad request', 400)
end
@@ -29,5 +32,11 @@ module Releases
def empty_params?
params.except(:tag).empty?
end
+
+ def milestones_updated?(previous_milestones)
+ return false unless param_for_milestone_titles_provided?
+
+ previous_milestones.to_set != release.milestones.map(&:title)
+ end
end
end
diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb
new file mode 100644
index 00000000000..1b85ca811a1
--- /dev/null
+++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# We store events about issuable label changes and weight changes in a separate
+# table (not as other system notes), but we still want to display notes about
+# label changes and weight changes as classic system notes in UI. This service
+# generates "synthetic" notes for label event changes.
+
+module ResourceEvents
+ class BaseSyntheticNotesBuilderService
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :resource, :current_user, :params
+
+ def initialize(resource, current_user, params = {})
+ @resource = resource
+ @current_user = current_user
+ @params = params
+ end
+
+ def execute
+ synthetic_notes
+ end
+
+ private
+
+ def since_fetch_at(events)
+ return events unless params[:last_fetched_at].present?
+
+ last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
+ events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
+ end
+
+ def resource_parent
+ strong_memoize(:resource_parent) do
+ resource.project || resource.group
+ end
+ end
+ end
+end
diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb
index 7504773a002..47948fcff6e 100644
--- a/app/services/resource_events/merge_into_notes_service.rb
+++ b/app/services/resource_events/merge_into_notes_service.rb
@@ -1,10 +1,9 @@
# frozen_string_literal: true
-# We store events about issuable label changes in a separate table (not as
-# other system notes), but we still want to display notes about label changes
-# as classic system notes in UI. This service generates "synthetic" notes for
-# label event changes and merges them with classic notes and sorts them by
-# creation time.
+# We store events about issuable label changes and weight changes in separate tables (not as
+# other system notes), but we still want to display notes about label and weight changes
+# as classic system notes in UI. This service merges synthetic label and weight notes
+# with classic notes and sorts them by creation time.
module ResourceEvents
class MergeIntoNotesService
@@ -19,39 +18,15 @@ module ResourceEvents
end
def execute(notes = [])
- (notes + label_notes).sort_by { |n| n.created_at }
+ (notes + synthetic_notes).sort_by { |n| n.created_at }
end
private
- def label_notes
- label_events_by_discussion_id.map do |discussion_id, events|
- LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
- end
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def label_events_by_discussion_id
- return [] unless resource.respond_to?(:resource_label_events)
-
- events = resource.resource_label_events.includes(:label, user: :status)
- events = since_fetch_at(events)
-
- events.group_by { |event| event.discussion_id }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def since_fetch_at(events)
- return events unless params[:last_fetched_at].present?
-
- last_fetched_at = Time.at(params.fetch(:last_fetched_at).to_i)
- events.created_after(last_fetched_at - NotesFinder::FETCH_OVERLAP)
- end
-
- def resource_parent
- strong_memoize(:resource_parent) do
- resource.project || resource.group
- end
+ def synthetic_notes
+ SyntheticLabelNotesBuilderService.new(resource, current_user, params).execute
end
end
end
+
+ResourceEvents::MergeIntoNotesService.prepend_if_ee('EE::ResourceEvents::MergeIntoNotesService')
diff --git a/app/services/resource_events/synthetic_label_notes_builder_service.rb b/app/services/resource_events/synthetic_label_notes_builder_service.rb
new file mode 100644
index 00000000000..fd128101b49
--- /dev/null
+++ b/app/services/resource_events/synthetic_label_notes_builder_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# We store events about issuable label changes in a separate table (not as
+# other system notes), but we still want to display notes about label changes
+# as classic system notes in UI. This service generates "synthetic" notes for
+# label event changes.
+
+module ResourceEvents
+ class SyntheticLabelNotesBuilderService < BaseSyntheticNotesBuilderService
+ private
+
+ def synthetic_notes
+ label_events_by_discussion_id.map do |discussion_id, events|
+ LabelNote.from_events(events, resource: resource, resource_parent: resource_parent)
+ end
+ end
+
+ def label_events_by_discussion_id
+ return [] unless resource.respond_to?(:resource_label_events)
+
+ events = resource.resource_label_events.includes(:label, user: :status) # rubocop: disable CodeReuse/ActiveRecord
+ events = since_fetch_at(events)
+
+ events.group_by { |event| event.discussion_id }
+ end
+ end
+end
diff --git a/app/services/search_service.rb b/app/services/search_service.rb
index 91c0f9ba104..fe5e823b56c 100644
--- a/app/services/search_service.rb
+++ b/app/services/search_service.rb
@@ -3,6 +3,9 @@
class SearchService
include Gitlab::Allowable
+ SEARCH_TERM_LIMIT = 64
+ SEARCH_CHAR_LIMIT = 4096
+
def initialize(current_user, params = {})
@current_user = current_user
@params = params.dup
@@ -42,6 +45,14 @@ class SearchService
@show_snippets = params[:snippets] == 'true'
end
+ def valid_query_length?
+ params[:search].length <= SEARCH_CHAR_LIMIT
+ end
+
+ def valid_terms_count?
+ params[:search].split.count { |word| word.length >= 3 } <= SEARCH_TERM_LIMIT
+ end
+
delegate :scope, to: :search_service
def search_results
diff --git a/app/services/snippets/base_service.rb b/app/services/snippets/base_service.rb
new file mode 100644
index 00000000000..2b450db0b83
--- /dev/null
+++ b/app/services/snippets/base_service.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Snippets
+ class BaseService < ::BaseService
+ private
+
+ def snippet_error_response(snippet, http_status)
+ ServiceResponse.error(
+ message: snippet.errors.full_messages.to_sentence,
+ http_status: http_status,
+ payload: { snippet: snippet }
+ )
+ end
+ end
+end
diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb
new file mode 100644
index 00000000000..250e99c466a
--- /dev/null
+++ b/app/services/snippets/create_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Snippets
+ class CreateService < Snippets::BaseService
+ include SpamCheckMethods
+
+ def execute
+ filter_spam_check_params
+
+ snippet = if project
+ project.snippets.build(params)
+ else
+ PersonalSnippet.new(params)
+ end
+
+ unless Gitlab::VisibilityLevel.allowed_for?(current_user, snippet.visibility_level)
+ deny_visibility_level(snippet)
+
+ return snippet_error_response(snippet, 403)
+ end
+
+ snippet.author = current_user
+
+ spam_check(snippet, current_user)
+
+ snippet_saved = snippet.with_transaction_returning_status do
+ snippet.save && snippet.store_mentions!
+ end
+
+ if snippet_saved
+ UserAgentDetailService.new(snippet, @request).create
+ Gitlab::UsageDataCounters::SnippetCounter.count(:create)
+
+ ServiceResponse.success(payload: { snippet: snippet } )
+ else
+ snippet_error_response(snippet, 400)
+ end
+ end
+ end
+end
diff --git a/app/services/snippets/destroy_service.rb b/app/services/snippets/destroy_service.rb
new file mode 100644
index 00000000000..f253817d94f
--- /dev/null
+++ b/app/services/snippets/destroy_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Snippets
+ class DestroyService
+ include Gitlab::Allowable
+
+ attr_reader :current_user, :project
+
+ def initialize(user, snippet)
+ @current_user = user
+ @snippet = snippet
+ @project = snippet&.project
+ end
+
+ def execute
+ if snippet.nil?
+ return service_response_error('No snippet found.', 404)
+ end
+
+ unless user_can_delete_snippet?
+ return service_response_error(
+ "You don't have access to delete this snippet.",
+ 403
+ )
+ end
+
+ if snippet.destroy
+ ServiceResponse.success(message: 'Snippet was deleted.')
+ else
+ service_response_error('Failed to remove snippet.', 400)
+ end
+ end
+
+ private
+
+ attr_reader :snippet
+
+ def user_can_delete_snippet?
+ return can?(current_user, :admin_project_snippet, snippet) if project
+
+ can?(current_user, :admin_personal_snippet, snippet)
+ end
+
+ def service_response_error(message, http_status)
+ ServiceResponse.error(message: message, http_status: http_status)
+ end
+ end
+end
diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb
new file mode 100644
index 00000000000..8d2c8cac148
--- /dev/null
+++ b/app/services/snippets/update_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Snippets
+ class UpdateService < Snippets::BaseService
+ include SpamCheckMethods
+
+ def execute(snippet)
+ # check that user is allowed to set specified visibility_level
+ new_visibility = visibility_level
+
+ if new_visibility && new_visibility.to_i != snippet.visibility_level
+ unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
+ deny_visibility_level(snippet, new_visibility)
+
+ return snippet_error_response(snippet, 403)
+ end
+ end
+
+ filter_spam_check_params
+ snippet.assign_attributes(params)
+ spam_check(snippet, current_user)
+
+ snippet_saved = snippet.with_transaction_returning_status do
+ snippet.save && snippet.store_mentions!
+ end
+
+ if snippet_saved
+ Gitlab::UsageDataCounters::SnippetCounter.count(:update)
+
+ ServiceResponse.success(payload: { snippet: snippet } )
+ else
+ snippet_error_response(snippet, 400)
+ end
+ end
+ end
+end
diff --git a/app/services/spam/mark_as_spam_service.rb b/app/services/spam/mark_as_spam_service.rb
new file mode 100644
index 00000000000..0ebcf17927a
--- /dev/null
+++ b/app/services/spam/mark_as_spam_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Spam
+ class MarkAsSpamService
+ include ::AkismetMethods
+
+ attr_accessor :spammable, :options
+
+ def initialize(spammable:)
+ @spammable = spammable
+ @options = {}
+
+ @options[:ip_address] = @spammable.ip_address
+ @options[:user_agent] = @spammable.user_agent
+ end
+
+ def execute
+ return unless spammable.submittable_as_spam?
+ return unless akismet.submit_spam
+
+ spammable.user_agent_detail.update_attribute(:submitted, true)
+ end
+ end
+end
diff --git a/app/services/spam_service.rb b/app/services/spam_service.rb
index babe69cfdc8..ba9b812a01c 100644
--- a/app/services/spam_service.rb
+++ b/app/services/spam_service.rb
@@ -1,10 +1,12 @@
# frozen_string_literal: true
class SpamService
+ include AkismetMethods
+
attr_accessor :spammable, :request, :options
attr_reader :spam_log
- def initialize(spammable, request = nil)
+ def initialize(spammable:, request:)
@spammable = spammable
@request = request
@options = {}
@@ -19,16 +21,6 @@ class SpamService
end
end
- def mark_as_spam!
- return false unless spammable.submittable_as_spam?
-
- if akismet.submit_spam
- spammable.user_agent_detail.update_attribute(:submitted, true)
- else
- false
- end
- end
-
def when_recaptcha_verified(recaptcha_verified, api = false)
# In case it's a request which is already verified through recaptcha, yield
# block.
@@ -54,27 +46,6 @@ class SpamService
true
end
- def akismet
- @akismet ||= AkismetService.new(
- spammable_owner,
- spammable.spammable_text,
- options
- )
- end
-
- def spammable_owner
- @user ||= User.find(spammable_owner_id)
- end
-
- def spammable_owner_id
- @owner_id ||=
- if spammable.respond_to?(:author_id)
- spammable.author_id
- elsif spammable.respond_to?(:creator_id)
- spammable.creator_id
- end
- end
-
def check_for_spam?
spammable.check_for_spam?
end
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index 8ba50e22b09..a6485e42bdb 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -2,6 +2,24 @@
module Suggestions
class ApplyService < ::BaseService
+ DEFAULT_SUGGESTION_COMMIT_MESSAGE = 'Apply suggestion to %{file_path}'
+
+ PLACEHOLDERS = {
+ 'project_path' => ->(suggestion, user) { suggestion.project.path },
+ 'project_name' => ->(suggestion, user) { suggestion.project.name },
+ 'file_path' => ->(suggestion, user) { suggestion.file_path },
+ 'branch_name' => ->(suggestion, user) { suggestion.branch },
+ 'username' => ->(suggestion, user) { user.username },
+ 'user_full_name' => ->(suggestion, user) { user.name }
+ }.freeze
+
+ # This regex is built dynamically using the keys from the PLACEHOLDER struct.
+ # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
+ # This regex will build the new PLACEHOLDER_REGEX with the new information
+ PLACEHOLDERS_REGEX = Regexp.union(PLACEHOLDERS.keys.map { |key| Regexp.new(Regexp.escape(key)) }).freeze
+
+ attr_reader :current_user
+
def initialize(current_user)
@current_user = current_user
end
@@ -22,7 +40,7 @@ module Suggestions
end
params = file_update_params(suggestion, diff_file)
- result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
+ result = ::Files::UpdateService.new(suggestion.project, current_user, params).execute
if result[:status] == :success
suggestion.update(commit_id: result[:result], applied: true)
@@ -46,13 +64,14 @@ module Suggestions
def file_update_params(suggestion, diff_file)
blob = diff_file.new_blob
+ project = suggestion.project
file_path = suggestion.file_path
branch_name = suggestion.branch
file_content = new_file_content(suggestion, blob)
- commit_message = "Apply suggestion to #{file_path}"
+ commit_message = processed_suggestion_commit_message(suggestion)
file_last_commit =
- Gitlab::Git::Commit.last_for_path(suggestion.project.repository,
+ Gitlab::Git::Commit.last_for_path(project.repository,
blob.commit_id,
blob.path)
@@ -75,5 +94,17 @@ module Suggestions
content.join
end
+
+ def suggestion_commit_message(project)
+ project.suggestion_commit_message || DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ end
+
+ def processed_suggestion_commit_message(suggestion)
+ message = suggestion_commit_message(suggestion.project)
+
+ Gitlab::StringPlaceholderReplacer.replace_string_placeholders(message, PLACEHOLDERS_REGEX) do |key|
+ PLACEHOLDERS[key].call(suggestion, current_user)
+ end
+ end
end
end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index 06d2037fb63..0d369c23b57 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -14,7 +14,7 @@ class SystemHooksService
hook.async_execute(data, 'system_hooks')
end
- Gitlab::Plugin.execute_all_async(data)
+ Gitlab::FileHook.execute_all_async(data)
end
private
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 25e3282d3fb..38e0a7d34ad 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -60,9 +60,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_due_date(noteable, project, author, due_date)
- body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
+ ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_due_date(due_date)
end
# Called when the estimated time of a Noteable is changed
@@ -80,14 +78,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_time_estimate(noteable, project, author)
- parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
- body = if noteable.time_estimate == 0
- "removed time estimate"
- else
- "changed time estimate to #{parsed_time}"
- end
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_estimate
end
# Called when the spent time of a Noteable is changed
@@ -105,21 +96,7 @@ module SystemNoteService
#
# Returns the created Note object
def change_time_spent(noteable, project, author)
- time_spent = noteable.time_spent
-
- if time_spent == :reset
- body = "removed time spent"
- else
- spent_at = noteable.spent_at
- parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
- action = time_spent > 0 ? 'added' : 'subtracted'
-
- text_parts = ["#{action} #{parsed_time} of time spent"]
- text_parts << "at #{spent_at}" if spent_at
- body = text_parts.join(' ')
- end
-
- create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ ::SystemNotes::TimeTrackingService.new(noteable: noteable, project: project, author: author).change_time_spent
end
def change_status(noteable, project, author, status, source = nil)
diff --git a/app/services/system_notes/time_tracking_service.rb b/app/services/system_notes/time_tracking_service.rb
new file mode 100644
index 00000000000..8de42bd3225
--- /dev/null
+++ b/app/services/system_notes/time_tracking_service.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+module SystemNotes
+ class TimeTrackingService < ::SystemNotes::BaseService
+ # Called when the due_date of a Noteable is changed
+ #
+ # due_date - Due date being assigned, or nil
+ #
+ # Example Note text:
+ #
+ # "removed due date"
+ #
+ # "changed due date to September 20, 2018"
+ #
+ # Returns the created Note object
+ def change_due_date(due_date)
+ body = due_date ? "changed due date to #{due_date.to_s(:long)}" : 'removed due date'
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'due_date'))
+ end
+
+ # Called when the estimated time of a Noteable is changed
+ #
+ # time_estimate - Estimated time
+ #
+ # Example Note text:
+ #
+ # "removed time estimate"
+ #
+ # "changed time estimate to 3d 5h"
+ #
+ # Returns the created Note object
+ def change_time_estimate
+ parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
+ body = if noteable.time_estimate == 0
+ "removed time estimate"
+ else
+ "changed time estimate to #{parsed_time}"
+ end
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ end
+
+ # Called when the spent time of a Noteable is changed
+ #
+ # time_spent - Spent time
+ #
+ # Example Note text:
+ #
+ # "removed time spent"
+ #
+ # "added 2h 30m of time spent"
+ #
+ # Returns the created Note object
+ def change_time_spent
+ time_spent = noteable.time_spent
+
+ if time_spent == :reset
+ body = "removed time spent"
+ else
+ spent_at = noteable.spent_at
+ parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
+ action = time_spent > 0 ? 'added' : 'subtracted'
+
+ text_parts = ["#{action} #{parsed_time} of time spent"]
+ text_parts << "at #{spent_at}" if spent_at
+ body = text_parts.join(' ')
+ end
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'time_tracking'))
+ end
+ end
+end
diff --git a/app/services/template_engines/liquid_service.rb b/app/services/template_engines/liquid_service.rb
new file mode 100644
index 00000000000..809ebd0316b
--- /dev/null
+++ b/app/services/template_engines/liquid_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module TemplateEngines
+ class LiquidService < BaseService
+ RenderError = Class.new(StandardError)
+
+ DEFAULT_RENDER_SCORE_LIMIT = 1_000
+
+ def initialize(string)
+ @template = Liquid::Template.parse(string)
+ end
+
+ def render(context, render_score_limit: DEFAULT_RENDER_SCORE_LIMIT)
+ set_limits(render_score_limit)
+
+ @template.render!(context.stringify_keys)
+ rescue Liquid::MemoryError => e
+ handle_exception(e, string: @string, context: context)
+
+ raise RenderError, _('Memory limit exceeded while rendering template')
+ rescue Liquid::Error => e
+ handle_exception(e, string: @string, context: context)
+
+ raise RenderError, _('Error rendering query')
+ end
+
+ private
+
+ def set_limits(render_score_limit)
+ @template.resource_limits.render_score_limit = render_score_limit
+
+ # We can also set assign_score_limit and render_length_limit if required.
+
+ # render_score_limit limits the number of nodes (string, variable, block, tags)
+ # that are allowed in the template.
+ # render_length_limit seems to limit the sum of the bytesize of all node blocks.
+ # assign_score_limit seems to limit the sum of the bytesize of all capture blocks.
+ end
+
+ def handle_exception(exception, extra = {})
+ log_error(exception.message)
+ Gitlab::ErrorTracking.track_exception(exception, {
+ template_string: extra[:string],
+ variables: extra[:context]
+ })
+ end
+ end
+end
diff --git a/app/services/update_snippet_service.rb b/app/services/update_snippet_service.rb
deleted file mode 100644
index ac7f8e9b1f5..00000000000
--- a/app/services/update_snippet_service.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# frozen_string_literal: true
-
-class UpdateSnippetService < BaseService
- include SpamCheckService
-
- attr_accessor :snippet
-
- def initialize(project, user, snippet, params)
- super(project, user, params)
- @snippet = snippet
- end
-
- def execute
- # check that user is allowed to set specified visibility_level
- new_visibility = visibility_level
-
- if new_visibility && new_visibility.to_i != snippet.visibility_level
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
- deny_visibility_level(snippet, new_visibility)
- return snippet
- end
- end
-
- filter_spam_check_params
- snippet.assign_attributes(params)
- spam_check(snippet, current_user)
-
- snippet_saved = snippet.with_transaction_returning_status do
- snippet.save && snippet.store_mentions!
- end
-
- if snippet_saved
- Gitlab::UsageDataCounters::SnippetCounter.count(:update)
- end
- end
-end
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index 33444c2a7dc..85855f45e33 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -4,7 +4,7 @@ module Users
class ActivityService
LEASE_TIMEOUT = 1.minute.to_i
- def initialize(author, activity)
+ def initialize(author)
@user = if author.respond_to?(:username)
author
elsif author.respond_to?(:user)
@@ -12,7 +12,6 @@ module Users
end
@user = nil unless @user.is_a?(User)
- @activity = activity
end
def execute
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index ea4d11e728e..d18f20bc1db 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -86,6 +86,8 @@ module Users
:email_confirmation,
:password_automatically_set,
:name,
+ :first_name,
+ :last_name,
:password,
:username
]
@@ -107,6 +109,12 @@ module Users
if user_params[:skip_confirmation].nil?
user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting
end
+
+ fallback_name = "#{user_params[:first_name]} #{user_params[:last_name]}"
+
+ if user_params[:name].blank? && fallback_name.present?
+ user_params = user_params.merge(name: fallback_name)
+ end
end
if user_default_internal_regex_enabled? && !user_params.key?(:external)
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index e341c7f0537..643ebdc6839 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -56,6 +56,13 @@ module Users
MigrateToGhostUserService.new(user).execute unless options[:hard_delete]
+ if Feature.enabled?(:destroy_user_associations_in_batches)
+ # Rails attempts to load all related records into memory before
+ # destroying: https://github.com/rails/rails/issues/22510
+ # This ensures we delete records in batches.
+ user.destroy_dependent_associations_in_batches
+ end
+
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
user_data = user.destroy
namespace.destroy
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index 422c8ed6575..e7667b0ca18 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -17,6 +17,8 @@ module Users
yield(@user) if block_given?
user_exists = @user.persisted?
+
+ discard_read_only_attributes
assign_attributes
assign_identity
@@ -50,13 +52,19 @@ module Users
success
end
- def assign_attributes
+ def discard_read_only_attributes
+ discard_synced_attributes
+ end
+
+ def discard_synced_attributes
if (metadata = @user.user_synced_attributes_metadata)
read_only = metadata.read_only_attributes
params.reject! { |key, _| read_only.include?(key.to_sym) }
end
+ end
+ def assign_attributes
@user.assign_attributes(params.except(*identity_attributes)) unless params.empty?
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index d42c9dbedf4..b79a5deb9c0 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -5,6 +5,9 @@ class AvatarUploader < GitlabUploader
include RecordsUploads::Concern
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
+ include UploadTypeCheck::Concern
+
+ check_upload_type extensions: AvatarUploader::SAFE_IMAGE_EXT
def exists?
model.avatar.file && model.avatar.file.present?
diff --git a/app/uploaders/favicon_uploader.rb b/app/uploaders/favicon_uploader.rb
index a0b275b56a9..f393fdf0d84 100644
--- a/app/uploaders/favicon_uploader.rb
+++ b/app/uploaders/favicon_uploader.rb
@@ -1,8 +1,12 @@
# frozen_string_literal: true
class FaviconUploader < AttachmentUploader
+ include UploadTypeCheck::Concern
+
EXTENSION_WHITELIST = %w[png ico].freeze
+ check_upload_type extensions: EXTENSION_WHITELIST
+
def extension_whitelist
EXTENSION_WHITELIST
end
diff --git a/app/uploaders/upload_type_check.rb b/app/uploaders/upload_type_check.rb
new file mode 100644
index 00000000000..2837b001660
--- /dev/null
+++ b/app/uploaders/upload_type_check.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+# Ensure that uploaded files are what they say they are for security and
+# handling purposes. The checks are not 100% reliable so we err on the side of
+# caution and allow by default, and deny when we're confident of a fail state.
+#
+# Include this concern, then call `check_upload_type` to check all
+# uploads. Attach a `mime_type` or `extensions` parameter to only check
+# specific upload types. Both parameters will be normalized to a MIME type and
+# checked against the inferred MIME type of the upload content and filename
+# extension.
+#
+# class YourUploader
+# include UploadTypeCheck::Concern
+# check_upload_type mime_types: ['image/png', /image\/jpe?g/]
+#
+# # or...
+#
+# check_upload_type extensions: ['png', 'jpg', 'jpeg']
+# end
+#
+# The mime_types parameter can accept `NilClass`, `String`, `Regexp`,
+# `Array[String, Regexp]`. This matches the CarrierWave `extension_whitelist`
+# and `content_type_whitelist` family of behavior.
+#
+# The extensions parameter can accept `NilClass`, `String`, `Array[String]`.
+module UploadTypeCheck
+ module Concern
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def check_upload_type(mime_types: nil, extensions: nil)
+ define_method :check_upload_type_callback do |file|
+ magic_file = MagicFile.new(file.to_file)
+
+ # Map file extensions back to mime types.
+ if extensions
+ mime_types = Array(mime_types) +
+ Array(extensions).map { |e| MimeMagic::EXTENSIONS[e] }
+ end
+
+ if mime_types.nil? || magic_file.matches_mime_types?(mime_types)
+ check_content_matches_extension!(magic_file)
+ end
+ end
+ before :cache, :check_upload_type_callback
+ end
+ end
+
+ def check_content_matches_extension!(magic_file)
+ return if magic_file.ambiguous_type?
+
+ if magic_file.magic_type != magic_file.ext_type
+ raise CarrierWave::IntegrityError, 'Content type does not match file extension'
+ end
+ end
+ end
+
+ # Convenience class to wrap MagicMime objects.
+ class MagicFile
+ attr_reader :file
+
+ def initialize(file)
+ @file = file
+ end
+
+ def magic_type
+ @magic_type ||= MimeMagic.by_magic(file)
+ end
+
+ def ext_type
+ @ext_type ||= MimeMagic.by_path(file.path)
+ end
+
+ def magic_type_type
+ magic_type&.type
+ end
+
+ def ext_type_type
+ ext_type&.type
+ end
+
+ def matches_mime_types?(mime_types)
+ Array(mime_types).any? do |mt|
+ magic_type_type =~ /\A#{mt}\z/ || ext_type_type =~ /\A#{mt}\z/
+ end
+ end
+
+ # - Both types unknown or text/plain.
+ # - Ambiguous magic type with text extension. Plain text file.
+ # - Text magic type with ambiguous extension. TeX file missing extension.
+ def ambiguous_type?
+ (ext_type.to_s.blank? && magic_type.to_s.blank?) ||
+ (magic_type.to_s.blank? && ext_type_type == 'text/plain') ||
+ (ext_type.to_s.blank? && magic_type_type == 'text/plain')
+ end
+ end
+end
diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb
index 891d13b1596..9809047ae83 100644
--- a/app/validators/key_restriction_validator.rb
+++ b/app/validators/key_restriction_validator.rb
@@ -21,7 +21,8 @@ class KeyRestrictionValidator < ActiveModel::EachValidator
def supported_sizes_message
sizes = self.class.supported_sizes(options[:type])
- sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ')
+
+ Gitlab::Utils.to_exclusive_sentence(sizes)
end
def valid_restriction?(value)
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 6b95c0f40c5..80a53dba2aa 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -52,6 +52,7 @@
= f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
= _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
+ = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f
= render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
= f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_gitaly.html.haml b/app/views/admin/application_settings/_gitaly.html.haml
index 1da02de0461..fac2de8811f 100644
--- a/app/views/admin/application_settings/_gitaly.html.haml
+++ b/app/views/admin/application_settings/_gitaly.html.haml
@@ -8,6 +8,9 @@
.form-text.text-muted
Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
for git fetch/push operations or Sidekiq jobs.
+ This timeout should be less than the worker timeout. If a Gitaly call timeout would exceed the
+ worker timeout, the remaining time from the worker timeout would be used to avoid having to terminate
+ the worker.
.form-group
= f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'label-bold'
= f.number_field :gitaly_timeout_fast, class: 'form-control'
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index b15afb3b806..8214cf8ce9f 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -15,6 +15,15 @@
.form-text.text-muted
= _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled")
= link_to icon('question-circle'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')
+ - if Gitlab.config.pages.access_control
+ .form-group
+ .form-check
+ = f.check_box :force_pages_access_control, class: 'form-check-input'
+ = f.label :force_pages_access_control, class: 'form-check-label' do
+ = _("Disable public access to Pages sites")
+ .form-text.text-muted
+ = _("Access to Pages websites are controlled based on the user's membership to a given project. By checking this box, users will be required to be logged in to have access to all Pages websites in your instance.")
+ = link_to icon('question-circle'), help_page_path('administration/pages/index.md', anchor: 'disabling-public-access-to-all-pages-websites')
%h5
= _("Configure Let's Encrypt")
%p
diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml
index b9d9d86ca30..c29e52abaf6 100644
--- a/app/views/admin/application_settings/_signup.html.haml
+++ b/app/views/admin/application_settings/_signup.html.haml
@@ -7,6 +7,8 @@
= f.check_box :signup_enabled, class: 'form-check-input'
= f.label :signup_enabled, class: 'form-check-label' do
Sign-up enabled
+ .form-text.text-muted
+ = _("When enabled, any user visiting %{host} will be able to create an account.") % { host: "#{new_user_session_url(host: Gitlab.config.gitlab.host)}" }
.form-group
.form-check
= f.check_box :send_user_confirmation_email, class: 'form-check-input'
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index 55a48da8342..ff40d7da892 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -47,6 +47,9 @@
.settings-content
= render 'performance_bar'
+- if Feature.enabled?(:self_monitoring_project)
+ .js-self-monitoring-settings{ data: self_monitoring_project_data }
+
%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header#usage-statistics
%h4
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index 44d57beec0f..33b56655206 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -1,23 +1,38 @@
-.broadcast-message-preview{ style: broadcast_message_style(@broadcast_message) }
- = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top mr-2')
+.broadcast-banner-message.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) }
+ = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
.js-broadcast-message-preview
- if @broadcast_message.message.present?
= render_broadcast_message(@broadcast_message)
- else
Your message here
+- if Feature.enabled?(:broadcast_notification_type)
+ .d-flex.justify-content-center
+ .broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) }
+ = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top')
+ .js-broadcast-message-preview
+ - if @broadcast_message.message.present?
+ = render_broadcast_message(@broadcast_message)
+ - else
+ Your message here
= form_for [:admin, @broadcast_message], html: { class: 'broadcast-message-form js-quick-submit js-requires-input'} do |f|
= form_errors(@broadcast_message)
- .form-group.row
+ .form-group.row.mt-4
.col-sm-2.col-form-label
= f.label :message
.col-sm-10
- = f.text_area :message, class: "form-control js-autosize",
+ = f.text_area :message, class: "form-control js-autosize js-broadcast-message-message",
required: true,
dir: 'auto',
data: { preview_path: preview_admin_broadcast_messages_path }
- .form-group.row
+ - if Feature.enabled?(:broadcast_notification_type)
+ .form-group.row
+ .col-sm-2.col-form-label
+ = f.label :broadcast_type, _('Type')
+ .col-sm-10
+ = f.select :broadcast_type, broadcast_type_options, {}, class: 'form-control js-broadcast-message-type'
+ .form-group.row.js-broadcast-message-background-color-form-group{ class: ('hidden' unless @broadcast_message.banner? ) }
.col-sm-2.col-form-label
= f.label :color, _("Background color")
.col-sm-10
@@ -25,7 +40,7 @@
.input-group-prepend
.input-group-text.label-color-preview{ :style => 'background-color: ' + @broadcast_message.color + '; color: ' + @broadcast_message.font }
= '&nbsp;'.html_safe
- = f.text_field :color, class: "form-control"
+ = f.text_field :color, class: "form-control js-broadcast-message-color"
.form-text.text-muted
= _('Choose any color.')
%br
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 4731421fd9e..6f2433e3306 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -20,6 +20,7 @@
%th Starts
%th Ends
%th Target Path
+ %th Type
%th &nbsp;
%tbody
- @broadcast_messages.each do |message|
@@ -27,7 +28,7 @@
%td
= broadcast_message_status(message)
%td
- = broadcast_message(message)
+ = broadcast_message(message, preview: true)
%td
= message.starts_at
%td
@@ -35,6 +36,8 @@
%td
= message.target_path
%td
+ = message.broadcast_type.capitalize
+ %td
= link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn'
= link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger'
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index eed3ec74d60..1c14291b58e 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -11,4 +11,4 @@
= render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class
-= render 'shared/plugins/index'
+= render 'shared/file_hooks/index'
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index f8ef7a45f7f..818d265c767 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -57,24 +57,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options('runners') }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { action: 'submit' } }
- = button_tag class: %w[btn btn-link] do
- = sprite_icon('search')
- %span
- = _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
- {{hint}}
- %span.js-filter-tag.dropdown-light-content
- {{tag}}
-
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ %button.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index ca5109614fc..978e830d0e4 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -19,8 +19,8 @@
= link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default'
- unless user == current_user
%button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } }
- = icon('cog')
- = icon('caret-down')
+ = sprite_icon('settings')
+ = sprite_icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
= _('Settings')
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 3c6ad899d1e..ecbabab3e7f 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -9,7 +9,7 @@
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
- %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
index b89789e9915..04afc38a056 100644
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ b/app/views/clusters/clusters/_cluster.html.haml
@@ -3,7 +3,7 @@
.table-section.section-60
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
.table-mobile-content
- = cluster.item_link(clusterable)
+ = cluster.item_link(clusterable, html_options: { data: { qa_selector: 'cluster', qa_cluster_name: cluster.name } })
- unless cluster.enabled?
%span.badge.badge-danger Connection disabled
.table-section.section-25
diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml
index d89e6965dac..5bbdadf83f3 100644
--- a/app/views/clusters/clusters/aws/_new.html.haml
+++ b/app/views/clusters/clusters/aws/_new.html.haml
@@ -11,6 +11,6 @@
'role-arn' => @aws_role.role_arn,
'instance-types' => @instance_types,
'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'),
- 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'),
- 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'eks-cluster'),
+ 'account-and-external-ids-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'),
+ 'create-role-arn-help-path' => help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'new-eks-cluster'),
'external-link-icon' => icon('external-link') } }
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index 5beeaf7259a..4b295cd022d 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -30,6 +30,7 @@
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
+ ingress_mod_security_help_path: help_page_path('user/clusters/applications.md', anchor: 'web-application-firewall-modsecurity'),
environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'),
diff --git a/app/views/dashboard/projects/_projects.html.haml b/app/views/dashboard/projects/_projects.html.haml
index 9e0e908e656..5122164dbcb 100644
--- a/app/views/dashboard/projects/_projects.html.haml
+++ b/app/views/dashboard/projects/_projects.html.haml
@@ -1 +1 @@
-= render 'shared/projects/list', projects: @projects, ci: true, user: current_user
+= render 'shared/projects/list', projects: @projects, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true), user: current_user
diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
index 5d163d03c73..4832861445b 100644
--- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
+++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml
@@ -1,14 +1,23 @@
- content_for(:page_title, _('Register for GitLab'))
+- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
.signup-box.p-3.mb-2
.signup-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors.mt-0
= render "devise/shared/error_messages", resource: resource
- = invisible_captcha
+ - if Feature.enabled?(:invisible_captcha)
+ = invisible_captcha
+ .name.form-row
+ .col.form-group
+ = f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
+ = f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First Name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.")
+ .col.form-group
+ = f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
+ = f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last Name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
- = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ = f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...')
@@ -27,5 +36,8 @@
- accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
= accept_terms_label.html_safe
= render_if_exists 'devise/shared/email_opted_in', f: f
+ %div
+ - if show_recaptcha_sign_up?
+ = recaptcha_tags
.submit-container.mt-3
= f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' }
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 2cd77af6877..7c5b85c903c 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -1,4 +1,4 @@
-- max_name_length = 128
+- max_name_length = 255
- max_username_length = 255
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
.login-body
diff --git a/app/views/errors/_footer.html.haml b/app/views/errors/_footer.html.haml
index e67a3a142f6..bb9edc54b4b 100644
--- a/app/views/errors/_footer.html.haml
+++ b/app/views/errors/_footer.html.haml
@@ -4,7 +4,7 @@
= link_to s_('Nav|Home'), root_path
%li
- if current_user
- = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path
+ = link_to s_('Nav|Sign out and sign in with a different account'), destroy_user_session_path, method: :post
- else
= link_to s_('Nav|Sign In / Register'), new_session_path(:user, redirect_to_referer: 'yes')
%li
diff --git a/app/views/explore/projects/_projects.html.haml b/app/views/explore/projects/_projects.html.haml
index 35b32662b8a..d819c4ea554 100644
--- a/app/views/explore/projects/_projects.html.haml
+++ b/app/views/explore/projects/_projects.html.haml
@@ -1,2 +1,2 @@
- is_explore_page = defined?(explore_page) && explore_page
-= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page
+= render 'shared/projects/list', projects: projects, user: current_user, explore_page: is_explore_page, pipeline_status: Feature.enabled?(:dashboard_pipeline_status, default_enabled: true)
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 0e78ce9f656..fe5a00e3be9 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -53,4 +53,6 @@
.settings-content
= render 'groups/settings/advanced'
+= render_if_exists 'shared/groups/max_pages_size_setting'
+
= render 'shared/confirm_modal', phrase: @group.path
diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml
deleted file mode 100644
index 93dd8f48a60..00000000000
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ /dev/null
@@ -1,22 +0,0 @@
-= form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f|
- .row
- .col-md-4.col-lg-6
- = users_select_tag(:user_ids, group_member_select_options)
- .form-text.text-muted.append-bottom-10
- Search for members by name, username, or email, or invite new ones using their email address.
-
- .col-md-3.col-lg-2
- = select_tag :access_level, options_for_select(GroupMember.access_level_roles, @group_member.access_level), class: "form-control project-access-select"
- .form-text.text-muted.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions")
- about role permissions
-
- .col-md-3.col-lg-2
- .clearable-input
- = text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
- %i.clear-icon.js-clear-input
- .form-text.text-muted.append-bottom-10
- On this date, the member(s) will automatically lose access to this group and all of its projects.
-
- .col-md-2
- = f.submit 'Add to group', class: "btn btn-success btn-block", data: { qa_selector: 'add_to_group_button' }
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 882fcc79421..048edb80d99 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -1,17 +1,28 @@
-- page_title _("Members")
+- page_title _("Group members")
- can_manage_members = can?(current_user, :admin_group_member, @group)
- show_invited_members = can_manage_members && @invited_members.exists?
- pending_active = params[:search_invited].present?
+- total_count = @members.count + @group.shared_with_group_links.count
.project-members-page.prepend-top-default
%h4
- = _("Members")
+ = _("Group members")
%hr
- if can_manage_members
- .project-members-new.append-bottom-default
- %p.clearfix
- = _("Add new member to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
- = render "new_group_member"
+ - if Feature.enabled?(:share_group_with_group, default_enabled: true)
+ %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
+ %li.nav-tab{ role: 'presentation' }
+ %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
+ %li.nav-tab{ role: 'presentation' }
+ %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group")
+ .tab-content.gitlab-tab-content
+ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
+ = render_invite_member_for_group(@group, @group_member.access_level)
+ - if Feature.enabled?(:share_group_with_group, default_enabled: true)
+ .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
+ = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access'
+ - else
+ = render_invite_member_for_group(@group, @group_member.access_level)
= render 'shared/members/requests', membership_source: @group, requesters: @requesters
@@ -19,10 +30,10 @@
%ul.nav-links.mobile-separator.nav.nav-tabs.clearfix
%li.nav-item
- = link_to "#existing_members", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do
+ = link_to "#existing_shares", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do
%span
- = _("Existing")
- %span.badge.badge-pill= @members.total_count
+ = _("Existing shares")
+ %span.badge.badge-pill= total_count
- if show_invited_members
%li.nav-item
= link_to "#invited_members", class: ["nav-link", ("active" if pending_active)], 'data-toggle' => 'tab' do
@@ -31,7 +42,16 @@
%span.badge.badge-pill= @invited_members.total_count
.tab-content
- #existing_members.tab-pane{ :class => ("active" unless pending_active) }
+ #existing_shares.tab-pane{ :class => ("active" unless pending_active) }
+ - if @group.shared_with_group_links.any?
+ .card.card-without-border
+ .d-flex.flex-column.flex-md-row.row-content-block.second-block
+ %span.flex-grow-1.align-self-md-center.col-form-label
+ = _("Groups with access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe }
+ %ul.content-list.members-list{ data: { qa_selector: "groups_list" } }
+ - can_admin_member = can?(current_user, :admin_group_member, @group)
+ - @group.shared_with_group_links.each do |group_link|
+ = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: group_group_link_path(@group, group_link)
.card.card-without-border
.d-flex.flex-column.flex-md-row.row-content-block.second-block
%span.flex-grow-1.align-self-md-center.col-form-label
@@ -46,7 +66,7 @@
= label_tag '2fa', '2FA', class: 'col-form-label label-bold pr-md-2'
= render 'shared/members/filter_2fa_dropdown'
= render 'shared/members/sort_dropdown'
- %ul.content-list.members-list
+ %ul.content-list.members-list{ data: { qa_selector: "members_list" } }
= render partial: 'shared/members/member', collection: @members, as: :member
= paginate @members, theme: 'gitlab'
diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml
new file mode 100644
index 00000000000..9e1932185da
--- /dev/null
+++ b/app/views/groups/settings/_pages_settings.html.haml
@@ -0,0 +1,5 @@
+= form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f|
+ = render_if_exists 'shared/pages/max_pages_size_input', form: f
+
+ .prepend-top-10
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-success'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index 81bd15ed287..8c9b859e127 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -44,8 +44,10 @@
= expanded ? _('Collapse') : _('Expand')
%p
- auto_devops_url = help_page_path('topics/autodevops/index')
+ - quickstart_url = help_page_path('topics/autodevops/quick_start_guide')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
- = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
+ - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url }
+ = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
.settings-content
= render 'groups/settings/ci_cd/auto_devops_form', group: @group
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 518c44cc687..e86d4236be8 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -21,7 +21,7 @@
= form_tag personal_access_token_import_github_path, method: :post do
.form-group
%label.label-bold= _('Personal Access Token')
- = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }
+ = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' }
%span.form-text.text-muted
= import_github_personal_access_token_message
@@ -29,4 +29,4 @@
.form-actions.d-flex.justify-content-end
= link_to _('Cancel'), new_project_path, class: 'btn'
- = submit_tag _('Authenticate'), class: 'btn btn-success ml-2'
+ = submit_tag _('Authenticate'), class: 'btn btn-success ml-2', data: { qa_selector: 'authenticate_button' }
diff --git a/app/views/layouts/_broadcast.html.haml b/app/views/layouts/_broadcast.html.haml
index ee3ca824342..9d7ad249ac8 100644
--- a/app/views/layouts/_broadcast.html.haml
+++ b/app/views/layouts/_broadcast.html.haml
@@ -1,2 +1,4 @@
-- current_broadcast_messages&.each do |message|
+- current_broadcast_banner_messages.each do |message|
= broadcast_message(message)
+- if Feature.enabled?(:broadcast_notification_type)
+ = broadcast_message(current_broadcast_notification_message)
diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml
index de1caeaa50f..07c271be2f0 100644
--- a/app/views/layouts/_flash.html.haml
+++ b/app/views/layouts/_flash.html.haml
@@ -1,10 +1,12 @@
-# We currently only support `alert`, `notice`, `success`, 'toast'
+- icons = {'alert' => 'error', 'notice' => 'information-o', 'success' => 'check-circle'};
.flash-container.flash-container-page.sticky
- flash.each do |key, value|
- if key == 'toast' && value
.js-toast-message{ data: { message: value } }
- elsif value
%div{ class: "flash-#{key} mb-2" }
+ = sprite_icon(icons[key], size: 16, css_class: 'align-middle mr-1') unless icons[key].nil?
%span= value
%div{ class: "close-icon-wrapper js-close-icon" }
= sprite_icon('close', size: 16, css_class: 'close-icon')
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 0060d8323b0..6b336f3eba2 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -46,7 +46,7 @@
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
- = stylesheet_link_tag "test", media: "all" if Rails.env.test?
+ = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations']
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all"
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index f4ab491a38e..7af190f5a0b 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,5 +1,8 @@
+- page_classes = page_class << @html_class
+- page_classes = page_classes.flatten.compact
+
!!! 5
-%html{ lang: I18n.locale, class: page_class }
+%html{ lang: I18n.locale, class: page_classes }
= render "layouts/head"
%body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: body_data }
= render "layouts/init_auto_complete" if @gfm_form
diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml
index 88803f982e8..84906c305a7 100644
--- a/app/views/layouts/header/_current_user_dropdown.html.haml
+++ b/app/views/layouts/header/_current_user_dropdown.html.haml
@@ -47,4 +47,4 @@
- if current_user_menu?(:sign_out)
%li.divider
%li
- = link_to _("Sign out"), destroy_user_session_path, class: "sign-out-link", data: { qa_selector: 'sign_out_link' }
+ = link_to _("Sign out"), destroy_user_session_path, method: :post, class: "sign-out-link", data: { qa_selector: 'sign_out_link' }
diff --git a/app/views/layouts/instance_statistics.html.haml b/app/views/layouts/instance_statistics.html.haml
index bebd9c4536f..1de6b385c86 100644
--- a/app/views/layouts/instance_statistics.html.haml
+++ b/app/views/layouts/instance_statistics.html.haml
@@ -1,5 +1,5 @@
-- page_title _('Instance Statistics')
-- header_title _('Instance Statistics'), instance_statistics_root_path
+- page_title _('Analytics')
+- header_title _('Analytics'), instance_statistics_root_path
- nav 'instance_statistics'
- @left_sidebar = true
diff --git a/app/views/layouts/nav/_analytics_link.html.haml b/app/views/layouts/nav/_analytics_link.html.haml
new file mode 100644
index 00000000000..f481aeecc1b
--- /dev/null
+++ b/app/views/layouts/nav/_analytics_link.html.haml
@@ -0,0 +1,4 @@
+- return unless dashboard_nav_link?(:analytics)
+= nav_link(controller: [:dev_ops_score, :cohorts], html_options: { class: "d-none d-xl-block"}) do
+ = link_to instance_statistics_root_path, class: 'chart-icon', title: _('Analytics'), aria: { label: _('Analytics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = sprite_icon('chart', size: 18)
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 9a839765286..379ba976040 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -47,10 +47,7 @@
%li.dropdown
= render_if_exists 'dashboard/nav_link_list'
- - if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:dev_ops_score, :cohorts]) do
- = link_to instance_statistics_root_path do
- = _('Instance Statistics')
+
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
= link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do
@@ -58,7 +55,7 @@
- if Feature.enabled?(:user_mode_in_session)
- if header_link?(:admin_mode)
= nav_link(controller: 'admin/sessions') do
- = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do
+ = link_to destroy_admin_session_path, method: :post, class: 'd-lg-none lock-open-icon' do
= _('Leave Admin Mode')
- elsif current_user.admin?
= nav_link(controller: 'admin/sessions') do
@@ -69,6 +66,8 @@
= link_to sherlock_transactions_path, class: 'admin-icon' do
= _('Sherlock Transactions')
+ = render_if_exists 'layouts/nav/analytics_link'
+
- if current_user.admin?
= nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-xl-block"}) do
= link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
diff --git a/app/views/layouts/nav/sidebar/_analytics_link.html.haml b/app/views/layouts/nav/sidebar/_analytics_link.html.haml
new file mode 100644
index 00000000000..9e5ae422e2d
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_analytics_link.html.haml
@@ -0,0 +1,4 @@
+- return unless dashboard_nav_link?(:analytics)
+= nav_link(controller: [:dev_ops_score, :cohorts]) do
+ = link_to instance_statistics_root_path, class: 'd-xl-none' do
+ = _('Analytics')
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index a027dca1b56..88bb0a97487 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -44,7 +44,7 @@
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
- = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
+ = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do
%span
= _('Contribution Analytics')
diff --git a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
index 0a84e952442..979d98ec382 100644
--- a/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
+++ b/app/views/layouts/nav/sidebar/_instance_statistics.html.haml
@@ -1,34 +1,11 @@
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
.context-header
- = link_to instance_statistics_root_path, title: _('Instance Statistics') do
+ = link_to instance_statistics_root_path, title: _('Analytics') do
.avatar-container.s40.settings-avatar
= sprite_icon('chart', size: 24)
- .sidebar-context-title= _('Instance Statistics')
+ .sidebar-context-title= _('Analytics')
%ul.sidebar-top-level-items
- = nav_link(controller: :dev_ops_score) do
- = link_to instance_statistics_dev_ops_score_index_path do
- .nav-icon-container
- = sprite_icon('comment')
- %span.nav-item-name
- = _('DevOps Score')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do
- = link_to instance_statistics_dev_ops_score_index_path do
- %strong.fly-out-top-item-name
- = _('DevOps Score')
-
- - if Gitlab::CurrentSettings.usage_ping_enabled
- = nav_link(controller: :cohorts) do
- = link_to instance_statistics_cohorts_path do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name
- = _('Cohorts')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do
- = link_to instance_statistics_cohorts_path do
- %strong.fly-out-top-item-name
- = _('Cohorts')
+ = render 'layouts/nav/sidebar/instance_statistics_links'
= render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml
new file mode 100644
index 00000000000..ee2c83dc31e
--- /dev/null
+++ b/app/views/layouts/nav/sidebar/_instance_statistics_links.html.haml
@@ -0,0 +1,25 @@
+- return unless can?(current_user, :read_instance_statistics)
+= nav_link(controller: :dev_ops_score) do
+ = link_to instance_statistics_dev_ops_score_index_path do
+ .nav-icon-container
+ = sprite_icon('comment')
+ %span.nav-item-name
+ = _('DevOps Score')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :dev_ops_score, html_options: { class: "fly-out-top-item" } ) do
+ = link_to instance_statistics_dev_ops_score_index_path do
+ %strong.fly-out-top-item-name
+ = _('DevOps Score')
+
+- if Gitlab::CurrentSettings.usage_ping_enabled
+ = nav_link(controller: :cohorts) do
+ = link_to instance_statistics_cohorts_path do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name
+ = _('Cohorts')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :cohorts, html_options: { class: "fly-out-top-item" } ) do
+ = link_to instance_statistics_cohorts_path do
+ %strong.fly-out-top-item-name
+ = _('Cohorts')
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 1e2556aecc1..3464cc1ea07 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -290,6 +290,8 @@
= render_if_exists 'layouts/nav/sidebar/project_packages_link'
+ = render_if_exists 'layouts/nav/sidebar/project_analytics_link' # EE-specific
+
- if project_nav_tab? :wiki
- wiki_url = project_wiki_path(@project, :home)
= nav_link(controller: :wikis) do
diff --git a/app/views/profiles/_name.html.haml b/app/views/profiles/_name.html.haml
new file mode 100644
index 00000000000..87f1634b4f3
--- /dev/null
+++ b/app/views/profiles/_name.html.haml
@@ -0,0 +1,5 @@
+- if user.read_only_attribute?(:name)
+ = form.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' },
+ help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
+- else
+ = form.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index bb31049111c..f3ad0c4c8ad 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -24,3 +24,9 @@
%strong= _('Signed in')
= s_('ProfileSession|on')
= l(active_session.created_at, format: :short)
+
+ - unless is_current_session
+ .float-right
+ = link_to profile_active_session_path(active_session.public_id), data: { confirm: _('Are you sure? The device will be signed out of GitLab.') }, method: :delete, class: "btn btn-danger prepend-left-10" do
+ %span.sr-only= _('Revoke')
+ = _('Revoke')
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 1f311e9a4a4..73f6a821b51 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -39,12 +39,12 @@
%hr
%h5
- = _('Groups (%{count})') % { count: @group_notifications.count }
+ = _('Groups (%{count})') % { count: @group_notifications.size }
%div
- @group_notifications.each do |setting|
= render 'group_settings', setting: setting, group: setting.source
%h5
- = _('Projects (%{count})') % { count: @project_notifications.count }
+ = _('Projects (%{count})') % { count: @project_notifications.size }
%p.account-well
= _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
.append-bottom-default
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index bf76b7379dd..93acd6f550b 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -62,9 +62,13 @@
.form-text.text-muted
= s_('Preferences|Choose what content you want to see on a projectā€™s overview page.')
.form-group.form-check
+ = f.check_box :render_whitespace_in_code, class: 'form-check-input'
+ = f.label :render_whitespace_in_code, class: 'form-check-label' do
+ = s_('Preferences|Render whitespace characters in the Web IDE')
+ .form-group.form-check
= f.check_box :show_whitespace_in_diffs, class: 'form-check-input'
= f.label :show_whitespace_in_diffs, class: 'form-check-label' do
- = s_('Preferences|Show whitespace in diffs')
+ = s_('Preferences|Show whitespace changes in diffs')
.col-sm-12
%hr
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index cfad274f91d..49533c18c8f 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -88,11 +88,7 @@
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
.row
- - if @user.read_only_attribute?(:name)
- = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' },
- help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
- - else
- = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
+ = render 'profiles/name', form: f, user: @user
= f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' }
= f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md'
diff --git a/app/views/projects/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
new file mode 100644
index 00000000000..06bb9056e61
--- /dev/null
+++ b/app/views/projects/_merge_request_merge_suggestions_settings.html.haml
@@ -0,0 +1,17 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Merge suggestions')
+ %p.text-secondary
+ = s_('ProjectSettings|The commit message used to apply merge request suggestions')
+ = link_to icon('question-circle'),
+ help_page_path('user/discussions/index.md',
+ anchor: 'configure-the-commit-message-for-applied-suggestions'),
+ target: '_blank'
+ .mb-2
+ = form.text_field :suggestion_commit_message, class: 'form-control mb-2', placeholder: Suggestions::ApplyService::DEFAULT_SUGGESTION_COMMIT_MESSAGE
+ %p.form-text.text-muted
+ = s_('ProjectSettings|The variables GitLab supports:')
+ - Suggestions::ApplyService::PLACEHOLDERS.keys.each do |placeholder|
+ %code
+ = "%{#{placeholder}}".html_safe
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index f2ba38387a3..dc3a3fcc647 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -5,3 +5,5 @@
= render 'projects/merge_request_merge_options_settings', project: @project, form: form
= render 'projects/merge_request_merge_checks_settings', project: @project, form: form
+
+= render 'projects/merge_request_merge_suggestions_settings', project: @project, form: form
diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml
index 42964c900b3..dc9dc92675d 100644
--- a/app/views/projects/_merge_request_settings_description_text.html.haml
+++ b/app/views/projects/_merge_request_settings_description_text.html.haml
@@ -1 +1 @@
-%p= s_('ProjectSettings|Choose your merge method, merge options, and merge checks.')
+%p= s_('ProjectSettings|Choose your merge method, merge options, merge checks, and merge suggestions.')
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 30fe5622ebd..b17207c0da6 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -20,8 +20,14 @@
.commit-row-title
%span.item-title.str-truncated-100
= link_to commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title
- .float-right
- = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha"
+ %span
+ - previous_commit_id = commit.parent_id
+ - if previous_commit_id
+ = link_to project_blame_path(@project, tree_join(previous_commit_id, @path)),
+ title: _('View blame prior to this change'),
+ aria: { label: _('View blame prior to this change') },
+ data: { toggle: 'tooltip', placement: 'right', container: 'body' } do
+ = sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom')
&nbsp;
.light
= commit_author_link(commit, avatar: false)
diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml
index 9eef6cafd04..1ff68cd2d11 100644
--- a/app/views/projects/blob/_render_error.html.haml
+++ b/app/views/projects/blob/_render_error.html.haml
@@ -3,5 +3,5 @@
The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}.
You can
- = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+ = Gitlab::Utils.to_exclusive_sentence(blob_render_error_options(viewer)).html_safe
instead.
diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml
index c78f04c9c7c..546c064c06f 100644
--- a/app/views/projects/blob/viewers/_contributing.html.haml
+++ b/app/views/projects/blob/viewers/_contributing.html.haml
@@ -4,6 +4,6 @@ After you've reviewed these contribution guidelines, you'll be all set to
- options = contribution_options(viewer.project)
- if options.any?
= succeed '.' do
- = options.to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
+ = Gitlab::Utils.to_exclusive_sentence(options).html_safe
- else
contribute to this project.
diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml
index 59b5b9f8a30..d65c06aa2a4 100644
--- a/app/views/projects/ci/lints/_create.html.haml
+++ b/app/views/projects/ci/lints/_create.html.haml
@@ -1,8 +1,8 @@
- if @status
- %p
- %b= _("Status:")
- = _("syntax is correct")
- %i.fa.fa-ok.correct-syntax
+ .bs-callout.bs-callout-success
+ %p
+ %b= _("Status:")
+ = _("syntax is correct")
.table-holder
%table.table.table-bordered
@@ -40,9 +40,10 @@
%b= _("Allowed to fail")
- else
- %p
- %b= _("Status:")
- = _("syntax is incorrect")
- %i.fa.fa-remove.incorrect-syntax
- %b= _("Error:")
- = @error
+ .bs-callout.bs-callout-danger
+ %p
+ %b= _("Status:")
+ = _("syntax is incorrect")
+ %pre
+ - @errors.each do |message|
+ %p= message
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 3a9c7a8bec5..8b659034fe6 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -21,9 +21,9 @@
.commit-detail.flex-list
.commit-content.qa-commit-content
- if view_details && merge_request
- = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title js-onboarding-commit-item"
+ = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)]
- else
- = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item")
+ = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title js-onboarding-commit-item #{"font-italic" if commit.message.empty?}")
%span.commit-row-message.d-inline.d-sm-none
&middot;
= commit.short_id
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index 1691af9dfdd..8bbe4e66c50 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -50,7 +50,7 @@
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" }
%li.total-time-header.pr-5.text-right
%span.stage-name.font-weight-bold
- {{ __('Total Time') }}
+ {{ __('Time') }}
%i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" }
.stage-panel-body
%nav.stage-nav
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index 59efcde5825..6a09004143e 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -9,13 +9,23 @@
= _('Select the branch you want to set as the default for this project. All merge requests and commits will automatically be made against this branch unless you specify a different one.')
.settings-content
- - if @project.empty_repo?
- .text-secondary
- = _('A default branch cannot be chosen for an empty project.')
- - else
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f|
- %fieldset
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f|
+ %fieldset
+ - if @project.empty_repo?
+ .text-secondary
+ = _('A default branch cannot be chosen for an empty project.')
+ - else
.form-group
= f.label :default_branch, "Default Branch", class: 'label-bold'
= f.select(:default_branch, @project.repository.branch_names, {}, {class: 'select2 select-wide'})
- = f.submit 'Save changes', class: "btn btn-success"
+
+ .form-group
+ .form-check
+ = f.check_box :autoclose_referenced_issues, class: 'form-check-input'
+ = f.label :autoclose_referenced_issues, class: 'form-check-label' do
+ %strong= _("Auto-close referenced issues on default branch")
+ .form-text.text-muted
+ = _("Issues referenced by merge requests and commits within the default branch will be closed automatically")
+ = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.html', anchor: 'disabling-automatic-issue-closing'), target: '_blank'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/projects/diffs/_viewer.html.haml b/app/views/projects/diffs/_viewer.html.haml
index 5c4d1760871..37ff03b4a59 100644
--- a/app/views/projects/diffs/_viewer.html.haml
+++ b/app/views/projects/diffs/_viewer.html.haml
@@ -3,8 +3,6 @@
.diff-viewer{ data: { type: viewer.type }, class: ('hidden' if hidden) }
- if viewer.render_error
= render 'projects/diffs/render_error', viewer: viewer
- - elsif viewer.collapsed?
- = render 'projects/diffs/collapsed', viewer: viewer
- else
- viewer.prepare!
diff --git a/app/views/projects/diffs/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml
index 94dcda38bd6..94dcda38bd6 100644
--- a/app/views/projects/diffs/_collapsed.html.haml
+++ b/app/views/projects/diffs/viewers/_collapsed.html.haml
diff --git a/app/views/projects/environments/_pin_button.html.haml b/app/views/projects/environments/_pin_button.html.haml
new file mode 100644
index 00000000000..5c7bfc2b17b
--- /dev/null
+++ b/app/views/projects/environments/_pin_button.html.haml
@@ -0,0 +1,3 @@
+- if environment.auto_stop_at? && environment.available?
+ = button_to cancel_auto_stop_project_environment_path(environment.project, environment), class: 'btn btn-secondary has-tooltip', title: _('Prevent environment from auto-stopping') do
+ = sprite_icon('thumbtack')
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 62b1c140794..ff78abfddf4 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -32,9 +32,14 @@
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment')
-.top-area
- %h3.page-title= @environment.name
- .nav-controls.ml-auto.my-2
+.top-area.justify-content-between
+ .d-flex
+ %h3.page-title= @environment.name
+ - if @environment.auto_stop_at?
+ %p.align-self-end.prepend-left-8
+ = s_('Environments|Auto stops %{auto_stop_time}').html_safe % {auto_stop_time: time_ago_with_tooltip(@environment.auto_stop_at)}
+ .nav-controls.my-2
+ = render 'projects/environments/pin_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
= render 'projects/environments/metrics_button', environment: @environment
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index 2a2ccf8a6de..93a43b5d1ea 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -4,6 +4,9 @@
%h4.sub-header
= _("Programming languages used in this repository")
+ %p
+ = _("Measured in bytes of code. Excludes generated and vendored code.")
+
.row
.col-md-4
%ul.bordered-list
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 17f6fe95f10..9062f2097b8 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -71,6 +71,9 @@
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
+ - if @issue.sentry_issue.present?
+ #js-sentry-error-stack-trace{ data: error_details_data(@project, @issue.sentry_issue.sentry_issue_identifier) }
+
= render_if_exists 'projects/issues/related_issues'
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
deleted file mode 100644
index d8c4a5f0a5d..00000000000
--- a/app/views/projects/pages/_https_only.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f|
- .form-group
- .form-check
- = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
- = f.label :pages_https_only, class: pages_https_only_label_class do
- %strong
- = s_('GitLabPages|Force HTTPS (requires valid certificates)')
-
- - unless pages_https_only_disabled?
- .prepend-top-10
- = f.submit s_('GitLabPages|Save'), class: 'btn btn-success'
diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml
new file mode 100644
index 00000000000..58eddf630f4
--- /dev/null
+++ b/app/views/projects/pages/_pages_settings.html.haml
@@ -0,0 +1,13 @@
+= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f|
+ - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
+ = render_if_exists 'shared/pages/max_pages_size_input', form: f
+
+ .form-group
+ .form-check
+ = f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
+ = f.label :pages_https_only, class: pages_https_only_label_class do
+ %strong
+ = s_('GitLabPages|Force HTTPS (requires valid certificates)')
+
+ .prepend-top-10
+ = f.submit s_('GitLabPages|Save'), class: 'btn btn-success'
diff --git a/app/views/projects/pages/_ssl_limitations_warning.html.haml b/app/views/projects/pages/_ssl_limitations_warning.html.haml
new file mode 100644
index 00000000000..7188e169824
--- /dev/null
+++ b/app/views/projects/pages/_ssl_limitations_warning.html.haml
@@ -0,0 +1,7 @@
+.bs-callout.bs-callout-warning
+ %i.fa.fa-warning
+ %strong= _("Warning:")
+ - pages_host = Gitlab.config.pages.host
+ = s_("GitLabPages|When using Pages under the general domain of a GitLab instance (%{pages_host}), you cannot use HTTPS with sub-subdomains. This means that if your username/groupname contains a dot it will not work. This is a limitation of the HTTP Over TLS protocol. HTTP pages will continue to work provided you don't redirect HTTP to HTTPS.").html_safe % { pages_host: pages_host }
+
+ %strong= external_link(s_("GitLabPages|Learn more."), "https://docs.gitlab.com/ee/user/project/pages/introduction.html#limitations")
diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml
index 3ec87597849..4b7810ea357 100644
--- a/app/views/projects/pages/show.html.haml
+++ b/app/views/projects/pages/show.html.haml
@@ -10,11 +10,12 @@
%p.light
= s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.')
- - if Gitlab.config.pages.external_https
- = render 'https_only'
+
+ = render 'pages_settings'
%hr.clearfix
+ = render 'ssl_limitations_warning' if @project.pages_subdomain.include?(".")
= render 'access'
= render 'use'
- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https
diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml
index 00321014f91..353c36d0fed 100644
--- a/app/views/projects/project_members/_groups.html.haml
+++ b/app/views/projects/project_members/_groups.html.haml
@@ -3,4 +3,6 @@
= _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) }
%span.badge.badge-pill= group_links.size
%ul.content-list.members-list
- = render partial: 'shared/members/group', collection: group_links, as: :group_link
+ - can_admin_member = can?(current_user, :admin_project_member, @project)
+ - @group_links.each do |group_link|
+ = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: project_group_link_path(@project, group_link)
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 5310c1fad01..5d8005b2e2a 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -13,5 +13,5 @@
%button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= icon("search")
= render 'shared/members/sort_dropdown'
- %ul.content-list.members-list.qa-members-list
+ %ul.content-list.members-list{ data: { qa_selector: 'members_list' } }
= render partial: 'shared/members/member', collection: members, as: :member
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 24fe583a9b5..c24a9061146 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -23,13 +23,13 @@
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ = render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project)
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
- = render 'projects/project_members/new_project_group', tab_title: _('Invite group')
+ = render 'shared/members/invite_group', submit_url: project_group_links_path(@project), access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, group_link_field: 'link_group_id', group_access_field: 'link_group_access'
- elsif !membership_locked?
- .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ .invite-member= render 'shared/members/invite_member', submit_url: project_project_members_path(@project), access_levels: ProjectMember.access_level_roles, default_access_level: @project_member.access_level, can_import_members?: can_import_members?, import_path: import_project_project_members_path(@project)
- elsif @project.allowed_to_share_with_group?
- .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
+ .invite-group= render 'shared/members/invite_group', access_levels: ProjectGroupLink.access_options, default_access_level: ProjectGroupLink.default_access, submit_url: project_group_links_path(@project), group_link_field: 'link_group_id', group_access_field: 'link_group_access'
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml
index e1eed93664e..0e0341a9923 100644
--- a/app/views/projects/registry/settings/_index.haml
+++ b/app/views/projects/registry/settings/_index.haml
@@ -1,2 +1,4 @@
-#js-registry-settings{ data: { registry_settings_endpoint: '',
- help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } }
+#js-registry-settings{ data: { project_id: @project.id,
+ cadence_options: cadence_options.to_json,
+ keep_n_options: keep_n_options.to_json,
+ older_than_options: older_than_options.to_json} }
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 38483f599b7..a65afeecc17 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -23,8 +23,11 @@
%button.btn.btn-default.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.')
- = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md')
+ - auto_devops_url = help_page_path('topics/autodevops/index')
+ - quickstart_url = help_page_path('topics/autodevops/quick_start_guide')
+ - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
+ - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url }
+ = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe }
.settings-content
= render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled?
@@ -60,13 +63,14 @@
= render 'projects/triggers/index'
- if Feature.enabled?(:registry_retention_policies_settings, @project)
- %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) }
+ %section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
%h4
- = _("Container Registry tag expiration policies")
+ = _("Container Registry tag expiration policy")
+ = link_to icon('question-circle'), help_page_path('user/packages/container_registry/index', anchor: 'retention-and-expiration-policy'), target: '_blank', rel: 'noopener noreferrer'
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
- = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.")
+ = _("Expiration policy for the Container Registry is a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.")
.settings-content
= render 'projects/registry/settings/index'
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
index 589d3037eba..06b5243dfd9 100644
--- a/app/views/projects/settings/operations/_error_tracking.html.haml
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -12,7 +12,7 @@
= _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
= link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
.settings-content
- .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json),
+ .js-error-tracking-form{ data: { list_projects_endpoint: project_error_tracking_projects_path(@project, format: :json),
operations_settings_endpoint: project_settings_operations_path(@project),
project: error_tracking_setting_project_json,
api_host: setting.api_host,
diff --git a/app/views/projects/triggers/_content.html.haml b/app/views/projects/triggers/_content.html.haml
deleted file mode 100644
index e686068657c..00000000000
--- a/app/views/projects/triggers/_content.html.haml
+++ /dev/null
@@ -1,9 +0,0 @@
-- if Feature.enabled?(:use_legacy_pipeline_triggers, @project)
- %p.append-bottom-default
- Triggers with the
- %span.badge.badge-primary legacy
- label do not have an associated user and only have access to the current project.
- %br
- = succeed '.' do
- Learn more in the
- = link_to 'triggers documentation', help_page_path('ci/triggers/README'), target: '_blank'
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index a559ce41e57..55a9234f01a 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -1,6 +1,5 @@
.row.prepend-top-default.append-bottom-default.triggers-container
.col-lg-12
- = render "projects/triggers/content"
.card
.card-header
Manage your project's triggers
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 60de3630bb5..d80248f7e80 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -7,12 +7,7 @@
%span= trigger.short_token
.label-container
- - if trigger.legacy?
- - if trigger.supports_legacy_tokens?
- %span.badge.badge-primary.has-tooltip{ title: "Trigger makes use of deprecated functionality" } legacy
- - else
- %span.badge.badge-danger.has-tooltip{ title: "Trigger is invalid due to being a legacy trigger. We recommend replacing it with a new trigger" } invalid
- - elsif !trigger.can_access_project?
+ - unless trigger.can_access_project?
%span.badge.badge-danger.has-tooltip{ title: "Trigger user has insufficient permissions to project" } invalid
%td
diff --git a/app/views/projects/triggers/edit.html.haml b/app/views/projects/triggers/edit.html.haml
index c35df322b9d..0f74d733c06 100644
--- a/app/views/projects/triggers/edit.html.haml
+++ b/app/views/projects/triggers/edit.html.haml
@@ -1,9 +1,7 @@
- page_title "Trigger"
.row.prepend-top-default.append-bottom-default
- .col-lg-3
- = render "content"
- .col-lg-9
+ .col-lg-12
%h4.prepend-top-0
Update trigger
= render "form", btn_text: "Save trigger"
diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml
index 7b92f5070df..bc8d7ed10ef 100644
--- a/app/views/registrations/welcome.html.haml
+++ b/app/views/registrations/welcome.html.haml
@@ -1,5 +1,4 @@
-- content_for(:page_title, _('Welcome to GitLab @%{username}!') % { username: current_user.username })
-- max_name_length = 128
+- content_for(:page_title, _('Welcome to GitLab %{name}!') % { name: current_user.name })
.text-center.mb-3
= _('In order to tailor your experience with GitLab we<br>would like to know a bit more about you.').html_safe
.signup-box.p-3.mb-2
@@ -7,9 +6,6 @@
= form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f|
.devise-errors.mt-0
= render 'devise/shared/error_messages', resource: current_user
- .name.form-group
- = f.label :name, _('Full name'), class: 'label-bold'
- = f.text_field :name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_name_length, :max_length_message => s_('Name is too long (maximum is %{max_length} characters).') % { max_length: max_name_length }, :qa_selector => 'new_user_name_field' }, required: true, title: _('This field is required.')
.form-group
= f.label :role, _('Role'), class: 'label-bold'
= f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control'
diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml
index 084d295f2c1..128508e954e 100644
--- a/app/views/shared/_auto_devops_callout.html.haml
+++ b/app/views/shared/_auto_devops_callout.html.haml
@@ -1,16 +1,15 @@
-.js-autodevops-banner.banner-callout.banner-non-empty-state.append-bottom-20.prepend-top-10{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
- .banner-graphic
- = custom_icon('icon_autodevops')
+%section.js-autodevops-banner.gl-banner{ data: { uid: 'auto_devops_settings_dismissed', project_path: project_path(@project) } }
+ .gl-banner-illustration
+ = image_tag('illustrations/autodevops.svg')
- .banner-body.prepend-left-10.append-bottom-10
- %h5.banner-title= s_('AutoDevOps|Auto DevOps')
+ .gl-banner-content
+ %h1.gl-banner-title= s_('AutoDevOps|Auto DevOps')
%p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.')
%p
- link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer')
= s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link }
- .banner-buttons
- = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn js-close-callout'
+ = link_to s_('AutoDevOps|Enable in settings'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'), class: 'btn btn-md new-gl-button js-close-callout'
- %button.btn-transparent.banner-close.close.js-close-callout{ type: 'button',
+ %button.gl-banner-close.close.js-close-callout{ type: 'button',
'aria-label' => 'Dismiss Auto DevOps box' }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index eb9b7f6c48a..a62c385d711 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -42,9 +42,10 @@
%button.board-delete.no-drag.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
- .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo", ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
+ .issue-count-badge.pr-0.no-drag.text-secondary{ "v-if" => "showBoardListAndBoardInfo" }
%span.d-inline-flex
- %span.issue-count-badge-count
+ %gl-tooltip{ ":target" => "() => $refs.issueCount", ":title" => "issuesTooltip" }
+ %span.issue-count-badge-count{ "ref" => "issueCount" }
%icon.mr-1{ name: "issues" }
%issue-count{ ":maxIssueCount" => "list.maxIssueCount",
":issuesSize" => "list.issuesSize" }
diff --git a/app/views/shared/file_hooks/_index.html.haml b/app/views/shared/file_hooks/_index.html.haml
new file mode 100644
index 00000000000..74eb6c94116
--- /dev/null
+++ b/app/views/shared/file_hooks/_index.html.haml
@@ -0,0 +1,24 @@
+- file_hooks = Gitlab::FileHook.files
+
+.row.prepend-top-default
+ .col-lg-4
+ %h4.prepend-top-0
+ = _('File Hooks')
+ %p
+ = _('File hooks are similar to system hooks but are executed as files instead of sending data to a URL.')
+ = link_to _('For more information, see the File Hooks documentation.'), help_page_path('administration/file_hooks')
+
+
+ .col-lg-8.append-bottom-default
+ - if file_hooks.any?
+ .card
+ .card-header
+ = _('File Hooks (%{count})') % { count: file_hooks.count }
+ %ul.content-list
+ - file_hooks.each do |file|
+ %li
+ .monospace
+ = File.basename(file)
+ - else
+ .card.bg-light.text-center
+ .nothing-here-block= _('No file hooks found.')
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 9db6184ebca..2f2e6d83f9f 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -1,6 +1,8 @@
- project = local_assigns.fetch(:project)
- model = local_assigns.fetch(:model)
+
+
- form = local_assigns.fetch(:form)
- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files hereā€¦')
- supports_quick_actions = model.new_record?
@@ -14,6 +16,8 @@
= form.label :description, 'Description', class: 'col-form-label col-sm-2'
.col-sm-10
+ - if model.is_a?(Issuable)
+ = render 'shared/issuable/form/template_selector', issuable: model
= render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do
= render 'projects/zen', f: form, attr: :description,
classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description',
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index 0fb23adc31f..a020a04e366 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -17,7 +17,6 @@
.form-group.row
= form.label :title, class: 'col-form-label col-sm-2'
- = render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
#js-suggestions{ data: { project_path: @project.full_path } }
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 5da86195243..c3960ec5026 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -1,5 +1,6 @@
- type = local_assigns.fetch(:type)
- board = local_assigns.fetch(:board, nil)
+- show_sorting_dropdown = local_assigns.fetch(:show_sorting_dropdown, true)
- is_not_boards_modal_or_productivity_analytics = type != :boards_modal && type != :productivity_analytics
- block_css_class = is_not_boards_modal_or_productivity_analytics ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent)
@@ -30,23 +31,22 @@
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { action: 'submit' } }
- %button.btn.btn-link{ type: 'button' }
- = sprite_icon('search')
- %span
- = _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
+ %li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
%button.btn.btn-link{ type: 'button' }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
%use{ 'xlink:href': "#{'{{icon}}'}" }
%span.js-filter-hint
- {{hint}}
- %span.js-filter-tag.dropdown-light-content
- {{tag}}
+ {{formattedKey}}
+ #js-dropdown-operator.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
+ %li.filter-dropdown-item{ data: { value: "{{ title }}" } }
+ %button.btn.btn-link{ type: 'button' }
+ {{ title }}
+ %span.btn-helptext
+ {{ help }}
#js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu
- if current_user
%ul{ data: { dropdown: true } }
@@ -170,5 +170,5 @@
- if @project
#js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } }
#js-toggle-focus-btn
- - elsif is_not_boards_modal_or_productivity_analytics
+ - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown
= render 'shared/issuable/sort_dropdown'
diff --git a/app/views/shared/issuable/form/_template_selector.html.haml b/app/views/shared/issuable/form/_template_selector.html.haml
index d613bd31d81..bf34ea4a1b2 100644
--- a/app/views/shared/issuable/form/_template_selector.html.haml
+++ b/app/views/shared/issuable/form/_template_selector.html.haml
@@ -2,7 +2,7 @@
- return unless issuable && issuable_templates(issuable).any?
-.col-sm-3.col-lg-2
+.issuable-form-select-holder.selectbox.form-group
.js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name } }
= template_dropdown_tag(issuable) do
%ul.dropdown-footer-list
diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml
index 18368ecc9ff..4aeeac87f3c 100644
--- a/app/views/shared/members/_group.html.haml
+++ b/app/views/shared/members/_group.html.haml
@@ -1,6 +1,7 @@
- group_link = local_assigns[:group_link]
-- group = group_link.group
-- can_admin_member = can?(current_user, :admin_project_member, @project)
+- group = group_link.shared_with_group
+- can_admin_member = local_assigns[:can_admin_member]
+- group_link_path = local_assigns[:group_link_path]
- dom_id = "group_member_#{group_link.id}"
-# Note this is just for groups. For individual members please see shared/members/_member
@@ -17,7 +18,7 @@
%span{ class: ('text-warning' if group_link.expires_soon?) }
= _("Expires in %{expires_at}").html_safe % { expires_at: distance_of_time_in_words_to_now(group_link.expires_at) }
.controls.member-controls.align-items-center
- = form_tag project_group_link_path(@project, group_link), method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
+ = form_tag group_link_path, method: :put, remote: true, class: 'js-edit-member-form form-group d-sm-flex' do
= hidden_field_tag "group_link[group_access]", group_link.group_access
.member-form-control.dropdown.mr-sm-2.d-sm-inline-block
%button.dropdown-menu-toggle.js-member-permissions-dropdown{ type: "button",
@@ -39,7 +40,7 @@
= text_field_tag 'group_link[expires_at]', group_link.expires_at, class: 'form-control js-access-expiration-date js-member-update-control', placeholder: _('Expiration date'), id: "member_expires_at_#{group.id}", disabled: !can_admin_member
%i.clear-icon.js-clear-input
- if can_admin_member
- = link_to project_group_link_path(@project, group_link),
+ = link_to group_link_path,
method: :delete,
data: { confirm: _("Are you sure you want to remove %{group_name}?") % { group_name: group.name }, qa_selector: 'delete_group_access_link' },
class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do
diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/shared/members/_invite_group.html.haml
index d413048ca10..27c930bcbb5 100644
--- a/app/views/projects/project_members/_new_project_group.html.haml
+++ b/app/views/shared/members/_invite_group.html.haml
@@ -1,13 +1,18 @@
+- access_levels = local_assigns[:access_levels]
+- default_access_level = local_assigns[:default_access_level]
+- submit_url = local_assigns[:submit_url]
+- group_link_field = local_assigns[:group_link_field]
+- group_access_field = local_assigns[:group_access_field]
.row
.col-sm-12
- = form_tag project_group_links_path(@project), class: 'js-requires-input', method: :post do
+ = form_tag submit_url, class: 'invite-group-form js-requires-input', method: :post do
.form-group
- = label_tag :link_group_id, _("Select a group to invite"), class: "label-bold"
- = groups_select_tag(:link_group_id, data: { skip_groups: @skip_groups }, class: "input-clamp qa-group-select-field", required: true)
+ = label_tag group_link_field, _("Select a group to invite"), class: "label-bold"
+ = groups_select_tag(group_link_field, data: { skip_groups: @skip_groups }, class: 'input-clamp qa-group-select-field', required: true)
.form-group
- = label_tag :link_group_access, _("Max access level"), class: "label-bold"
+ = label_tag group_access_field, _("Max access level"), class: "label-bold"
.select-wrapper
- = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
+ = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- permissions_docs_path = help_page_path('user/permissions')
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/shared/members/_invite_member.html.haml
index 149b0d6cddd..d3a1c85e285 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/shared/members/_invite_member.html.haml
@@ -1,13 +1,18 @@
+- access_levels = local_assigns[:access_levels]
+- default_access_level = local_assigns[:default_access_level]
+- submit_url = local_assigns[:submit_url]
+- can_import_members = local_assigns[:can_import_members?]
+- import_path = local_assigns[:import_path]
.row
.col-sm-12
- = form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f|
+ = form_tag submit_url, class: 'invite-users-form', method: :post do
.form-group
= label_tag :user_ids, _("GitLab member or Email address"), class: "label-bold"
- = users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
+ = users_select_tag(:user_ids, multiple: true, class: 'input-clamp qa-member-select-field', scope: :all, email_user: true, placeholder: 'Search for members to update or invite')
.form-group
= label_tag :access_level, _("Choose a role permission"), class: "label-bold"
.select-wrapper
- = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control"
+ = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- permissions_docs_path = help_page_path('user/permissions')
@@ -18,6 +23,6 @@
= label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
%i.clear-icon.js-clear-input
- = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button"
- - if can_import_members?
- = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project")
+ = submit_tag _("Invite"), class: "btn btn-success", data: { qa_selector: 'invite_member_button' }
+ - if can_import_members
+ = link_to _("Import"), import_path, class: "btn btn-default", title: _("Import members from another project")
diff --git a/app/views/shared/plugins/_index.html.haml b/app/views/shared/plugins/_index.html.haml
deleted file mode 100644
index 9d230d12be2..00000000000
--- a/app/views/shared/plugins/_index.html.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- plugins = Gitlab::Plugin.files
-
-.row.prepend-top-default
- .col-lg-4
- %h4.prepend-top-0
- Plugins
- %p
- #{link_to 'Plugins', help_page_path('administration/plugins')} are similar to
- system hooks but are executed as files instead of sending data to a URL.
-
- .col-lg-8.append-bottom-default
- - if plugins.any?
- .card
- .card-header
- Plugins (#{plugins.count})
- %ul.content-list
- - plugins.each do |file|
- %li
- .monospace
- = File.basename(file)
- - else
- .card.bg-light.text-center
- .nothing-here-block No plugins found.
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index fab7ee9d763..c0c009f2a86 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -6,7 +6,6 @@
- merge_requests = true unless local_assigns[:merge_requests] == false
- issues = true unless local_assigns[:issues] == false
- pipeline_status = true unless local_assigns[:pipeline_status] == false
-- ci = false unless local_assigns[:ci] == true
- skip_namespace = false unless local_assigns[:skip_namespace] == true
- user = local_assigns[:user]
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true
@@ -40,7 +39,7 @@
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
= render "shared/projects/project", project: project, skip_namespace: skip_namespace,
- avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar,
+ avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar,
forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests,
issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode
= paginate_collection(projects, remote: remote) unless skip_pagination
diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml
index d2e35511b32..b401820daf6 100644
--- a/app/views/shared/snippets/_embed.html.haml
+++ b/app/views/shared/snippets/_embed.html.haml
@@ -10,10 +10,8 @@
%small
= number_to_human_size(blob.raw_size)
- %a.gitlab-logo{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
- on &nbsp;
- %span.logo-text
- GitLab
+ %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' }
+ %img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" }
.file-actions.d-none.d-sm-block
.btn-group{ role: "group" }<
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 02acf360afc..62b37f52cce 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -10,6 +10,7 @@
- chaos:chaos_sleep
- cronjob:admin_email
+- cronjob:container_expiration_policy
- cronjob:expire_build_artifacts
- cronjob:gitlab_usage_ping
- cronjob:import_export_project_cleanup
@@ -103,6 +104,7 @@
- pipeline_processing:stage_update
- pipeline_processing:update_head_pipeline_for_merge_request
- pipeline_processing:ci_build_schedule
+- pipeline_processing:ci_resource_groups_assign_resource_from_resource_group
- deployment:deployments_success
- deployment:deployments_finished
@@ -156,7 +158,7 @@
- pages
- pages_domain_verification
- pages_domain_ssl_renewal
-- plugin
+- file_hook
- post_receive
- process_commit
- project_cache
@@ -186,3 +188,5 @@
- project_daily_statistics
- create_evidence
- group_export
+- self_monitoring_project_create
+- self_monitoring_project_delete
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
index 42a23cd472a..6162dcf9d38 100644
--- a/app/workers/chat_notification_worker.rb
+++ b/app/workers/chat_notification_worker.rb
@@ -3,6 +3,9 @@
class ChatNotificationWorker
include ApplicationWorker
+ TimeoutExceeded = Class.new(StandardError)
+
+ sidekiq_options retry: false
feature_category :chatops
latency_sensitive_worker!
# TODO: break this into multiple jobs
@@ -11,18 +14,21 @@ class ChatNotificationWorker
# worker_has_external_dependencies!
RESCHEDULE_INTERVAL = 2.seconds
+ RESCHEDULE_TIMEOUT = 5.minutes
# rubocop: disable CodeReuse/ActiveRecord
- def perform(build_id)
+ def perform(build_id, reschedule_count = 0)
Ci::Build.find_by(id: build_id).try do |build|
send_response(build)
end
rescue Gitlab::Chat::Output::MissingBuildSectionError
+ raise TimeoutExceeded if timeout_exceeded?(reschedule_count)
+
# The creation of traces and sections appears to be eventually consistent.
# As a result it's possible for us to run the above code before the trace
# sections are present. To better handle such cases we'll just reschedule
# the job instead of producing an error.
- self.class.perform_in(RESCHEDULE_INTERVAL, build_id)
+ self.class.perform_in(RESCHEDULE_INTERVAL, build_id, reschedule_count + 1)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -37,4 +43,10 @@ class ChatNotificationWorker
end
end
end
+
+ private
+
+ def timeout_exceeded?(reschedule_count)
+ (reschedule_count * RESCHEDULE_INTERVAL) >= RESCHEDULE_TIMEOUT
+ end
end
diff --git a/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb
new file mode 100644
index 00000000000..62233d19516
--- /dev/null
+++ b/app/workers/ci/resource_groups/assign_resource_from_resource_group_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ module ResourceGroups
+ class AssignResourceFromResourceGroupWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+ feature_category :continuous_delivery
+
+ def perform(resource_group_id)
+ ::Ci::ResourceGroup.find_by_id(resource_group_id).try do |resource_group|
+ Ci::ResourceGroups::AssignResourceFromResourceGroupService.new(resource_group.project, nil)
+ .execute(resource_group)
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/cluster_queue.rb b/app/workers/concerns/cluster_queue.rb
index 180b86b0124..60ba8785347 100644
--- a/app/workers/concerns/cluster_queue.rb
+++ b/app/workers/concerns/cluster_queue.rb
@@ -8,6 +8,6 @@ module ClusterQueue
included do
queue_namespace :gcp_cluster
- feature_category :kubernetes_configuration
+ feature_category :kubernetes_management
end
end
diff --git a/app/workers/concerns/reenqueuer.rb b/app/workers/concerns/reenqueuer.rb
new file mode 100644
index 00000000000..5cc13e490d8
--- /dev/null
+++ b/app/workers/concerns/reenqueuer.rb
@@ -0,0 +1,101 @@
+# frozen_string_literal: true
+
+#
+# A concern that helps run exactly one instance of a worker, over and over,
+# until it returns false or raises.
+#
+# To ensure the worker is always up, you can schedule it every minute with
+# sidekiq-cron. Excess jobs will immediately exit due to an exclusive lease.
+#
+# The worker must define:
+#
+# - `#perform`
+# - `#lease_timeout`
+#
+# The worker spec should include `it_behaves_like 'reenqueuer'` and
+# `it_behaves_like 'it is rate limited to 1 call per'`.
+#
+# Optionally override `#minimum_duration` to adjust the rate limit.
+#
+# When `#perform` returns false, the job will not be reenqueued. Instead, we
+# will wait for the next one scheduled by sidekiq-cron.
+#
+# #lease_timeout should be longer than the longest possible `#perform`.
+# The lease is normally released in an ensure block, but it is possible to
+# orphan the lease by killing Sidekiq, so it should also be as short as
+# possible. Consider that long-running jobs are generally not recommended.
+# Ideally, every job finishes within 25 seconds because that is the default
+# wait time for graceful termination.
+#
+# Timing: It runs as often as Sidekiq allows. We rate limit with sleep for
+# now: https://gitlab.com/gitlab-org/gitlab/issues/121697
+module Reenqueuer
+ extend ActiveSupport::Concern
+
+ prepended do
+ include ExclusiveLeaseGuard
+ include ReenqueuerSleeper
+
+ sidekiq_options retry: false
+ end
+
+ def perform(*args)
+ try_obtain_lease do
+ reenqueue(*args) do
+ ensure_minimum_duration(minimum_duration) do
+ super
+ end
+ end
+ end
+ end
+
+ private
+
+ def reenqueue(*args)
+ self.class.perform_async(*args) if yield
+ end
+
+ # Override as needed
+ def minimum_duration
+ 5.seconds
+ end
+
+ # We intend to get rid of sleep:
+ # https://gitlab.com/gitlab-org/gitlab/issues/121697
+ module ReenqueuerSleeper
+ # The block will run, and then sleep until the minimum duration. Returns the
+ # block's return value.
+ #
+ # Usage:
+ #
+ # ensure_minimum_duration(5.seconds) do
+ # # do something
+ # end
+ #
+ def ensure_minimum_duration(minimum_duration)
+ start_time = Time.now
+
+ result = yield
+
+ sleep_if_time_left(minimum_duration, start_time)
+
+ result
+ end
+
+ private
+
+ def sleep_if_time_left(minimum_duration, start_time)
+ time_left = calculate_time_left(minimum_duration, start_time)
+
+ sleep(time_left) if time_left > 0
+ end
+
+ def calculate_time_left(minimum_duration, start_time)
+ minimum_duration - elapsed_time(start_time)
+ end
+
+ def elapsed_time(start_time)
+ Time.now - start_time
+ end
+ end
+end
diff --git a/app/workers/concerns/self_monitoring_project_worker.rb b/app/workers/concerns/self_monitoring_project_worker.rb
new file mode 100644
index 00000000000..44dd6866fad
--- /dev/null
+++ b/app/workers/concerns/self_monitoring_project_worker.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module SelfMonitoringProjectWorker
+ extend ActiveSupport::Concern
+
+ included do
+ # This worker falls under Self-monitoring with Monitor::APM group. However,
+ # self-monitoring is not classified as a feature category but rather as
+ # Other Functionality. Metrics seems to be the closest feature_category for
+ # this worker.
+ feature_category :metrics
+ end
+
+ LEASE_TIMEOUT = 15.minutes.to_i
+ EXCLUSIVE_LEASE_KEY = 'self_monitoring_service_creation_deletion'
+
+ class_methods do
+ # @param job_id [String]
+ # Job ID that is used to construct the cache keys.
+ # @return [Hash]
+ # Returns true if the job is enqueued or in progress and false otherwise.
+ def in_progress?(job_id)
+ Gitlab::SidekiqStatus.job_status(Array.wrap(job_id)).first
+ end
+ end
+
+ private
+
+ def lease_key
+ EXCLUSIVE_LEASE_KEY
+ end
+
+ def lease_timeout
+ self.class::LEASE_TIMEOUT
+ end
+end
diff --git a/app/models/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb
index 506215ca9ed..506215ca9ed 100644
--- a/app/models/concerns/worker_attributes.rb
+++ b/app/workers/concerns/worker_attributes.rb
diff --git a/app/workers/container_expiration_policy_worker.rb b/app/workers/container_expiration_policy_worker.rb
new file mode 100644
index 00000000000..595208230f6
--- /dev/null
+++ b/app/workers/container_expiration_policy_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class ContainerExpirationPolicyWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ feature_category :container_registry
+
+ def perform
+ ContainerExpirationPolicy.runnable_schedules.preloaded.find_each do |container_expiration_policy|
+ ContainerExpirationPolicyService.new(
+ container_expiration_policy.project, container_expiration_policy.project.owner
+ ).execute(container_expiration_policy)
+ end
+ end
+end
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
index 90bbc193651..6196b032f63 100644
--- a/app/workers/deployments/finished_worker.rb
+++ b/app/workers/deployments/finished_worker.rb
@@ -9,7 +9,10 @@ module Deployments
worker_resource_boundary :cpu
def perform(deployment_id)
- Deployment.find_by_id(deployment_id).try(:execute_hooks)
+ if (deploy = Deployment.find_by_id(deployment_id))
+ LinkMergeRequestsService.new(deploy).execute
+ deploy.execute_hooks
+ end
end
end
end
diff --git a/app/workers/plugin_worker.rb b/app/workers/file_hook_worker.rb
index e708031abdf..24fc2d75d24 100644
--- a/app/workers/plugin_worker.rb
+++ b/app/workers/file_hook_worker.rb
@@ -1,16 +1,16 @@
# frozen_string_literal: true
-class PluginWorker
+class FileHookWorker
include ApplicationWorker
sidekiq_options retry: false
feature_category :integrations
def perform(file_name, data)
- success, message = Gitlab::Plugin.execute(file_name, data)
+ success, message = Gitlab::FileHook.execute(file_name, data)
unless success
- Gitlab::PluginLogger.error("Plugin Error => #{file_name}: #{message}")
+ Gitlab::FileHookLogger.error("File Hook Error => #{file_name}: #{message}")
end
true
diff --git a/app/workers/group_destroy_worker.rb b/app/workers/group_destroy_worker.rb
index 553fd359baf..fc751f8b612 100644
--- a/app/workers/group_destroy_worker.rb
+++ b/app/workers/group_destroy_worker.rb
@@ -4,7 +4,7 @@ class GroupDestroyWorker
include ApplicationWorker
include ExceptionBacktrace
- feature_category :groups
+ feature_category :subgroups
def perform(group_id, user_id)
begin
diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb
index 5b742461f7a..0321ea5a6ce 100644
--- a/app/workers/pipeline_update_worker.rb
+++ b/app/workers/pipeline_update_worker.rb
@@ -7,10 +7,7 @@ class PipelineUpdateWorker
queue_namespace :pipeline_processing
latency_sensitive_worker!
- # rubocop: disable CodeReuse/ActiveRecord
def perform(pipeline_id)
- Ci::Pipeline.find_by(id: pipeline_id)
- .try(:update_status)
+ Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb
index 7343226fdcd..fd182125c07 100644
--- a/app/workers/rebase_worker.rb
+++ b/app/workers/rebase_worker.rb
@@ -7,12 +7,12 @@ class RebaseWorker
feature_category :source_code_management
- def perform(merge_request_id, current_user_id)
+ def perform(merge_request_id, current_user_id, skip_ci = false)
current_user = User.find(current_user_id)
merge_request = MergeRequest.find(merge_request_id)
MergeRequests::RebaseService
.new(merge_request.source_project, current_user)
- .execute(merge_request)
+ .execute(merge_request, skip_ci: skip_ci)
end
end
diff --git a/app/workers/self_monitoring_project_create_worker.rb b/app/workers/self_monitoring_project_create_worker.rb
new file mode 100644
index 00000000000..429ac8aacc4
--- /dev/null
+++ b/app/workers/self_monitoring_project_create_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class SelfMonitoringProjectCreateWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+ include SelfMonitoringProjectWorker
+
+ def perform
+ try_obtain_lease do
+ Gitlab::DatabaseImporters::SelfMonitoring::Project::CreateService.new.execute
+ end
+ end
+end
diff --git a/app/workers/self_monitoring_project_delete_worker.rb b/app/workers/self_monitoring_project_delete_worker.rb
new file mode 100644
index 00000000000..07a7d3f6c45
--- /dev/null
+++ b/app/workers/self_monitoring_project_delete_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class SelfMonitoringProjectDeleteWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+ include SelfMonitoringProjectWorker
+
+ def perform
+ try_obtain_lease do
+ Gitlab::DatabaseImporters::SelfMonitoring::Project::DeleteService.new.execute
+ end
+ end
+end
diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb
index de2454128f6..a96c4c6dda2 100644
--- a/app/workers/stage_update_worker.rb
+++ b/app/workers/stage_update_worker.rb
@@ -7,11 +7,7 @@ class StageUpdateWorker
queue_namespace :pipeline_processing
latency_sensitive_worker!
- # rubocop: disable CodeReuse/ActiveRecord
def perform(stage_id)
- Ci::Stage.find_by(id: stage_id).try do |stage|
- stage.update_status
- end
+ Ci::Stage.find_by_id(stage_id)&.update_legacy_status
end
- # rubocop: enable CodeReuse/ActiveRecord
end