summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/auth_buttons/salesforce_64.pngbin0 -> 8774 bytes
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js8
-rw-r--r--app/assets/javascripts/boards/components/board.js40
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue22
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue13
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js1
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue25
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue10
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue30
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue11
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue7
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue4
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue9
-rw-r--r--app/assets/javascripts/boards/components/sidebar/remove_issue.vue2
-rw-r--r--app/assets/javascripts/boards/index.js1
-rw-r--r--app/assets/javascripts/boards/mixins/sortable_default_options.js4
-rw-r--r--app/assets/javascripts/boards/models/list.js8
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js7
-rw-r--r--app/assets/javascripts/branches/divergence_graph.js62
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue4
-rw-r--r--app/assets/javascripts/clusters/components/knative_domain_editor.vue10
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_button.vue3
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue4
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js6
-rw-r--r--app/assets/javascripts/commons/polyfills.js1
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js8
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue4
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions_dropdown.vue16
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussion_reply.vue55
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue1
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_gutter_avatars.vue29
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue17
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue40
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue85
-rw-r--r--app/assets/javascripts/diffs/store/actions.js34
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js2
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js65
-rw-r--r--app/assets/javascripts/diffs/store/utils.js45
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue12
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue19
-rw-r--r--app/assets/javascripts/environments/components/environment_monitoring.vue3
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue3
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue9
-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.vue6
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js6
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js3
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js26
-rw-r--r--app/assets/javascripts/group.js4
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list_item.vue3
-rw-r--r--app/assets/javascripts/ide/components/external_link.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide_tree_list.vue7
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue55
-rw-r--r--app/assets/javascripts/ide/components/repo_file_status_icon.vue5
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue5
-rw-r--r--app/assets/javascripts/ide/stores/actions.js19
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js4
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js22
-rw-r--r--app/assets/javascripts/ide/stores/mutations/file.js6
-rw-r--r--app/assets/javascripts/ide/stores/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/utils.js10
-rw-r--r--app/assets/javascripts/ide/utils.js2
-rw-r--r--app/assets/javascripts/issue_show/components/edit_actions.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description_template.vue25
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue6
-rw-r--r--app/assets/javascripts/issue_show/components/locked_warning.vue7
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue8
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue5
-rw-r--r--app/assets/javascripts/lib/graphql.js2
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js17
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js11
-rw-r--r--app/assets/javascripts/manual_ordering.js58
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue17
-rw-r--r--app/assets/javascripts/monitoring/components/charts/column.vue131
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue113
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue50
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js14
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js21
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js7
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js3
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue17
-rw-r--r--app/assets/javascripts/notes/components/discussion_counter.vue6
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue57
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue8
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue2
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue4
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue2
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue29
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js6
-rw-r--r--app/assets/javascripts/notes/stores/actions.js12
-rw-r--r--app/assets/javascripts/notes/stores/getters.js6
-rw-r--r--app/assets/javascripts/pages/dashboard/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/branches/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue5
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue72
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js27
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js26
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js4
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue4
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue29
-rw-r--r--app/assets/javascripts/performance_bar/components/simple_metric.vue33
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue6
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js6
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js2
-rw-r--r--app/assets/javascripts/projects/project_new.js4
-rw-r--r--app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue4
-rw-r--r--app/assets/javascripts/registry/components/app.vue109
-rw-r--r--app/assets/javascripts/registry/components/svg_message.vue24
-rw-r--r--app/assets/javascripts/registry/index.js10
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue14
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue2
-rw-r--r--app/assets/javascripts/repository/components/breadcrumbs.vue2
-rw-r--r--app/assets/javascripts/repository/components/last_commit.vue135
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue2
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue53
-rw-r--r--app/assets/javascripts/repository/graphql.js17
-rw-r--r--app/assets/javascripts/repository/index.js31
-rw-r--r--app/assets/javascripts/repository/log_tree.js64
-rw-r--r--app/assets/javascripts/repository/queries/getCommit.query.graphql10
-rw-r--r--app/assets/javascripts/repository/queries/getCommits.query.graphql10
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.query.graphql1
-rw-r--r--app/assets/javascripts/repository/queries/pathLastCommit.query.graphql6
-rw-r--r--app/assets/javascripts/serverless/components/area.vue15
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue4
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue47
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue1
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue5
-rw-r--r--app/assets/javascripts/sidebar/mount_milestone_sidebar.js4
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js2
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js3
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/comment.js50
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/constants.js8
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/index.js16
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/login.js3
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/note.js14
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/utils.js6
-rw-r--r--app/assets/javascripts/visual_review_toolbar/components/wrapper.js36
-rw-r--r--app/assets/javascripts/visual_review_toolbar/index.js9
-rw-r--r--app/assets/javascripts/visual_review_toolbar/store/state.js3
-rw-r--r--app/assets/javascripts/visual_review_toolbar/styles/toolbar.css48
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue151
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js3
-rw-r--r--app/assets/stylesheets/application.scss8
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss81
-rw-r--r--app/assets/stylesheets/components/toast.scss4
-rw-r--r--app/assets/stylesheets/csslab.scss2
-rw-r--r--app/assets/stylesheets/errors.scss12
-rw-r--r--app/assets/stylesheets/framework.scss2
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss10
-rw-r--r--app/assets/stylesheets/pages/boards.scss72
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss6
-rw-r--r--app/assets/stylesheets/pages/diff.scss16
-rw-r--r--app/assets/stylesheets/pages/issuable.scss6
-rw-r--r--app/assets/stylesheets/pages/issues.scss14
-rw-r--r--app/assets/stylesheets/pages/notes.scss35
-rw-r--r--app/controllers/admin/application_settings_controller.rb2
-rw-r--r--app/controllers/application_controller.rb6
-rw-r--r--app/controllers/concerns/boards_responses.rb3
-rw-r--r--app/controllers/concerns/continue_params.rb2
-rw-r--r--app/controllers/concerns/internal_redirect.rb4
-rw-r--r--app/controllers/concerns/issuable_collections.rb2
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb4
-rw-r--r--app/controllers/concerns/multiple_boards_actions.rb90
-rw-r--r--app/controllers/concerns/notes_actions.rb14
-rw-r--r--app/controllers/concerns/requires_whitelisted_monitoring_client.rb4
-rw-r--r--app/controllers/dashboard/projects_controller.rb1
-rw-r--r--app/controllers/dashboard/todos_controller.rb1
-rw-r--r--app/controllers/groups/clusters_controller.rb9
-rw-r--r--app/controllers/groups_controller.rb7
-rw-r--r--app/controllers/projects/application_controller.rb11
-rw-r--r--app/controllers/projects/boards_controller.rb2
-rw-r--r--app/controllers/projects/branches_controller.rb38
-rw-r--r--app/controllers/projects/forks_controller.rb18
-rw-r--r--app/controllers/projects/imports_controller.rb8
-rw-r--r--app/controllers/projects/issues_controller.rb5
-rw-r--r--app/controllers/projects/jobs_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb7
-rw-r--r--app/controllers/projects/merge_requests/content_controller.rb22
-rw-r--r--app/controllers/projects/merge_requests_controller.rb6
-rw-r--r--app/controllers/projects/refs_controller.rb1
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb2
-rw-r--r--app/controllers/projects/settings/repository_controller.rb2
-rw-r--r--app/controllers/projects/templates_controller.rb17
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/controllers/registrations_controller.rb30
-rw-r--r--app/controllers/search_controller.rb2
-rw-r--r--app/controllers/snippets/notes_controller.rb8
-rw-r--r--app/controllers/snippets_controller.rb2
-rw-r--r--app/controllers/uploads_controller.rb20
-rw-r--r--app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb3
-rw-r--r--app/finders/boards/visits_finder.rb26
-rw-r--r--app/finders/branches_finder.rb14
-rw-r--r--app/graphql/mutations/.keep0
-rw-r--r--app/graphql/mutations/award_emojis/add.rb25
-rw-r--r--app/graphql/mutations/award_emojis/base.rb41
-rw-r--r--app/graphql/mutations/award_emojis/remove.rb33
-rw-r--r--app/graphql/mutations/award_emojis/toggle.rb40
-rw-r--r--app/graphql/mutations/base_mutation.rb7
-rw-r--r--app/graphql/types/award_emojis/award_emoji_type.rb46
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb3
-rw-r--r--app/graphql/types/commit_type.rb30
-rw-r--r--app/graphql/types/issue_state_enum.rb3
-rw-r--r--app/graphql/types/label_type.rb2
-rw-r--r--app/graphql/types/merge_request_state_enum.rb3
-rw-r--r--app/graphql/types/metadata_type.rb2
-rw-r--r--app/graphql/types/mutation_type.rb3
-rw-r--r--app/graphql/types/namespace_type.rb2
-rw-r--r--app/graphql/types/notes/diff_position_type.rb3
-rw-r--r--app/graphql/types/project_type.rb4
-rw-r--r--app/graphql/types/query_type.rb5
-rw-r--r--app/graphql/types/task_completion_status.rb4
-rw-r--r--app/graphql/types/tree/blob_type.rb3
-rw-r--r--app/graphql/types/tree/submodule_type.rb3
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb3
-rw-r--r--app/graphql/types/tree/tree_type.rb8
-rw-r--r--app/helpers/application_settings_helper.rb11
-rw-r--r--app/helpers/auth_helper.rb2
-rw-r--r--app/helpers/boards_helper.rb18
-rw-r--r--app/helpers/branches_helper.rb8
-rw-r--r--app/helpers/groups_helper.rb2
-rw-r--r--app/helpers/issuables_helper.rb5
-rw-r--r--app/helpers/issues_helper.rb1
-rw-r--r--app/helpers/onboarding_experiment_helper.rb7
-rw-r--r--app/helpers/recaptcha_experiment_helper.rb7
-rw-r--r--app/helpers/search_helper.rb5
-rw-r--r--app/helpers/snippets_helper.rb10
-rw-r--r--app/helpers/todos_helper.rb2
-rw-r--r--app/mailers/emails/notes.rb4
-rw-r--r--app/models/application_setting.rb26
-rw-r--r--app/models/application_setting_implementation.rb22
-rw-r--r--app/models/board.rb5
-rw-r--r--app/models/broadcast_message.rb2
-rw-r--r--app/models/ci/pipeline.rb5
-rw-r--r--app/models/clusters/applications/ingress.rb15
-rw-r--r--app/models/clusters/applications/jupyter.rb10
-rw-r--r--app/models/clusters/applications/prometheus.rb8
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/instance.rb4
-rw-r--r--app/models/clusters/platforms/kubernetes.rb49
-rw-r--r--app/models/concerns/cacheable_attributes.rb10
-rw-r--r--app/models/concerns/deployable.rb1
-rw-r--r--app/models/concerns/deployment_platform.rb16
-rw-r--r--app/models/concerns/issuable.rb6
-rw-r--r--app/models/concerns/relative_positioning.rb4
-rw-r--r--app/models/concerns/update_project_statistics.rb19
-rw-r--r--app/models/deploy_token.rb14
-rw-r--r--app/models/deployment.rb27
-rw-r--r--app/models/environment.rb19
-rw-r--r--app/models/group.rb4
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/list.rb1
-rw-r--r--app/models/merge_request.rb5
-rw-r--r--app/models/merge_requests_closing_issues.rb35
-rw-r--r--app/models/namespace.rb10
-rw-r--r--app/models/namespace/aggregation_schedule.rb47
-rw-r--r--app/models/namespace/root_storage_statistics.rb38
-rw-r--r--app/models/note.rb2
-rw-r--r--app/models/pages_domain.rb10
-rw-r--r--app/models/postgresql/replication_slot.rb2
-rw-r--r--app/models/project.rb9
-rw-r--r--app/models/project_services/bugzilla_service.rb18
-rw-r--r--app/models/project_services/custom_issue_tracker_service.rb20
-rw-r--r--app/models/project_services/gitlab_issue_tracker_service.rb10
-rw-r--r--app/models/project_services/issue_tracker_service.rb33
-rw-r--r--app/models/project_services/jira_service.rb41
-rw-r--r--app/models/project_services/redmine_service.rb18
-rw-r--r--app/models/project_services/youtrack_service.rb12
-rw-r--r--app/models/release.rb10
-rw-r--r--app/models/repository.rb41
-rw-r--r--app/models/service.rb2
-rw-r--r--app/models/snippet.rb4
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/user.rb11
-rw-r--r--app/policies/award_emoji_policy.rb11
-rw-r--r--app/policies/clusters/instance_policy.rb3
-rw-r--r--app/policies/project_policy.rb3
-rw-r--r--app/policies/repository_policy.rb5
-rw-r--r--app/presenters/award_emoji_presenter.rb27
-rw-r--r--app/presenters/commit_presenter.rb8
-rw-r--r--app/serializers/board_simple_entity.rb1
-rw-r--r--app/serializers/merge_request_widget_entity.rb8
-rw-r--r--app/services/boards/create_service.rb2
-rw-r--r--app/services/boards/destroy_service.rb11
-rw-r--r--app/services/boards/update_service.rb9
-rw-r--r--app/services/boards/visits/latest_service.rb19
-rw-r--r--app/services/branches/diverging_commit_counts_service.rb32
-rw-r--r--app/services/deploy_tokens/create_service.rb4
-rw-r--r--app/services/issuable_base_service.rb13
-rw-r--r--app/services/merge_requests/create_from_issue_service.rb36
-rw-r--r--app/services/namespaces/statistics_refresher_service.rb22
-rw-r--r--app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb10
-rw-r--r--app/services/projects/propagate_service_template.rb2
-rw-r--r--app/services/releases/concerns.rb4
-rw-r--r--app/services/releases/create_service.rb1
-rw-r--r--app/services/system_note_service.rb9
-rw-r--r--app/services/users/update_service.rb10
-rw-r--r--app/uploaders/file_mover.rb82
-rw-r--r--app/validators/color_validator.rb2
-rw-r--r--app/views/admin/application_settings/_grafana.html.haml17
-rw-r--r--app/views/admin/application_settings/_localization.html.haml7
-rw-r--r--app/views/admin/application_settings/_logging.html.haml38
-rw-r--r--app/views/admin/application_settings/_spam.html.haml5
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml11
-rw-r--r--app/views/admin/application_settings/reporting.html.haml11
-rw-r--r--app/views/admin/runners/index.html.haml2
-rw-r--r--app/views/admin/services/_form.html.haml2
-rw-r--r--app/views/clusters/clusters/_form.html.haml2
-rw-r--r--app/views/devise/shared/_signup_box.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml4
-rw-r--r--app/views/discussions/_new_issue_for_discussion.html.haml4
-rw-r--r--app/views/groups/settings/_general.html.haml2
-rw-r--r--app/views/layouts/_head.html.haml2
-rw-r--r--app/views/layouts/fullscreen.html.haml2
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml5
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml2
-rw-r--r--app/views/layouts/snippets.html.haml5
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml40
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_files.html.haml4
-rw-r--r--app/views/projects/_merge_request_settings_description_text.html.haml1
-rw-r--r--app/views/projects/blob/_editor.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml8
-rw-r--r--app/views/projects/branches/index.html.haml1
-rw-r--r--app/views/projects/deploy_tokens/_form.html.haml5
-rw-r--r--app/views/projects/edit.html.haml4
-rw-r--r--app/views/projects/environments/show.html.haml28
-rw-r--r--app/views/projects/issues/_issues.html.haml2
-rw-r--r--app/views/projects/new.html.haml4
-rw-r--r--app/views/projects/notes/_actions.html.haml2
-rw-r--r--app/views/projects/pages_domains/_form.html.haml2
-rw-r--r--app/views/projects/registry/repositories/index.html.haml54
-rw-r--r--app/views/projects/tree/_tree_header.html.haml1
-rw-r--r--app/views/search/_category.html.haml1
-rw-r--r--app/views/shared/_confirm_modal.html.haml6
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml2
-rw-r--r--app/views/shared/_issues.html.haml2
-rw-r--r--app/views/shared/_personal_access_tokens_created_container.html.haml7
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml14
-rw-r--r--app/views/shared/_personal_access_tokens_table.html.haml23
-rw-r--r--app/views/shared/boards/components/_board.html.haml60
-rw-r--r--app/views/shared/boards/components/sidebar/_time_tracker.html.haml1
-rw-r--r--app/views/shared/issuable/_form.html.haml6
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml3
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml6
-rw-r--r--app/views/shared/notes/_comment_button.html.haml4
-rw-r--r--app/views/shared/projects/_list.html.haml2
-rw-r--r--app/views/shared/tokens/_scopes_list.html.haml2
-rw-r--r--app/workers/all_queues.yml6
-rw-r--r--app/workers/namespaces/prune_aggregation_schedules_worker.rb22
-rw-r--r--app/workers/namespaces/root_statistics_worker.rb31
-rw-r--r--app/workers/namespaces/schedule_aggregation_worker.rb45
-rw-r--r--app/workers/pages_domain_ssl_renewal_cron_worker.rb14
-rw-r--r--app/workers/pages_domain_ssl_renewal_worker.rb13
374 files changed, 3785 insertions, 1582 deletions
diff --git a/app/assets/images/auth_buttons/salesforce_64.png b/app/assets/images/auth_buttons/salesforce_64.png
new file mode 100644
index 00000000000..c8a86a0c515
--- /dev/null
+++ b/app/assets/images/auth_buttons/salesforce_64.png
Binary files differ
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index d0b7f3ff7a2..b23de36f860 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -59,6 +59,14 @@ export default function renderMermaid($els) {
mermaid.init(undefined, el, id => {
const svg = document.getElementById(id);
+ // As of https://github.com/knsv/mermaid/commit/57b780a0d,
+ // Mermaid will make two init callbacks:one to initialize the
+ // flow charts, and another to initialize the Gannt charts.
+ // Guard against an error caused by double initialization.
+ if (svg.classList.contains('mermaid')) {
+ return;
+ }
+
svg.classList.add('mermaid');
// pre > code > svg
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index 45b9e57f9ab..c6122fbc686 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -1,6 +1,7 @@
+import $ from 'jquery';
import Sortable from 'sortablejs';
import Vue from 'vue';
-import { n__ } from '~/locale';
+import { n__, s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
import AccessorUtilities from '../../lib/utils/accessor';
@@ -53,12 +54,19 @@ export default Vue.extend({
const { issuesSize } = this.list;
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
+ caretTooltip() {
+ return this.list.isExpanded ? s__('Boards|Collapse') : s__('Boards|Expand');
+ },
isNewIssueShown() {
return (
this.list.type === 'backlog' ||
(!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
);
},
+ uniqueKey() {
+ // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
+ return `boards.${this.boardId}.${this.list.type}.${this.list.id}`;
+ },
},
watch: {
filter: {
@@ -72,31 +80,34 @@ export default Vue.extend({
},
},
mounted() {
- this.sortableOptions = getBoardSortableDefaultOptions({
+ const instance = this;
+
+ const sortableOptions = getBoardSortableDefaultOptions({
disabled: this.disabled,
group: 'boards',
draggable: '.is-draggable',
handle: '.js-board-handle',
- onEnd: e => {
+ onEnd(e) {
sortableEnd();
+ const sortable = this;
+
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
- const order = this.sortable.toArray();
+ const order = sortable.toArray();
const list = boardsStore.findList('id', parseInt(e.item.dataset.id, 10));
- this.$nextTick(() => {
+ instance.$nextTick(() => {
boardsStore.moveList(list, order);
});
}
},
});
- this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
+ Sortable.create(this.$el.parentNode, sortableOptions);
},
created() {
if (this.list.isExpandable && AccessorUtilities.isLocalStorageAccessSafe()) {
- const isCollapsed =
- localStorage.getItem(`boards.${this.boardId}.${this.list.type}.expanded`) === 'false';
+ const isCollapsed = localStorage.getItem(`${this.uniqueKey}.expanded`) === 'false';
this.list.isExpanded = !isCollapsed;
}
@@ -105,16 +116,17 @@ export default Vue.extend({
showNewIssueForm() {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
},
- toggleExpanded(e) {
- if (this.list.isExpandable && !e.target.classList.contains('js-no-trigger-collapse')) {
+ toggleExpanded() {
+ if (this.list.isExpandable) {
this.list.isExpanded = !this.list.isExpanded;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
- localStorage.setItem(
- `boards.${this.boardId}.${this.list.type}.expanded`,
- this.list.isExpanded,
- );
+ localStorage.setItem(`${this.uniqueKey}.expanded`, this.list.isExpanded);
}
+
+ // When expanding/collapsing, the tooltip on the caret button sometimes stays open.
+ // Close all tooltips manually to prevent dangling tooltips.
+ $('.tooltip').tooltip('hide');
}
},
},
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 1cbd31729cd..f58149c9f7b 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
/* global ListLabel */
import Cookies from 'js-cookie';
import boardsStore from '../stores/boards_store';
@@ -7,8 +8,8 @@ export default {
data() {
return {
predefinedLabels: [
- new ListLabel({ title: 'To Do', color: '#F0AD4E' }),
- new ListLabel({ title: 'Doing', color: '#5CB85C' }),
+ new ListLabel({ title: __('To Do'), color: '#F0AD4E' }),
+ new ListLabel({ title: __('Doing'), color: '#5CB85C' }),
],
};
},
@@ -58,7 +59,11 @@ export default {
<template>
<div class="board-blank-state p-3">
- <p>Add the following default lists to your Issue Board with one click:</p>
+ <p>
+ {{
+ __('BoardBlankState|Add the following default lists to your Issue Board with one click:')
+ }}
+ </p>
<ul class="list-unstyled board-blank-state-list">
<li v-for="(label, index) in predefinedLabels" :key="index">
<span
@@ -70,18 +75,21 @@ export default {
</li>
</ul>
<p>
- Starting out with the default set of lists will get you right on the way to making the most of
- your board.
+ {{
+ __(
+ 'BoardBlankState|Starting out with the default set of lists will get you right on the way to making the most of your board.',
+ )
+ }}
</p>
<button
class="btn btn-success btn-inverted btn-block"
type="button"
@click.stop="addDefaultLists"
>
- Add default lists
+ {{ __('BoardBlankState|Add default lists') }}
</button>
<button class="btn btn-default btn-block" type="button" @click.stop="clearBlankState">
- Nevermind, I'll use my own
+ {{ __("BoardBlankState|Nevermind, I'll use my own") }}
</button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index b1a8b13f3ac..787ff110bf8 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -227,7 +227,7 @@ export default {
:class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
class="board-list-component position-relative h-100"
>
- <div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues">
+ <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')">
<gl-loading-icon />
</div>
<board-new-issue
@@ -257,7 +257,7 @@ export default {
/>
<li v-if="showCount" class="board-list-count text-center" data-issue-id="-1">
<gl-loading-icon v-show="list.loadingMore" label="Loading more issues" />
- <span v-if="list.issues.length === list.issuesSize"> Showing all issues </span>
+ <span v-if="list.issues.length === list.issuesSize">{{ __('Showing all issues') }}</span>
<span v-else> Showing {{ list.issues.length }} of {{ list.issuesSize }} issues </span>
</li>
</ul>
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index cc6af8e88cd..4180023b7db 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -102,9 +102,9 @@ export default {
<div class="board-card position-relative p-3 rounded">
<form @submit="submit($event)">
<div v-if="error" class="flash-container">
- <div class="flash-alert">An error occurred. Please try again.</div>
+ <div class="flash-alert">{{ __('An error occurred. Please try again.') }}</div>
</div>
- <label :for="list.id + '-title'" class="label-bold"> Title </label>
+ <label :for="list.id + '-title'" class="label-bold">{{ __('Title') }}</label>
<input
:id="list.id + '-title'"
ref="input"
@@ -122,12 +122,11 @@ export default {
class="float-left"
variant="success"
type="submit"
+ >{{ __('Submit issue') }}</gl-button
>
- Submit issue
- </gl-button>
- <gl-button class="float-right" type="button" variant="default" @click="cancel">
- Cancel
- </gl-button>
+ <gl-button class="float-right" type="button" variant="default" @click="cancel">{{
+ __('Cancel')
+ }}</gl-button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index c587b276fa3..2ace0060c42 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -38,6 +38,7 @@ export default Vue.extend({
issue: {},
list: {},
loadingAssignees: false,
+ timeTrackingLimitToHours: boardsStore.timeTracking.limitToHours,
};
},
computed: {
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index a8516f178fc..7f554c99669 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -124,7 +124,7 @@ export default {
return `${this.rootPath}${assignee.username}`;
},
avatarUrlTitle(assignee) {
- return `Avatar for ${assignee.name}`;
+ return sprintf(__(`Avatar for %{assigneeName}`), { assigneeName: assignee.name });
},
showLabel(label) {
if (!label.id) return false;
@@ -160,9 +160,10 @@ export default {
:title="__('Confidential')"
class="confidential-icon append-right-4"
:aria-label="__('Confidential')"
- /><a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>{{
- issue.title
- }}</a>
+ />
+ <a :href="issue.path" :title="issue.title" class="js-no-trigger" @mousemove.stop>
+ {{ issue.title }}
+ </a>
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
@@ -204,13 +205,13 @@ export default {
placement="bottom"
class="board-issue-path block-truncated bold"
>{{ issueReferencePath }}</tooltip-on-truncate
- >#{{ issue.iid }}
+ >
+ #{{ issue.iid }}
</span>
<span class="board-info-items prepend-top-8 d-inline-block">
- <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate
- v-if="issue.timeEstimate"
- :estimate="issue.timeEstimate"
- /><issue-card-weight
+ <issue-due-date v-if="issue.dueDate" :date="issue.dueDate" />
+ <issue-time-estimate v-if="issue.timeEstimate" :estimate="issue.timeEstimate" />
+ <issue-card-weight
v-if="issue.weight"
:weight="issue.weight"
@click="filterByWeight(issue.weight)"
@@ -230,7 +231,8 @@ export default {
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
- <span class="bold d-block">Assignee</span> {{ assignee.name }}
+ <span class="bold d-block">{{ __('Assignee') }}</span>
+ {{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
@@ -240,9 +242,8 @@ export default {
:title="assigneeCounterTooltip"
class="avatar-counter"
data-placement="bottom"
+ >{{ assigneeCounterLabel }}</span
>
- {{ assigneeCounterLabel }}
- </span>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 98c1d29db16..3385aad5b11 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -2,6 +2,7 @@
import { GlTooltip } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { parseSeconds, stringifyTime } from '~/lib/utils/datetime_utility';
+import boardsStore from '../stores/boards_store';
export default {
components: {
@@ -14,12 +15,17 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ limitToHours: boardsStore.timeTracking.limitToHours,
+ };
+ },
computed: {
title() {
- return stringifyTime(parseSeconds(this.estimate), true);
+ return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }), true);
},
timeEstimate() {
- return stringifyTime(parseSeconds(this.estimate));
+ return stringifyTime(parseSeconds(this.estimate, { limitToHours: this.limitToHours }));
},
},
};
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
index 091700de93f..66f59009714 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ b/app/assets/javascripts/boards/components/modal/empty_state.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
@@ -20,19 +21,20 @@ export default {
computed: {
contents() {
const obj = {
- title: "You haven't added any issues to your project yet",
- content: `
- An issue can be a bug, a todo or a feature request that needs to be
- discussed in a project. Besides, issues are searchable and filterable.
- `,
+ title: __("You haven't added any issues to your project yet"),
+ content: __(
+ 'An issue can be a bug, a todo or a feature request that needs to be discussed in a project. Besides, issues are searchable and filterable.',
+ ),
};
if (this.activeTab === 'selected') {
- obj.title = "You haven't selected any issues yet";
- obj.content = `
- Go back to <strong>Open issues</strong> and select some issues
- to add to your board.
- `;
+ obj.title = __("You haven't selected any issues yet");
+ obj.content = sprintf(
+ __(
+ 'Go back to %{startTag}Open issues%{endTag} and select some issues to add to your board.',
+ ),
+ { startTag: '<strong>', endTag: '</strong>' },
+ );
}
return obj;
@@ -51,16 +53,16 @@ export default {
<div class="text-content">
<h4>{{ contents.title }}</h4>
<p v-html="contents.content"></p>
- <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">
- New issue
- </a>
+ <a v-if="activeTab === 'all'" :href="newIssuePath" class="btn btn-success btn-inverted">{{
+ __('New issue')
+ }}</a>
<button
v-if="activeTab === 'selected'"
class="btn btn-default"
type="button"
@click="changeTab('all')"
>
- Open issues
+ {{ __('Open issues') }}
</button>
</div>
</div>
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
index d4afd9d59da..a1d634c8f19 100644
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ b/app/assets/javascripts/boards/components/modal/footer.vue
@@ -1,8 +1,7 @@
<script>
import Flash from '../../../flash';
-import { __ } from '../../../locale';
+import { __, n__ } from '../../../locale';
import ListsDropdown from './lists_dropdown.vue';
-import { pluralize } from '../../../lib/utils/text_utility';
import ModalStore from '../../stores/modal_store';
import modalMixin from '../../mixins/modal_mixins';
import boardsStore from '../../stores/boards_store';
@@ -24,8 +23,8 @@ export default {
},
submitText() {
const count = ModalStore.selectedCount();
-
- return `Add ${count > 0 ? count : ''} ${pluralize('issue', count)}`;
+ if (!count) return __('Add issues');
+ return n__(`Add %d issue`, `Add %d issues`, count);
},
},
methods: {
@@ -68,11 +67,11 @@ export default {
<button :disabled="submitDisabled" class="btn btn-success" type="button" @click="addIssues">
{{ submitText }}
</button>
- <span class="inline add-issues-footer-to-list"> to list </span>
+ <span class="inline add-issues-footer-to-list">{{ __('to list') }}</span>
<lists-dropdown />
</div>
<button class="btn btn-default float-right" type="button" @click="toggleModal(false)">
- Cancel
+ {{ __('Cancel') }}
</button>
</footer>
</template>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index 1cfa6d39362..7a696035dc8 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import ModalFilters from './filters';
import ModalTabs from './tabs.vue';
import ModalStore from '../../stores/modal_store';
@@ -30,10 +31,10 @@ export default {
computed: {
selectAllText() {
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
- return 'Select all';
+ return __('Select all');
}
- return 'Deselect all';
+ return __('Deselect all');
},
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
@@ -57,7 +58,7 @@ export default {
type="button"
class="close"
data-dismiss="modal"
- aria-label="Close"
+ :aria-label="__('Close')"
@click="toggleModal(false)"
>
<span aria-hidden="true">×</span>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index 28d2019af2f..1802b543687 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -123,7 +123,9 @@ export default {
class="empty-state add-issues-empty-state-filter text-center"
>
<div class="svg-content"><img :src="emptyStateSvg" /></div>
- <div class="text-content"><h4>There are no issues to show.</h4></div>
+ <div class="text-content">
+ <h4>{{ __('There are no issues to show.') }}</h4>
+ </div>
</div>
<div v-for="(group, index) in groupedIssues" :key="index" class="add-issues-list-column">
<div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index 8274647744f..a1cf1866faf 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
import $ from 'jquery';
import _ from 'underscore';
import Icon from '~/vue_shared/components/icon.vue';
@@ -27,7 +28,7 @@ export default {
},
computed: {
selectedProjectName() {
- return this.selectedProject.name || 'Select a project';
+ return this.selectedProject.name || __('Select a project');
},
},
mounted() {
@@ -81,7 +82,7 @@ export default {
<template>
<div>
- <label class="label-bold prepend-top-10"> Project </label>
+ <label class="label-bold prepend-top-10">{{ __('Project') }}</label>
<div ref="projectsDropdown" class="dropdown dropdown-projects">
<button
class="dropdown-menu-toggle wide"
@@ -92,9 +93,9 @@ export default {
{{ selectedProjectName }} <icon name="chevron-down" />
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-full-width">
- <div class="dropdown-title">Projects</div>
+ <div class="dropdown-title">{{ __('Projects') }}</div>
<div class="dropdown-input">
- <input class="dropdown-input-field" type="search" placeholder="Search projects" />
+ <input class="dropdown-input-field" type="search" :placeholder="__('Search projects')" />
<icon name="search" class="dropdown-input-search" data-hidden="true" />
</div>
<div class="dropdown-content"></div>
diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
index 4ab2b17301f..b84722244d1 100644
--- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
+++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue
@@ -76,7 +76,7 @@ export default Vue.extend({
<template>
<div class="block list">
<button class="btn btn-default btn-block" type="button" @click="removeIssue">
- Remove from board
+ {{ __('Remove from board') }}
</button>
</div>
</template>
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f2f37d22b97..a020765f335 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -49,6 +49,7 @@ export default () => {
}
boardsStore.create();
+ boardsStore.setTimeTrackingLimitToHours($boardApp.dataset.timeTrackingLimitToHours);
issueBoardsApp = new Vue({
el: $boardApp,
diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js
index 983b28d2e67..68ea28e68d9 100644
--- a/app/assets/javascripts/boards/mixins/sortable_default_options.js
+++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js
@@ -1,7 +1,7 @@
/* global DocumentTouch */
import $ from 'jquery';
-import sortableConfig from '../../sortable/sortable_config';
+import sortableConfig from 'ee_else_ce/sortable/sortable_config';
export function sortableStart() {
$('.has-tooltip')
@@ -20,7 +20,7 @@ export function getBoardSortableDefaultOptions(obj) {
'ontouchstart' in window || (window.DocumentTouch && document instanceof DocumentTouch);
const defaultSortOptions = Object.assign({}, sortableConfig, {
- filter: '.board-delete, .btn',
+ filter: '.no-drag',
delay: touchEnabled ? 100 : 0,
scrollSensitivity: touchEnabled ? 60 : 100,
scrollSpeed: 20,
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index a9d88f19146..cd553d0c4af 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -26,6 +26,12 @@ const TYPES = {
isExpandable: false,
isBlank: true,
},
+ default: {
+ // includes label, assignee, and milestone lists
+ isPreset: false,
+ isExpandable: true,
+ isBlank: false,
+ },
};
class List {
@@ -249,7 +255,7 @@ class List {
}
getTypeInfo(type) {
- return TYPES[type] || {};
+ return TYPES[type] || TYPES.default;
}
onNewIssueResponse(issue, data) {
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 4b3b44574a8..4ba4cde6bae 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -12,6 +12,9 @@ import eventHub from '../eventhub';
const boardsStore = {
disabled: false,
+ timeTracking: {
+ limitToHours: false,
+ },
scopedLabels: {
helpLink: '',
enabled: false,
@@ -222,6 +225,10 @@ const boardsStore = {
setIssueDetail(issueDetail) {
this.detail.issue = issueDetail;
},
+
+ setTimeTrackingLimitToHours(limitToHours) {
+ this.timeTracking.limitToHours = parseBoolean(limitToHours);
+ },
};
BoardsStoreEE.initEESpecific(boardsStore);
diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js
index 670e8e9eb60..96bc6a5f8e8 100644
--- a/app/assets/javascripts/branches/divergence_graph.js
+++ b/app/assets/javascripts/branches/divergence_graph.js
@@ -1,23 +1,49 @@
import Vue from 'vue';
+import { __ } from '../locale';
+import createFlash from '../flash';
+import axios from '../lib/utils/axios_utils';
import DivergenceGraph from './components/divergence_graph.vue';
-export default () => {
- document.querySelectorAll('.js-branch-divergence-graph').forEach(el => {
- const { distance, aheadCount, behindCount, defaultBranch, maxCommits } = el.dataset;
-
- return new Vue({
- el,
- render(h) {
- return h(DivergenceGraph, {
- props: {
- defaultBranch,
- distance: distance ? parseInt(distance, 10) : null,
- aheadCount: parseInt(aheadCount, 10),
- behindCount: parseInt(behindCount, 10),
- maxCommits: parseInt(maxCommits, 10),
- },
- });
- },
- });
+export function createGraphVueApp(el, data, maxCommits) {
+ return new Vue({
+ el,
+ render(h) {
+ return h(DivergenceGraph, {
+ props: {
+ defaultBranch: 'master',
+ distance: data.distance ? parseInt(data.distance, 10) : null,
+ aheadCount: parseInt(data.ahead, 10),
+ behindCount: parseInt(data.behind, 10),
+ maxCommits,
+ },
+ });
+ },
});
+}
+
+export default endpoint => {
+ const names = [...document.querySelectorAll('.js-branch-item')].map(
+ ({ dataset }) => dataset.name,
+ );
+ return axios
+ .get(endpoint, {
+ params: { names },
+ })
+ .then(({ data }) => {
+ const maxCommits = Object.entries(data).reduce((acc, [, val]) => {
+ const max = Math.max(...Object.values(val));
+ return max > acc ? max : acc;
+ }, 100);
+
+ Object.entries(data).forEach(([branchName, val]) => {
+ const el = document.querySelector(`.js-branch-${branchName} .js-branch-divergence-graph`);
+
+ if (!el) return;
+
+ createGraphVueApp(el, val, maxCommits);
+ });
+ })
+ .catch(() =>
+ createFlash(__('Error fetching diverging counts for branches. Please try again.')),
+ );
};
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index 4771090aa7e..cd2121db3b2 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -207,7 +207,7 @@ export default {
return __('Updating');
}
- return __('Updated');
+ return this.updateSuccessful ? __('Updated to') : __('Updated');
},
updateFailureDescription() {
return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
@@ -331,8 +331,6 @@ export default {
class="form-text text-muted label p-0 js-cluster-application-update-details"
>
{{ versionLabel }}
- <span v-if="updateSuccessful">to</span>
-
<gl-link
v-if="updateSuccessful"
:href="chartRepo"
diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
index 480228619a5..e26ef135bc5 100644
--- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue
+++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue
@@ -2,7 +2,7 @@
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import { GlLoadingIcon } from '@gitlab/ui';
-import { s__ } from '~/locale';
+import { __, s__ } from '~/locale';
import { APPLICATION_STATUS } from '~/clusters/constants';
@@ -32,7 +32,7 @@ export default {
return [UPDATING].includes(this.knative.status);
},
saveButtonLabel() {
- return this.saving ? this.__('Saving') : this.__('Save changes');
+ return this.saving ? __('Saving') : __('Save changes');
},
knativeInstalled() {
return this.knative.installed;
@@ -122,9 +122,9 @@ export default {
`ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
</p>
<p
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
index ef4bcbe14dd..8465312d84d 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_button.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
@@ -1,6 +1,7 @@
<script>
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import { APPLICATION_STATUS } from '~/clusters/constants';
+import { __ } from '~/locale';
const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
@@ -22,7 +23,7 @@ export default {
return this.status === UNINSTALLING;
},
label() {
- return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
+ return this.loading ? __('Uninstalling') : __('Uninstall');
},
},
};
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
index 65827f1cb6a..920439ebb23 100644
--- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -14,7 +14,9 @@ const CUSTOM_APP_WARNING_TEXT = {
[PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
[RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
[KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'),
- [JUPYTER]: '',
+ [JUPYTER]: s__(
+ 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.',
+ ),
};
export default {
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
index 17ea4d77795..6e632519d8a 100644
--- a/app/assets/javascripts/clusters/services/application_state_machine.js
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -80,6 +80,9 @@ const applicationStateMachine = {
installFailed: false,
},
},
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
// This is possible in artificial environments for E2E testing
[INSTALLED]: {
target: INSTALLED,
@@ -108,6 +111,9 @@ const applicationStateMachine = {
updateSuccessful: false,
},
},
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
[UNINSTALL_EVENT]: {
target: UNINSTALLING,
effects: {
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index d0cc4897aeb..a4394ab7e92 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -12,6 +12,7 @@ import 'core-js/es/promise/finally';
import 'core-js/es/string/code-point-at';
import 'core-js/es/string/from-code-point';
import 'core-js/es/string/includes';
+import 'core-js/es/string/starts-with';
import 'core-js/es/symbol';
import 'core-js/es/map';
import 'core-js/es/weak-map';
diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
index f66e07ba31a..7817b41514d 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -32,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({
buttonText: function() {
if (this.isDiscussionResolved) {
if (this.textareaIsEmpty) {
- return __('Unresolve discussion');
+ return __('Unresolve thread');
} else {
- return __('Comment & unresolve discussion');
+ return __('Comment & unresolve thread');
}
} else {
if (this.textareaIsEmpty) {
- return __('Resolve discussion');
+ return __('Resolve thread');
} else {
- return __('Comment & resolve discussion');
+ return __('Comment & resolve thread');
}
}
},
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index aaa9f8b759a..58d5b658b17 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -49,6 +49,8 @@ export default {
return this.author.id ? this.author.id : '';
},
authorUrl() {
+ // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings
+ // name: 'mailto:' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
return this.author.web_url || `mailto:${this.commit.author_email}`;
},
authorAvatar() {
@@ -80,7 +82,7 @@ export default {
v-html="commit.title_html"
></a>
- <span class="commit-row-message d-block d-sm-none"> &middot; {{ commit.short_id }} </span>
+ <span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
<button
v-if="commit.description_html"
diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
index 80aec84f574..1dcdb65d5c7 100644
--- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
@@ -1,6 +1,6 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
-import { n__, __ } from '~/locale';
+import { n__, __, sprintf } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
@@ -54,11 +54,7 @@ export default {
},
methods: {
commitsText(version) {
- return n__(
- `${version.commits_count} commit,`,
- `${version.commits_count} commits,`,
- version.commits_count,
- );
+ return n__(`%d commit,`, `%d commits,`, version.commits_count);
},
href(version) {
if (this.isBase(version)) {
@@ -76,7 +72,7 @@ export default {
if (this.targetBranch && (this.isBase(version) || !version)) {
return this.targetBranch.branchName;
}
- return `version ${version.version_index}`;
+ return sprintf(__(`version %{versionIndex}`), { versionIndex: version.version_index });
},
isActive(version) {
if (!version) {
@@ -125,9 +121,9 @@ export default {
<div>
<strong>
{{ versionName(version) }}
- <template v-if="isBase(version)">
- (base)
- </template>
+ <template v-if="isBase(version)">{{
+ s__('DiffsCompareBaseBranch|(base)')
+ }}</template>
</strong>
</div>
<div>
diff --git a/app/assets/javascripts/diffs/components/diff_discussion_reply.vue b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
new file mode 100644
index 00000000000..2aa5e9b3339
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_discussion_reply.vue
@@ -0,0 +1,55 @@
+<script>
+import { mapGetters } from 'vuex';
+import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
+import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ name: 'DiffDiscussionReply',
+ components: {
+ NoteSignedOutWidget,
+ ReplyPlaceholder,
+ UserAvatarLink,
+ },
+ props: {
+ hasForm: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ renderReplyPlaceholder: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters({
+ currentUser: 'getUserData',
+ userCanReply: 'userCanReply',
+ }),
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-reply-holder d-flex clearfix">
+ <template v-if="userCanReply">
+ <slot v-if="hasForm" name="form"></slot>
+ <template v-else-if="renderReplyPlaceholder">
+ <user-avatar-link
+ :link-href="currentUser.path"
+ :img-src="currentUser.avatar_url"
+ :img-alt="currentUser.name"
+ :img-size="40"
+ class="d-none d-sm-block"
+ />
+ <reply-placeholder
+ class="qa-discussion-reply"
+ :button-text="__('Start a new discussion...')"
+ @onClick="$emit('showNewDiscussionForm')"
+ />
+ </template>
+ </template>
+ <note-signed-out-widget v-else />
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index 4c73eea4049..b0460bacff2 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -80,7 +80,6 @@ export default {
v-show="isExpanded(discussion)"
:discussion="discussion"
:render-diff-file="false"
- :always-expanded="true"
:discussions-by-diff-order="true"
:line="line"
:help-page-path="helpPagePath"
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index f5876a73eff..63350fafefa 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -151,21 +151,22 @@ export default {
<div v-if="forkMessageVisible" class="js-file-fork-suggestion-section file-fork-suggestion">
<span class="file-fork-suggestion-note">
- You're not allowed to <span class="js-file-fork-suggestion-section-action">edit</span> files
- in this project directly. Please fork this project, make your changes there, and submit a
- merge request.
+ {{ sprintf(__("You're not allowed to %{tag_start}edit%{tag_end} files in this project
+ directly. Please fork this project, make your changes there, and submit a merge request."),
+ { tag_start: '<span class="js-file-fork-suggestion-section-action">', tag_end: '</span>' })
+ }}
</span>
<a
:href="file.fork_path"
class="js-fork-suggestion-button btn btn-grouped btn-inverted btn-success"
- >Fork</a
+ >{{ __('Fork') }}</a
>
<button
class="js-cancel-fork-suggestion-button btn btn-grouped"
type="button"
@click="hideForkMessage"
>
- Cancel
+ {{ __('Cancel') }}
</button>
</div>
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index eb9f1465945..4b226e30699 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -151,7 +151,11 @@ export default {
stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
- ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']),
+ ...mapActions('diffs', [
+ 'toggleFileDiscussions',
+ 'toggleFileDiscussionWrappers',
+ 'toggleFullDiff',
+ ]),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
@@ -165,7 +169,7 @@ export default {
this.$emit('showForkMessage');
},
handleToggleDiscussions() {
- this.toggleFileDiscussions(this.diffFile);
+ this.toggleFileDiscussionWrappers(this.diffFile);
},
handleFileNameClick(e) {
const isLinkToOtherPage =
diff --git a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
index 7cf3d90d468..af5550aec3b 100644
--- a/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
+++ b/app/assets/javascripts/diffs/components/diff_gutter_avatars.vue
@@ -1,5 +1,4 @@
<script>
-import { mapActions } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize, truncate } from '~/lib/utils/text_utility';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
@@ -19,11 +18,13 @@ export default {
type: Array,
required: true,
},
+ discussionsExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- discussionsExpanded() {
- return this.discussions.every(discussion => discussion.expanded);
- },
allDiscussions() {
return this.discussions.reduce((acc, note) => acc.concat(note.notes), []);
},
@@ -45,26 +46,14 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
getTooltipText(noteData) {
let { note } = noteData;
-
if (note.length > LENGTH_OF_AVATAR_TOOLTIP) {
note = truncate(note, LENGTH_OF_AVATAR_TOOLTIP);
}
return `${noteData.author.name}: ${note}`;
},
- toggleDiscussions() {
- const forceExpanded = this.discussions.some(discussion => !discussion.expanded);
-
- this.discussions.forEach(discussion => {
- this.toggleDiscussion({
- discussionId: discussion.id,
- forceExpanded,
- });
- });
- },
},
};
</script>
@@ -74,9 +63,9 @@ export default {
<button
v-if="discussionsExpanded"
type="button"
- aria-label="Show comments"
+ :aria-label="__('Show comments')"
class="diff-notes-collapse js-diff-comment-avatar js-diff-comment-button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>
<icon :size="12" name="collapse" />
</button>
@@ -87,7 +76,7 @@ export default {
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
class="diff-comment-avatar js-diff-comment-avatar"
- @click.native="toggleDiscussions"
+ @click.native="$emit('toggleLineDiscussions')"
/>
<span
v-if="moreText"
@@ -97,7 +86,7 @@ export default {
data-container="body"
data-placement="top"
role="button"
- @click="toggleDiscussions"
+ @click="$emit('toggleLineDiscussions')"
>+{{ moreCount }}</span
>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index 1281f9b17ef..351110f0a87 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -105,7 +105,13 @@ export default {
},
},
methods: {
- ...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
+ ...mapActions('diffs', [
+ 'loadMoreLines',
+ 'showCommentForm',
+ 'setHighlightedRow',
+ 'toggleLineDiscussions',
+ 'toggleLineDiscussionWrappers',
+ ]),
handleCommentButton() {
this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.fileHash });
},
@@ -184,7 +190,14 @@ export default {
@click="setHighlightedRow(lineCode)"
>
</a>
- <diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
+ <diff-gutter-avatars
+ v-if="shouldShowAvatarsOnGutter"
+ :discussions="line.discussions"
+ :discussions-expanded="line.discussionsExpanded"
+ @toggleLineDiscussions="
+ toggleLineDiscussions({ lineCode, fileHash, expanded: !line.discussionsExpanded })
+ "
+ />
</template>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index 1faa0493e79..ca3285e9afd 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -32,10 +35,12 @@ export default {
if (!this.line.discussions || !this.line.discussions.length) {
return false;
}
-
- return this.line.discussions.every(discussion => discussion.expanded);
+ return this.line.discussionsExpanded;
},
},
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ },
};
</script>
@@ -49,13 +54,22 @@ export default {
:discussions="line.discussions"
:help-page-path="helpPagePath"
/>
- <diff-line-note-form
- v-if="line.hasForm"
- :diff-file-hash="diffFileHash"
- :line="line"
- :note-target-line="line"
- :help-page-path="helpPagePath"
- />
+ <diff-discussion-reply
+ :has-form="line.hasForm"
+ :render-reply-placeholder="Boolean(line.discussions.length)"
+ @showNewDiscussionForm="
+ showCommentForm({ lineCode: line.line_code, fileHash: diffFileHash })
+ "
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line"
+ :note-target-line="line"
+ :help-page-path="helpPagePath"
+ />
+ </template>
+ </diff-discussion-reply>
</div>
</td>
</tr>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index d2e54edca85..c00b0e010ff 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -1,11 +1,14 @@
<script>
-import diffDiscussions from './diff_discussions.vue';
-import diffLineNoteForm from './diff_line_note_form.vue';
+import { mapActions } from 'vuex';
+import DiffDiscussions from './diff_discussions.vue';
+import DiffLineNoteForm from './diff_line_note_form.vue';
+import DiffDiscussionReply from './diff_discussion_reply.vue';
export default {
components: {
- diffDiscussions,
- diffLineNoteForm,
+ DiffDiscussions,
+ DiffLineNoteForm,
+ DiffDiscussionReply,
},
props: {
line: {
@@ -29,24 +32,30 @@ export default {
computed: {
hasExpandedDiscussionOnLeft() {
return this.line.left && this.line.left.discussions.length
- ? this.line.left.discussions.every(discussion => discussion.expanded)
+ ? this.line.left.discussionsExpanded
: false;
},
hasExpandedDiscussionOnRight() {
return this.line.right && this.line.right.discussions.length
- ? this.line.right.discussions.every(discussion => discussion.expanded)
+ ? this.line.right.discussionsExpanded
: false;
},
hasAnyExpandedDiscussion() {
return this.hasExpandedDiscussionOnLeft || this.hasExpandedDiscussionOnRight;
},
shouldRenderDiscussionsOnLeft() {
- return this.line.left && this.line.left.discussions && this.hasExpandedDiscussionOnLeft;
+ return (
+ this.line.left &&
+ this.line.left.discussions &&
+ this.line.left.discussions.length &&
+ this.hasExpandedDiscussionOnLeft
+ );
},
shouldRenderDiscussionsOnRight() {
return (
this.line.right &&
this.line.right.discussions &&
+ this.line.right.discussions.length &&
this.hasExpandedDiscussionOnRight &&
this.line.right.type
);
@@ -81,6 +90,22 @@ export default {
return hasCommentFormOnLeft || hasCommentFormOnRight;
},
+ shouldRenderReplyPlaceholderOnLeft() {
+ return Boolean(
+ this.line.left && this.line.left.discussions && this.line.left.discussions.length,
+ );
+ },
+ shouldRenderReplyPlaceholderOnRight() {
+ return Boolean(
+ this.line.right && this.line.right.discussions && this.line.right.discussions.length,
+ );
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['showCommentForm']),
+ showNewDiscussionForm() {
+ this.showCommentForm({ lineCode: this.line.line_code, fileHash: this.diffFileHash });
+ },
},
};
</script>
@@ -90,37 +115,49 @@ export default {
<td class="notes-content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
- v-if="line.left.discussions.length"
:discussions="line.left.discussions"
:line="line.left"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showLeftSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.left"
- :note-target-line="line.left"
- :help-page-path="helpPagePath"
- line-position="left"
- />
+ <diff-discussion-reply
+ :has-form="showLeftSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnLeft"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.left"
+ :note-target-line="line.left"
+ :help-page-path="helpPagePath"
+ line-position="left"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
<td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
- v-if="line.right.discussions.length"
:discussions="line.right.discussions"
:line="line.right"
:help-page-path="helpPagePath"
/>
</div>
- <diff-line-note-form
- v-if="showRightSideCommentForm"
- :diff-file-hash="diffFileHash"
- :line="line.right"
- :note-target-line="line.right"
- line-position="right"
- />
+ <diff-discussion-reply
+ :has-form="showRightSideCommentForm"
+ :render-reply-placeholder="shouldRenderReplyPlaceholderOnRight"
+ @showNewDiscussionForm="showNewDiscussionForm"
+ >
+ <template #form>
+ <diff-line-note-form
+ :diff-file-hash="diffFileHash"
+ :line="line.right"
+ :note-target-line="line.right"
+ line-position="right"
+ />
+ </template>
+ </diff-discussion-reply>
</td>
</tr>
</template>
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 88d7b4bba63..32e0d8f42ee 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -12,6 +12,7 @@ import {
getNoteFormData,
convertExpandLines,
idleCallback,
+ allDiscussionWrappersExpanded,
} from './utils';
import * as types from './mutation_types';
import {
@@ -79,6 +80,7 @@ export const assignDiscussionsToDiff = (
discussions = rootState.notes.discussions,
) => {
const diffPositionByLineCode = getDiffPositionByLineCode(state.diffFiles);
+ const hash = getLocationHash();
discussions
.filter(discussion => discussion.diff_discussion)
@@ -86,6 +88,7 @@ export const assignDiscussionsToDiff = (
commit(types.SET_LINE_DISCUSSIONS_FOR_FILE, {
discussion,
diffPositionByLineCode,
+ hash,
});
});
@@ -99,6 +102,10 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const toggleLineDiscussions = ({ commit }, options) => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, options);
+};
+
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
@@ -257,6 +264,31 @@ export const toggleFileDiscussions = ({ getters, dispatch }, diff) => {
});
};
+export const toggleFileDiscussionWrappers = ({ commit }, diff) => {
+ const discussionWrappersExpanded = allDiscussionWrappersExpanded(diff);
+ let linesWithDiscussions;
+ if (diff.highlighted_diff_lines) {
+ linesWithDiscussions = diff.highlighted_diff_lines.filter(line => line.discussions.length);
+ }
+ if (diff.parallel_diff_lines) {
+ linesWithDiscussions = diff.parallel_diff_lines.filter(
+ line =>
+ (line.left && line.left.discussions.length) ||
+ (line.right && line.right.discussions.length),
+ );
+ }
+
+ if (linesWithDiscussions.length) {
+ linesWithDiscussions.forEach(line => {
+ commit(types.TOGGLE_LINE_DISCUSSIONS, {
+ fileHash: diff.file_hash,
+ lineCode: line.line_code,
+ expanded: !discussionWrappersExpanded,
+ });
+ });
+ }
+};
+
export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
const postData = getNoteFormData({
commit: state.commit,
@@ -267,7 +299,7 @@ export const saveDiffDiscussion = ({ state, dispatch }, { note, formData }) => {
return dispatch('saveNote', postData, { root: true })
.then(result => dispatch('updateDiscussion', result.discussion, { root: true }))
.then(discussion => dispatch('assignDiscussionsToDiff', [discussion]))
- .then(() => dispatch('updateResolvableDiscussonsCounts', null, { root: true }))
+ .then(() => dispatch('updateResolvableDiscussionsCounts', null, { root: true }))
.then(() => dispatch('closeDiffFileCommentForm', formData.diffFile.file_hash))
.catch(() => createFlash(s__('MergeRequests|Saving the comment failed')));
};
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 8d6111da500..9db56331faa 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -35,3 +35,5 @@ export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINE
export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
export const SET_SHOW_SUGGEST_POPOVER = 'SET_SHOW_SUGGEST_POPOVER';
+
+export const TOGGLE_LINE_DISCUSSIONS = 'TOGGLE_LINE_DISCUSSIONS';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 00181a63c43..a66f205bbbd 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -6,6 +6,7 @@ import {
addContextLines,
prepareDiffData,
isDiscussionApplicableToLine,
+ updateLineInFile,
} from './utils';
import * as types from './mutation_types';
@@ -109,7 +110,7 @@ export default {
}));
},
- [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode }) {
+ [types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { discussion, diffPositionByLineCode, hash }) {
const { latestDiff } = state;
const discussionLineCode = discussion.line_code;
@@ -130,13 +131,27 @@ export default {
: [],
});
+ const setDiscussionsExpanded = line => {
+ const isLineNoteTargeted = line.discussions.some(
+ disc => disc.notes && disc.notes.find(note => hash === `note_${note.id}`),
+ );
+
+ return {
+ ...line,
+ discussionsExpanded:
+ line.discussions && line.discussions.length
+ ? line.discussions.some(disc => !disc.resolved) || isLineNoteTargeted
+ : false,
+ };
+ };
+
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
- lineCheck(line) ? mapDiscussions(line) : line,
+ setDiscussionsExpanded(lineCheck(line) ? mapDiscussions(line) : line),
);
}
@@ -148,8 +163,10 @@ export default {
if (left || right) {
return {
...line,
- left: line.left ? mapDiscussions(line.left) : null,
- right: line.right ? mapDiscussions(line.right, () => !left) : null,
+ left: line.left ? setDiscussionsExpanded(mapDiscussions(line.left)) : null,
+ right: line.right
+ ? setDiscussionsExpanded(mapDiscussions(line.right, () => !left))
+ : null,
};
}
@@ -173,32 +190,11 @@ export default {
[types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
- if (selectedFile.parallel_diff_lines) {
- const targetLine = selectedFile.parallel_diff_lines.find(
- line =>
- (line.left && line.left.line_code === lineCode) ||
- (line.right && line.right.line_code === lineCode),
- );
- if (targetLine) {
- const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
-
- Object.assign(targetLine[side], {
- discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
-
- if (selectedFile.highlighted_diff_lines) {
- const targetInlineLine = selectedFile.highlighted_diff_lines.find(
- line => line.line_code === lineCode,
- );
-
- if (targetInlineLine) {
- Object.assign(targetInlineLine, {
- discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
- });
- }
- }
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, {
+ discussions: line.discussions.filter(discussion => discussion.notes.length),
+ }),
+ );
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
@@ -207,6 +203,15 @@ export default {
}
}
},
+
+ [types.TOGGLE_LINE_DISCUSSIONS](state, { fileHash, lineCode, expanded }) {
+ const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
+
+ updateLineInFile(selectedFile, lineCode, line =>
+ Object.assign(line, { discussionsExpanded: expanded }),
+ );
+ },
+
[types.TOGGLE_FOLDER_OPEN](state, path) {
state.treeEntries[path].opened = !state.treeEntries[path].opened;
},
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 71956255eef..1c3ed84001c 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -454,3 +454,48 @@ export const convertExpandLines = ({
};
export const idleCallback = cb => requestIdleCallback(cb);
+
+export const updateLineInFile = (selectedFile, lineCode, updateFn) => {
+ if (selectedFile.parallel_diff_lines) {
+ const targetLine = selectedFile.parallel_diff_lines.find(
+ line =>
+ (line.left && line.left.line_code === lineCode) ||
+ (line.right && line.right.line_code === lineCode),
+ );
+ if (targetLine) {
+ const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
+
+ updateFn(targetLine[side]);
+ }
+ }
+ if (selectedFile.highlighted_diff_lines) {
+ const targetInlineLine = selectedFile.highlighted_diff_lines.find(
+ line => line.line_code === lineCode,
+ );
+
+ if (targetInlineLine) {
+ updateFn(targetInlineLine);
+ }
+ }
+};
+
+export const allDiscussionWrappersExpanded = diff => {
+ const discussionsExpandedArray = [];
+ if (diff.parallel_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.left && line.left.discussions.length) {
+ discussionsExpandedArray.push(line.left.discussionsExpanded);
+ }
+ if (line.right && line.right.discussions.length) {
+ discussionsExpandedArray.push(line.right.discussionsExpanded);
+ }
+ });
+ } else if (diff.highlighted_diff_lines) {
+ diff.parallel_diff_lines.forEach(line => {
+ if (line.discussions.length) {
+ discussionsExpandedArray.push(line.discussionsExpanded);
+ }
+ });
+ }
+ return discussionsExpandedArray.every(el => el);
+};
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 208bd19f6b0..21244c14977 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -1,5 +1,5 @@
<script>
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import { formatTime } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
import eventHub from '../event_hub';
@@ -28,7 +28,7 @@ export default {
},
computed: {
title() {
- return 'Deploy to...';
+ return __('Deploy to...');
},
},
methods: {
@@ -80,7 +80,8 @@ export default {
data-toggle="dropdown"
>
<span>
- <icon name="play" /> <icon name="chevron-down" />
+ <icon name="play" />
+ <icon name="chevron-down" />
<gl-loading-icon v-if="isLoading" />
</span>
</button>
@@ -94,9 +95,10 @@ export default {
class="js-manual-action-link no-btn btn d-flex align-items-center"
@click="onClickAction(action)"
>
- <span class="flex-fill"> {{ action.name }} </span>
+ <span class="flex-fill">{{ action.name }}</span>
<span v-if="action.scheduledAt" class="text-secondary">
- <icon name="clock" /> {{ remainingTime(action) }}
+ <icon name="clock" />
+ {{ remainingTime(action) }}
</span>
</button>
</li>
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index f0e80cba753..813045cb5e4 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import Timeago from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
@@ -14,7 +15,6 @@ import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { CLUSTER_TYPE } from '~/clusters/constants';
/**
* Environment Item Component
@@ -80,15 +80,6 @@ export default {
},
/**
- * Hide group cluster features which are not currently implemented.
- *
- * @returns {Boolean}
- */
- disableGroupClusterFeatures() {
- return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP;
- },
-
- /**
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
@@ -172,7 +163,9 @@ export default {
this.model.last_deployment.user &&
this.model.last_deployment.user.username
) {
- return `${this.model.last_deployment.user.username}'s avatar'`;
+ return sprintf(__("%{username}'s avatar"), {
+ username: this.model.last_deployment.user.username,
+ });
}
return '';
},
@@ -293,6 +286,9 @@ export default {
* @returns {Boolean|Undefined}
*/
isLastDeployment() {
+ // TODO: when the vue i18n rules are merged need to disable @gitlab/i18n/no-non-i18n-strings
+ // name: 'last?' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
+ // Vue i18n ESLint rules issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/63560
return this.model && this.model.last_deployment && this.model.last_deployment['last?'];
},
@@ -575,7 +571,6 @@ export default {
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
- :disabled="disableGroupClusterFeatures"
/>
<rollback-component
diff --git a/app/assets/javascripts/environments/components/environment_monitoring.vue b/app/assets/javascripts/environments/components/environment_monitoring.vue
index ae4f07a71cd..886490847ea 100644
--- a/app/assets/javascripts/environments/components/environment_monitoring.vue
+++ b/app/assets/javascripts/environments/components/environment_monitoring.vue
@@ -1,4 +1,5 @@
<script>
+import { __ } from '~/locale';
/**
* Renders the Monitoring (Metrics) link in environments table.
*/
@@ -21,7 +22,7 @@ export default {
},
computed: {
title() {
- return 'Monitoring';
+ return __('Monitoring');
},
},
};
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 13195d32cc4..37f94f9f5ab 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -5,6 +5,7 @@
*/
import { GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -27,7 +28,7 @@ export default {
},
computed: {
title() {
- return 'Terminal';
+ return __('Terminal');
},
},
};
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
index 060d8e25227..ef1d1e49320 100644
--- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -49,9 +49,9 @@ export default {
</p>
</div>
<div class="form-group" :class="{ 'gl-show-field-errors': connectError }">
- <label class="label-bold" for="error-tracking-token">{{
- s__('ErrorTracking|Auth Token')
- }}</label>
+ <label class="label-bold" for="error-tracking-token">
+ {{ s__('ErrorTracking|Auth Token') }}
+ </label>
<div class="row">
<div class="col-8 col-md-9 gl-pr-0">
<gl-form-input
@@ -65,9 +65,8 @@ export default {
<gl-button
class="js-error-tracking-connect prepend-left-5"
@click="$emit('handle-connect')"
+ >{{ __('Connect') }}</gl-button
>
- {{ __('Connect') }}
- </gl-button>
<icon
v-show="connectSuccessful"
class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
index 891086b4142..f280f3cd26c 100644
--- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -10,7 +10,7 @@ import { mergeUrlParams } from '../lib/utils/url_utility';
export default class AvailableDropdownMappings {
constructor(
container,
- baseEndpoint,
+ runnerTagsEndpoint,
labelsEndpoint,
milestonesEndpoint,
groupsOnly,
@@ -18,7 +18,7 @@ export default class AvailableDropdownMappings {
includeDescendantGroups,
) {
this.container = container;
- this.baseEndpoint = baseEndpoint;
+ this.runnerTagsEndpoint = runnerTagsEndpoint;
this.labelsEndpoint = labelsEndpoint;
this.milestonesEndpoint = milestonesEndpoint;
this.groupsOnly = groupsOnly;
@@ -149,7 +149,7 @@ export default class AvailableDropdownMappings {
}
getRunnerTagsEndpoint() {
- return `${this.baseEndpoint}/admin/runners/tag_list.json`;
+ return `${this.runnerTagsEndpoint}.json`;
}
getMergeRequestTargetBranchesEndpoint() {
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 19bc3313373..4757c4b1e43 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -59,7 +59,7 @@ export default {
<template>
<div>
<div v-if="!isLocalStorageAvailable" class="dropdown-info-note">
- This feature requires local storage to be enabled
+ {{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
<li v-for="(item, index) in processedItems" :key="`processed-items-${index}`">
@@ -90,10 +90,10 @@ export default {
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
>
- Clear recent searches
+ {{ __('Clear recent searches') }}
</button>
</li>
</ul>
- <div v-else class="dropdown-info-note">You don't have any recent searches</div>
+ <div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div>
</div>
</template>
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
index 1cbfd7f9bb9..835d3bf8a53 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -8,7 +8,7 @@ import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
constructor({
- baseEndpoint = '',
+ runnerTagsEndpoint = '',
labelsEndpoint = '',
milestonesEndpoint = '',
tokenizer,
@@ -19,7 +19,7 @@ export default class FilteredSearchDropdownManager {
filteredSearchTokenKeys,
}) {
this.container = FilteredSearchContainer.container;
- this.baseEndpoint = baseEndpoint.replace(/\/$/, '');
+ this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, '');
this.labelsEndpoint = labelsEndpoint.replace(/\/$/, '');
this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, '');
this.tokenizer = tokenizer;
@@ -51,7 +51,7 @@ export default class FilteredSearchDropdownManager {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
const availableMappings = new AvailableDropdownMappings(
this.container,
- this.baseEndpoint,
+ this.runnerTagsEndpoint,
this.labelsEndpoint,
this.milestonesEndpoint,
this.groupsOnly,
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 450e0725f2e..d1f52b91d9e 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -85,7 +85,8 @@ export default class FilteredSearchManager {
if (this.filteredSearchInput) {
this.tokenizer = FilteredSearchTokenizer;
this.dropdownManager = new FilteredSearchDropdownManager({
- baseEndpoint: this.filteredSearchInput.getAttribute('data-base-endpoint') || '',
+ runnerTagsEndpoint:
+ this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '',
labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '',
milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '',
tokenizer: this.tokenizer,
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 3b73dd83c9f..b308cd9c236 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -318,6 +318,7 @@ class GfmAutoComplete {
}
setupLabels($input) {
+ const instance = this;
const fetchData = this.fetchData.bind(this);
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
let command = '';
@@ -348,7 +349,6 @@ class GfmAutoComplete {
}));
},
matcher(flag, subtext) {
- const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
const subtextNodes = subtext
.split(/\n+/g)
.pop()
@@ -366,6 +366,27 @@ class GfmAutoComplete {
return null;
});
+ // If any label matches the inserted text after the last `~`, suggest those labels,
+ // even if any spaces or funky characters were typed.
+ // This allows matching labels like "Accepting merge requests".
+ const labels = instance.cachedData[flag];
+ if (labels) {
+ if (!subtext.includes(flag)) {
+ // Do not match if there is no `~` before the cursor
+ return null;
+ }
+ const lastCandidate = subtext.split(flag).pop();
+ if (labels.find(label => label.title.startsWith(lastCandidate))) {
+ return lastCandidate;
+ }
+ } else {
+ // Load all labels into the autocompleter.
+ // This needs to happen if e.g. editing a label in an existing comment, because normally
+ // label data would only be loaded only once you type `~`.
+ fetchData(this.$inputor, this.at);
+ }
+
+ const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
return match && match.length ? match[1] : null;
},
filter(query, data, searchKey) {
@@ -563,8 +584,9 @@ class GfmAutoComplete {
const accentAChar = decodeURI('%C3%80');
const accentYChar = decodeURI('%C3%BF');
+ // Holy regex, batman!
const regexp = new RegExp(
- `^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`,
+ `^(?:\\B|[^a-zA-Z0-9_\`${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-:]|[^\\x00-\\x7a])*)$`,
'gi',
);
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
index 903c838e266..460174caf4d 100644
--- a/app/assets/javascripts/group.js
+++ b/app/assets/javascripts/group.js
@@ -1,5 +1,5 @@
import $ from 'jquery';
-import { slugifyWithHyphens } from './lib/utils/text_utility';
+import { slugify } from './lib/utils/text_utility';
export default class Group {
constructor() {
@@ -14,7 +14,7 @@ export default class Group {
}
update() {
- const slug = slugifyWithHyphens(this.groupName.val());
+ const slug = slugify(this.groupName.val());
this.groupPath.val(slug);
}
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
index 4be4b02ac1e..c8fbc3cb9f1 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue
@@ -107,7 +107,8 @@ export default {
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path d-flex align-items-center">
- <file-icon :file-name="file.name" class="append-right-8" />{{ file.name }}
+ <file-icon :file-name="file.name" class="append-right-8" />
+ {{ file.name }}
</span>
<div class="ml-auto d-flex align-items-center">
<div class="d-flex align-items-center ide-commit-list-changed-icon">
diff --git a/app/assets/javascripts/ide/components/external_link.vue b/app/assets/javascripts/ide/components/external_link.vue
index 954f84cea17..d1857f0176a 100644
--- a/app/assets/javascripts/ide/components/external_link.vue
+++ b/app/assets/javascripts/ide/components/external_link.vue
@@ -27,7 +27,7 @@ export default {
target="_blank"
rel="noopener noreferrer"
>
- <span class="vertical-align-middle">Open in file view</span>
+ <span class="vertical-align-middle">{{ __('Open in file view') }}</span>
<icon :size="16" name="external-link" css-classes="vertical-align-middle space-right" />
</a>
</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue
index 95782b2c88a..1af86a94482 100644
--- a/app/assets/javascripts/ide/components/ide_tree_list.vue
+++ b/app/assets/javascripts/ide/components/ide_tree_list.vue
@@ -30,6 +30,9 @@ export default {
showLoading() {
return !this.currentTree || this.currentTree.loading;
},
+ actualTreeList() {
+ return this.currentTree.tree.filter(entry => !entry.moved);
+ },
},
mounted() {
this.updateViewer(this.viewerType);
@@ -54,9 +57,9 @@ export default {
<slot name="header"></slot>
</header>
<div class="ide-tree-body h-100">
- <template v-if="currentTree.tree.length">
+ <template v-if="actualTreeList.length">
<file-row
- v-for="file in currentTree.tree"
+ v-for="file in actualTreeList"
:key="file.key"
:file="file"
:level="0"
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index b0c4969c5e4..5fcb11a232e 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -8,6 +8,7 @@ import { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor';
import ExternalLink from './external_link.vue';
import FileTemplatesBar from './file_templates/bar.vue';
+import { __ } from '~/locale';
export default {
components: {
@@ -40,27 +41,36 @@ export default {
},
showContentViewer() {
return (
- (this.shouldHideEditor || this.file.viewMode === 'preview') &&
+ (this.shouldHideEditor || this.isPreviewViewMode) &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
+ isEditorViewMode() {
+ return this.file.viewMode === 'editor';
+ },
+ isPreviewViewMode() {
+ return this.file.viewMode === 'preview';
+ },
editTabCSS() {
return {
- active: this.file.viewMode === 'editor',
+ active: this.isEditorViewMode,
};
},
previewTabCSS() {
return {
- active: this.file.viewMode === 'preview',
+ active: this.isPreviewViewMode,
};
},
fileType() {
const info = viewerInformationForPath(this.file.path);
return (info && info.id) || '';
},
+ showEditor() {
+ return !this.shouldHideEditor && this.isEditorViewMode;
+ },
},
watch: {
file(newVal, oldVal) {
@@ -89,7 +99,7 @@ export default {
}
},
rightPanelCollapsed() {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
},
viewer() {
if (!this.file.pending) {
@@ -98,11 +108,17 @@ export default {
},
panelResizing() {
if (!this.panelResizing) {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
}
},
rightPaneIsOpen() {
- this.editor.updateDimensions();
+ this.refreshEditorDimensions();
+ },
+ showEditor(val) {
+ if (val) {
+ // We need to wait for the editor to actually be rendered.
+ this.$nextTick(() => this.refreshEditorDimensions());
+ }
},
},
beforeDestroy() {
@@ -145,7 +161,14 @@ export default {
this.createEditorInstance();
})
.catch(err => {
- flash('Error setting up editor. Please try again.', 'alert', document, null, false, true);
+ flash(
+ __('Error setting up editor. Please try again.'),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
throw err;
});
},
@@ -212,6 +235,11 @@ export default {
eol: this.model.eol,
});
},
+ refreshEditorDimensions() {
+ if (this.showEditor) {
+ this.editor.updateDimensions();
+ }
+ },
},
viewerTypes,
};
@@ -227,12 +255,8 @@ export default {
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'editor' })"
>
- <template v-if="viewer === $options.viewerTypes.edit">
- {{ __('Edit') }}
- </template>
- <template v-else>
- {{ __('Review') }}
- </template>
+ <template v-if="viewer === $options.viewerTypes.edit">{{ __('Edit') }}</template>
+ <template v-else>{{ __('Review') }}</template>
</a>
</li>
<li v-if="file.previewMode" :class="previewTabCSS">
@@ -240,16 +264,15 @@ export default {
href="javascript:void(0);"
role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'preview' })"
+ >{{ file.previewMode.previewTitle }}</a
>
- {{ file.previewMode.previewTitle }}
- </a>
</li>
</ul>
<external-link :file="file" />
</div>
<file-templates-bar v-if="showFileTemplatesBar(file.name)" />
<div
- v-show="!shouldHideEditor && file.viewMode === 'editor'"
+ v-show="showEditor"
ref="editor"
:class="{
'is-readonly': isCommitModeActive,
diff --git a/app/assets/javascripts/ide/components/repo_file_status_icon.vue b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
index a964d90b090..84a962bfc7d 100644
--- a/app/assets/javascripts/ide/components/repo_file_status_icon.vue
+++ b/app/assets/javascripts/ide/components/repo_file_status_icon.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import '~/lib/utils/datetime_utility';
@@ -18,7 +19,9 @@ export default {
},
computed: {
lockTooltip() {
- return `Locked by ${this.file.file_lock.user.name}`;
+ return sprintf(__(`Locked by %{fileLockUserName}`), {
+ fileLockUserName: this.file.file_lock.user.name,
+ });
},
},
};
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index f6aa2295844..7615cfc966e 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import { mapActions } from 'vuex';
import FileIcon from '~/vue_shared/components/file_icon.vue';
@@ -27,9 +28,9 @@ export default {
computed: {
closeLabel() {
if (this.fileHasChanged) {
- return `${this.tab.name} changed`;
+ return sprintf(__(`%{tabname} changed`), { tabname: this.tab.name });
}
- return `Close ${this.tab.name}`;
+ return sprintf(__(`Close %{tabname}`, { tabname: this.tab.name }));
},
showChangedIcon() {
if (this.tab.pending) return true;
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index 5429b834708..8c0119a1fed 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -8,6 +8,7 @@ import * as types from './mutation_types';
import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
import service from '../services';
+import router from '../ide_router';
export const redirectToUrl = (self, url) => visitUrl(url);
@@ -61,7 +62,7 @@ export const createTempEntry = (
new Promise(resolve => {
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
- if (state.entries[name]) {
+ if (state.entries[name] && !state.entries[name].deleted) {
flash(
`The name "${name.split('/').pop()}" is already taken in this directory.`,
'alert',
@@ -207,10 +208,7 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
}
commit(types.DELETE_ENTRY, path);
-
- if (entry.parentPath && state.entries[entry.parentPath].tree.length === 0) {
- dispatch('deleteEntry', entry.parentPath);
- }
+ dispatch('stageChange', path);
dispatch('triggerFilesChange');
};
@@ -238,10 +236,15 @@ export const renameEntry = (
parentPath: newParentPath,
});
});
- }
+ } else {
+ const newPath = parentPath ? `${parentPath}/${name}` : name;
+ const newEntry = state.entries[newPath];
+ commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true });
- if (!entryPath && !entry.tempFile) {
- dispatch('deleteEntry', path);
+ if (entry.opened) {
+ router.push(`/project${newEntry.url}`);
+ commit(types.TOGGLE_FILE_OPEN, entry.path);
+ }
}
dispatch('triggerFilesChange');
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index dc40a1fa6a2..7627b6e03af 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -73,7 +73,9 @@ export const getFileData = (
.getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
.then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers);
- setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
+ let title = normalizedHeaders['PAGE-TITLE'];
+ title = file.prevPath ? title.replace(file.prevPath, file.path) : title;
+ setPageTitle(decodeURI(title));
if (data) commit(types.SET_FILE_DATA, { data, file });
if (openFile) commit(types.TOGGLE_FILE_OPEN, path);
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index 01ca6a6b12f..ac34491c1ad 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -186,6 +186,8 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true });
+ commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true });
+
setTimeout(() => {
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js
index 86ab76136df..f021729c451 100644
--- a/app/assets/javascripts/ide/stores/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/mutation_types.js
@@ -60,6 +60,8 @@ export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES';
export const STAGE_CHANGE = 'STAGE_CHANGE';
export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
+export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES';
+
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index ae42b87c9a7..ea125214ebb 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -56,6 +56,11 @@ export default {
stagedFiles: [],
});
},
+ [types.CLEAR_REPLACED_FILES](state) {
+ Object.assign(state, {
+ replacedFiles: [],
+ });
+ },
[types.SET_ENTRIES](state, entries) {
Object.assign(state, {
entries,
@@ -70,6 +75,13 @@ export default {
Object.assign(state.entries, {
[key]: entry,
});
+ } else if (foundEntry.deleted) {
+ Object.assign(state.entries, {
+ [key]: {
+ ...entry,
+ replaces: true,
+ },
+ });
} else {
const tree = entry.tree.filter(
f => foundEntry.tree.find(e => e.path === f.path) === undefined,
@@ -144,6 +156,7 @@ export default {
raw: file.content,
changed: Boolean(changedFile),
staged: false,
+ replaces: false,
prevPath: '',
moved: false,
lastCommitSha: lastCommit.commit.id,
@@ -216,15 +229,16 @@ export default {
Vue.set(state.entries, newPath, {
...oldEntry,
id: newPath,
- key: `${newPath}-${oldEntry.type}-${oldEntry.id}`,
+ key: `${newPath}-${oldEntry.type}-${oldEntry.path}`,
path: newPath,
name: entryPath ? oldEntry.name : name,
tempFile: true,
prevPath: oldEntry.tempFile ? null : oldEntry.path,
url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath),
tree: [],
- parentPath,
raw: '',
+ opened: false,
+ parentPath,
});
oldEntry.moved = true;
@@ -241,10 +255,6 @@ export default {
state.changedFiles = state.changedFiles.concat(newEntry);
}
- if (state.entries[newPath].opened) {
- state.openFiles.push(state.entries[newPath]);
- }
-
if (oldEntry.tempFile) {
const filterMethod = f => f.path !== oldEntry.path;
diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js
index 6ca246c1d63..c88244492e0 100644
--- a/app/assets/javascripts/ide/stores/mutations/file.js
+++ b/app/assets/javascripts/ide/stores/mutations/file.js
@@ -170,12 +170,16 @@ export default {
entries: Object.assign(state.entries, {
[path]: Object.assign(state.entries[path], {
staged: true,
- changed: false,
}),
}),
});
if (stagedFile) {
+ Object.assign(state, {
+ replacedFiles: state.replacedFiles.concat({
+ ...stagedFile,
+ }),
+ });
Object.assign(stagedFile, {
...state.entries[path],
});
diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js
index d400b9831a9..c4da482bf0a 100644
--- a/app/assets/javascripts/ide/stores/state.js
+++ b/app/assets/javascripts/ide/stores/state.js
@@ -6,6 +6,7 @@ export default () => ({
currentMergeRequestId: '',
changedFiles: [],
stagedFiles: [],
+ replacedFiles: [],
endpoints: {},
lastCommitMsg: '',
lastCommitPath: '',
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 4e7a8765abe..01f78a29cf6 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -18,6 +18,7 @@ export const dataStructure = () => ({
active: false,
changed: false,
staged: false,
+ replaces: false,
lastCommitPath: '',
lastCommitSha: '',
lastCommit: {
@@ -119,7 +120,7 @@ export const commitActionForFile = file => {
return commitActionTypes.move;
} else if (file.deleted) {
return commitActionTypes.delete;
- } else if (file.tempFile) {
+ } else if (file.tempFile && !file.replaces) {
return commitActionTypes.create;
}
@@ -147,11 +148,12 @@ export const createCommitPayload = ({
commit_message: state.commitMessage || getters.preBuiltCommitMessage,
actions: getCommitFiles(rootState.stagedFiles).map(f => ({
action: commitActionForFile(f),
- file_path: f.path,
+ file_path: f.moved ? f.movedPath : f.path,
previous_path: f.prevPath === '' ? undefined : f.prevPath,
- content: f.content || undefined,
+ content: f.prevPath ? null : f.content || undefined,
encoding: f.base64 ? 'base64' : 'text',
- last_commit_id: newBranch || f.deleted || f.prevPath ? undefined : f.lastCommitSha,
+ last_commit_id:
+ newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha,
})),
start_sha: newBranch ? rootGetters.lastCommit.short_id : undefined,
});
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index d895eca7af0..ae579fef25f 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -3,7 +3,7 @@ import { commitItemIconMap } from './constants';
export const getCommitIconMap = file => {
if (file.deleted) {
return commitItemIconMap.deleted;
- } else if (file.tempFile) {
+ } else if (file.tempFile && !file.prevPath) {
return commitItemIconMap.addition;
}
diff --git a/app/assets/javascripts/issue_show/components/edit_actions.vue b/app/assets/javascripts/issue_show/components/edit_actions.vue
index 42a3de62772..b2f9296c68b 100644
--- a/app/assets/javascripts/issue_show/components/edit_actions.vue
+++ b/app/assets/javascripts/issue_show/components/edit_actions.vue
@@ -73,7 +73,9 @@ export default {
Save changes
<i v-if="formState.updateLoading" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
</button>
- <button class="btn btn-default float-right" type="button" @click="closeForm">Cancel</button>
+ <button class="btn btn-default float-right" type="button" @click="closeForm">
+ {{ __('Cancel') }}
+ </button>
<button
v-if="shouldShowDeleteButton"
:class="{ disabled: deleteLoading }"
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index d27dd873125..447d7bf21a5 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -39,7 +39,7 @@ export default {
<template>
<div class="common-note-form">
- <label class="sr-only" for="issue-description"> Description </label>
+ <label class="sr-only" for="issue-description">{{ __('Description') }}</label>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
@@ -55,8 +55,8 @@ export default {
qa-description-textarea"
dir="auto"
data-supports-quick-actions="false"
- aria-label="Description"
- placeholder="Write a comment or drag your files here…"
+ :aria-label="__('Description')"
+ :placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
>
diff --git a/app/assets/javascripts/issue_show/components/fields/description_template.vue b/app/assets/javascripts/issue_show/components/fields/description_template.vue
index 14f0acf6540..6f955928d8e 100644
--- a/app/assets/javascripts/issue_show/components/fields/description_template.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description_template.vue
@@ -56,22 +56,31 @@ export default {
data-selected="null"
data-toggle="dropdown"
>
- <span class="dropdown-toggle-text"> Choose a template </span>
+ <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span>
<i aria-hidden="true" class="fa fa-chevron-down"> </i>
</button>
<div class="dropdown-menu dropdown-select">
<div class="dropdown-title">
Choose a template
- <button class="dropdown-title-button dropdown-menu-close" aria-label="Close" type="button">
+ <button
+ class="dropdown-title-button dropdown-menu-close"
+ :aria-label="__('Close')"
+ type="button"
+ >
<i aria-hidden="true" class="fa fa-times dropdown-menu-close-icon"> </i>
</button>
</div>
<div class="dropdown-input">
- <input type="search" class="dropdown-input-field" placeholder="Filter" autocomplete="off" />
+ <input
+ type="search"
+ class="dropdown-input-field"
+ :placeholder="__('Filter')"
+ autocomplete="off"
+ />
<i aria-hidden="true" class="fa fa-search dropdown-input-search"> </i>
<i
role="button"
- aria-label="Clear templates search input"
+ :aria-label="__('Clear templates search input')"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
>
</i>
@@ -79,8 +88,12 @@ export default {
<div class="dropdown-content"></div>
<div class="dropdown-footer">
<ul class="dropdown-footer-list">
- <li><a class="no-template"> No template </a></li>
- <li><a class="reset-template"> Reset template </a></li>
+ <li>
+ <a class="no-template">{{ __('No template') }}</a>
+ </li>
+ <li>
+ <a class="reset-template">{{ __('Reset template') }}</a>
+ </li>
</ul>
</div>
</div>
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
index ce4baf17d09..34eb0451d53 100644
--- a/app/assets/javascripts/issue_show/components/fields/title.vue
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -14,7 +14,7 @@ export default {
<template>
<fieldset>
- <label class="sr-only" for="issuable-title"> Title </label>
+ <label class="sr-only" for="issuable-title">{{ __('Title') }}</label>
<input
id="issuable-title"
ref="input"
@@ -22,8 +22,8 @@ export default {
class="form-control qa-title-input"
dir="auto"
type="text"
- placeholder="Title"
- aria-label="Title"
+ :placeholder="__('Title')"
+ :aria-label="__('Title')"
@keydown.meta.enter="updateIssuable"
@keydown.ctrl.enter="updateIssuable"
/>
diff --git a/app/assets/javascripts/issue_show/components/locked_warning.vue b/app/assets/javascripts/issue_show/components/locked_warning.vue
index 639221473b1..2f3e611e089 100644
--- a/app/assets/javascripts/issue_show/components/locked_warning.vue
+++ b/app/assets/javascripts/issue_show/components/locked_warning.vue
@@ -10,8 +10,9 @@ export default {
<template>
<div class="alert alert-danger">
- Someone edited the issue at the same time you did. Please check out
- <a :href="currentPath" target="_blank" rel="nofollow">the issue</a> and make sure your changes
- will not unintentionally remove theirs.
+ {{ sprintf(__("Someone edited the issue at the same time you did. Please check out
+ %{linkStart}%the issue%{linkEnd} and make sure your changes will not unintentionally remove
+ theirs."), { linkStart: `<a href="${currentPath}" target="_blank" rel="nofollow">` linkEnd: '</a
+ >', }) }}
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 607b2bd1c74..156735441ca 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -3,7 +3,7 @@ import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { polyfillSticky } from '~/lib/utils/sticky';
import Icon from '~/vue_shared/components/icon.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { sprintf } from '~/locale';
+import { __, sprintf } from '~/locale';
import scrollDown from '../svg/scroll_down.svg';
export default {
@@ -50,7 +50,7 @@ export default {
},
computed: {
jobLogSize() {
- return sprintf('Showing last %{size} of log -', {
+ return sprintf(__('Showing last %{size} of log -'), {
size: numberToHumanSize(this.size),
});
},
@@ -74,14 +74,12 @@ export default {
<div class="js-truncated-info truncated-info d-none d-sm-block float-left">
<template v-if="isTraceSizeVisible">
{{ jobLogSize }}
-
<gl-link
v-if="rawPath"
:href="rawPath"
class="js-raw-link text-plain text-underline prepend-left-5"
+ >{{ s__('Job|Complete Raw') }}</gl-link
>
- {{ s__('Job|Complete Raw') }}
- </gl-link>
</template>
</div>
<!-- eo truncate information -->
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 24276c06486..e9704584c9f 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -1,4 +1,5 @@
<script>
+import { __, sprintf } from '~/locale';
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import { GlLink, GlButton } from '@gitlab/ui';
@@ -63,7 +64,9 @@ export default {
let t = this.job.metadata.timeout_human_readable;
if (this.job.metadata.timeout_source !== '') {
- t += ` (from ${this.job.metadata.timeout_source})`;
+ t += sprintf(__(` (from %{timeoutSource})`), {
+ timeoutSource: this.job.metadata.timeout_source,
+ });
}
return t;
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 5857f9e22ae..c05db4a5c71 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -22,7 +22,7 @@ export default (resolvers = {}, config = {}) => {
return new ApolloClient({
link: ApolloLink.split(
- operation => operation.getContext().hasUpload,
+ operation => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
new BatchHttpLink(httpOptions),
),
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index d521c462ad8..062d21ed247 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -479,9 +479,13 @@ export const pikadayToString = date => {
* Seconds can be negative or positive, zero or non-zero. Can be configured for any day
* or week length.
*/
-export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) => {
+export const parseSeconds = (
+ seconds,
+ { daysPerWeek = 5, hoursPerDay = 8, limitToHours = false } = {},
+) => {
const DAYS_PER_WEEK = daysPerWeek;
const HOURS_PER_DAY = hoursPerDay;
+ const SECONDS_PER_MINUTE = 60;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
@@ -493,9 +497,18 @@ export const parseSeconds = (seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {})
minutes: 1,
};
- let unorderedMinutes = Math.abs(seconds / MINUTES_PER_HOUR);
+ if (limitToHours) {
+ timePeriodConstraints.weeks = 0;
+ timePeriodConstraints.days = 0;
+ }
+
+ let unorderedMinutes = Math.abs(seconds / SECONDS_PER_MINUTE);
return _.mapObject(timePeriodConstraints, minutesPerPeriod => {
+ if (minutesPerPeriod === 0) {
+ return 0;
+ }
+
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
unorderedMinutes -= periodCount * minutesPerPeriod;
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index cc1d85fd97d..d38f59b5861 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -44,11 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' :
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Replaces whitespaces with hyphens and converts to lower case
+ * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters
* @param {String} str
* @returns {String}
*/
-export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
+export const slugify = str => {
+ const slug = str
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-zA-Z0-9_.-]+/g, '-');
+
+ return slug === '-' ? '' : slug;
+};
/**
* Replaces whitespaces with underscore and converts to lower case
diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js
new file mode 100644
index 00000000000..012d1e70410
--- /dev/null
+++ b/app/assets/javascripts/manual_ordering.js
@@ -0,0 +1,58 @@
+import Sortable from 'sortablejs';
+import { s__ } from '~/locale';
+import createFlash from '~/flash';
+import {
+ getBoardSortableDefaultOptions,
+ sortableStart,
+} from '~/boards/mixins/sortable_default_options';
+import axios from '~/lib/utils/axios_utils';
+
+const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
+ axios
+ .put(`${url}/reorder`, {
+ move_before_id,
+ move_after_id,
+ group_full_path: issueList.dataset.groupFullPath,
+ })
+ .catch(() => {
+ createFlash(s__("ManualOrdering|Couldn't save the order of the issues"));
+ });
+
+const initManualOrdering = () => {
+ const issueList = document.querySelector('.manual-ordering');
+
+ if (!issueList || !(gon.features && gon.features.manualSorting) || !(gon.current_user_id > 0)) {
+ return;
+ }
+
+ Sortable.create(
+ issueList,
+ getBoardSortableDefaultOptions({
+ scroll: true,
+ dataIdAttr: 'data-id',
+ fallbackOnBody: false,
+ group: {
+ name: 'issues',
+ },
+ draggable: 'li.issue',
+ onStart: () => {
+ sortableStart();
+ },
+ onUpdate: event => {
+ const el = event.item;
+
+ const url = el.getAttribute('url');
+
+ const prev = el.previousElementSibling;
+ const next = el.nextElementSibling;
+
+ const beforeId = prev && parseInt(prev.dataset.id, 10);
+ const afterId = next && parseInt(next.dataset.id, 10);
+
+ updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId });
+ },
+ }),
+ );
+};
+
+export default initManualOrdering;
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index 9de4e96e4da..81773bd140e 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,4 +1,6 @@
<script>
+import { __ } from '~/locale';
+import { GlLink } from '@gitlab/ui';
import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils';
@@ -13,6 +15,7 @@ export default {
components: {
GlAreaChart,
GlChartSeriesLabel,
+ GlLink,
Icon,
},
inheritAttrs: false,
@@ -43,6 +46,10 @@ export default {
required: false,
default: () => [],
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
thresholds: {
type: Array,
required: false,
@@ -54,6 +61,7 @@ export default {
tooltip: {
title: '',
content: [],
+ commitUrl: '',
isDeployment: false,
sha: '',
},
@@ -99,7 +107,7 @@ export default {
chartOptions() {
return {
xAxis: {
- name: 'Time',
+ name: __('Time'),
type: 'time',
axisLabel: {
formatter: date => dateFormat(date, 'h:MM TT'),
@@ -194,12 +202,13 @@ export default {
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
this.tooltip.content = [];
params.seriesData.forEach(seriesData => {
- if (seriesData.componentSubType === graphTypes.deploymentData) {
- this.tooltip.isDeployment = true;
+ this.tooltip.isDeployment = seriesData.componentSubType === graphTypes.deploymentData;
+ if (this.tooltip.isDeployment) {
const [deploy] = this.recentDeployments.filter(
deployment => deployment.createdAt === seriesData.value[0],
);
this.tooltip.sha = deploy.sha.substring(0, 8);
+ this.tooltip.commitUrl = deploy.commitUrl;
} else {
const { seriesName, color } = seriesData;
// seriesData.value contains the chart's [x, y] value pair
@@ -258,7 +267,7 @@ export default {
</template>
<div slot="tooltipContent" class="d-flex align-items-center">
<icon name="commit" class="mr-2" />
- {{ tooltip.sha }}
+ <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
</div>
</template>
<template v-else>
diff --git a/app/assets/javascripts/monitoring/components/charts/column.vue b/app/assets/javascripts/monitoring/components/charts/column.vue
new file mode 100644
index 00000000000..05a2036f4c3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/column.vue
@@ -0,0 +1,131 @@
+<script>
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import { chartHeight } from '../../constants';
+import { makeDataSeries } from '~/helpers/monitor_helper';
+
+export default {
+ components: {
+ GlColumnChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator(data) {
+ return (
+ Array.isArray(data.queries) &&
+ data.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return (
+ query.result.filter(res => Array.isArray(res.values)).length === query.result.length
+ );
+ }
+ return false;
+ }).length === data.queries.length
+ );
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ },
+ },
+ data() {
+ return {
+ width: 0,
+ height: chartHeight,
+ svgs: {},
+ debouncedResizeCallback: {},
+ };
+ },
+ computed: {
+ chartData() {
+ const queryData = this.graphData.queries.reduce((acc, query) => {
+ const series = makeDataSeries(query.result, {
+ name: this.formatLegendLabel(query),
+ });
+
+ return acc.concat(series);
+ }, []);
+
+ return {
+ values: queryData[0].data,
+ };
+ },
+ xAxisTitle() {
+ return this.graphData.queries[0].result[0].x_label !== undefined
+ ? this.graphData.queries[0].result[0].x_label
+ : '';
+ },
+ yAxisTitle() {
+ return this.graphData.queries[0].result[0].y_label !== undefined
+ ? this.graphData.queries[0].result[0].y_label
+ : '';
+ },
+ xAxisType() {
+ return this.graphData.x_type !== undefined ? this.graphData.x_type : 'category';
+ },
+ dataZoomConfig() {
+ const handleIcon = this.svgs['scroll-handle'];
+
+ return handleIcon ? { handleIcon } : {};
+ },
+ chartOptions() {
+ return {
+ dataZoom: this.dataZoomConfig,
+ };
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.debouncedResizeCallback);
+ },
+ created() {
+ this.debouncedResizeCallback = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', this.debouncedResizeCallback);
+ this.setSvg('scroll-handle');
+ },
+ methods: {
+ formatLegendLabel(query) {
+ return `${query.label}`;
+ },
+ onResize() {
+ const { width } = this.$refs.columnChart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ setSvg(name) {
+ getSvgIconPathContent(name)
+ .then(path => {
+ if (path) {
+ this.$set(this.svgs, name, `path://${path}`);
+ }
+ })
+ .catch(() => {});
+ },
+ },
+};
+</script>
+<template>
+ <div class="prometheus-graph col-12 col-lg-6">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-column-chart
+ ref="columnChart"
+ v-bind="$attrs"
+ :data="chartData"
+ :option="chartOptions"
+ :width="width"
+ :height="height"
+ :x-axis-title="xAxisTitle"
+ :y-axis-title="yAxisTitle"
+ :x-axis-type="xAxisType"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 0a652329dfe..2cbda8ea05d 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -106,17 +106,24 @@ export default {
},
customMetricsPath: {
type: String,
- required: true,
+ required: false,
+ default: invalidUrl,
},
validateQueryPath: {
type: String,
- required: true,
+ required: false,
+ default: invalidUrl,
},
dashboardEndpoint: {
type: String,
required: false,
default: invalidUrl,
},
+ currentDashboard: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
return {
@@ -139,10 +146,15 @@ export default {
'deploymentData',
'metricsWithData',
'useDashboardEndpoint',
+ 'allDashboards',
+ 'multipleDashboardsEnabled',
]),
groupsWithData() {
return this.groups.filter(group => this.chartsWithData(group.metrics).length > 0);
},
+ selectedDashboardText() {
+ return this.currentDashboard || (this.allDashboards[0] && this.allDashboards[0].display_name);
+ },
},
created() {
this.setEndpoints({
@@ -150,6 +162,7 @@ export default {
environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentsEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
+ currentDashboard: this.currentDashboard,
});
this.timeWindows = timeWindows;
@@ -234,12 +247,30 @@ export default {
</script>
<template>
- <div v-if="!showEmptyState" class="prometheus-graphs">
+ <div class="prometheus-graphs">
<div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between">
<div
v-if="environmentsEndpoint"
class="dropdowns d-flex align-items-center justify-content-between"
>
+ <div v-if="multipleDashboardsEnabled" class="d-flex align-items-center">
+ <label class="mb-0">{{ __('Dashboard') }}</label>
+ <gl-dropdown
+ class="ml-2 mr-3 js-dashboards-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedDashboardText"
+ >
+ <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>
+ </div>
<div class="d-flex align-items-center">
<strong>{{ s__('Metrics|Environment') }}</strong>
<gl-dropdown
@@ -253,11 +284,12 @@ export default {
:key="environment.id"
:active="environment.name === currentEnvironmentName"
active-class="is-active"
+ :href="environment.metrics_path"
>{{ environment.name }}</gl-dropdown-item
>
</gl-dropdown>
</div>
- <div class="d-flex align-items-center prepend-left-8">
+ <div v-if="!showEmptyState" class="d-flex align-items-center prepend-left-8">
<strong>{{ s__('Metrics|Show last') }}</strong>
<gl-dropdown
class="prepend-left-10 js-time-window-dropdown"
@@ -276,7 +308,7 @@ export default {
</div>
</div>
<div class="d-flex">
- <div v-if="isEE && canAddMetrics">
+ <div v-if="isEE && canAddMetrics && !showEmptyState">
<gl-button
v-gl-modal-directive="$options.addMetric.modalId"
class="js-add-metric-button text-success border-success"
@@ -317,40 +349,43 @@ export default {
</gl-button>
</div>
</div>
- <graph-group
- v-for="(groupData, index) in groupsWithData"
- :key="index"
- :name="groupData.group"
- :show-panels="showPanels"
- >
- <monitor-area-chart
- v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
- :key="graphIndex"
- :graph-data="graphData"
- :deployment-data="deploymentData"
- :thresholds="getGraphAlertValues(graphData.queries)"
- :container-width="elWidth"
- group-id="monitor-area-chart"
+ <div v-if="!showEmptyState">
+ <graph-group
+ v-for="(groupData, index) in groupsWithData"
+ :key="index"
+ :name="groupData.group"
+ :show-panels="showPanels"
>
- <alert-widget
- v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
- :alerts-endpoint="alertsEndpoint"
- :relevant-queries="graphData.queries"
- :alerts-to-manage="getGraphAlerts(graphData.queries)"
- @setAlerts="setAlerts"
- />
- </monitor-area-chart>
- </graph-group>
+ <monitor-area-chart
+ v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)"
+ :key="graphIndex"
+ :project-path="projectPath"
+ :graph-data="graphData"
+ :deployment-data="deploymentData"
+ :thresholds="getGraphAlertValues(graphData.queries)"
+ :container-width="elWidth"
+ group-id="monitor-area-chart"
+ >
+ <alert-widget
+ v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
+ :alerts-endpoint="alertsEndpoint"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ @setAlerts="setAlerts"
+ />
+ </monitor-area-chart>
+ </graph-group>
+ </div>
+ <empty-state
+ v-else
+ :selected-state="emptyState"
+ :documentation-path="documentationPath"
+ :settings-path="settingsPath"
+ :clusters-path="clustersPath"
+ :empty-getting-started-svg-path="emptyGettingStartedSvgPath"
+ :empty-loading-svg-path="emptyLoadingSvgPath"
+ :empty-no-data-svg-path="emptyNoDataSvgPath"
+ :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
+ />
</div>
- <empty-state
- v-else
- :selected-state="emptyState"
- :documentation-path="documentationPath"
- :settings-path="settingsPath"
- :clusters-path="clustersPath"
- :empty-getting-started-svg-path="emptyGettingStartedSvgPath"
- :empty-loading-svg-path="emptyLoadingSvgPath"
- :empty-no-data-svg-path="emptyNoDataSvgPath"
- :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath"
- />
</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index 0e141d02ead..a3c6de14aa4 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,4 +1,6 @@
<script>
+import { __ } from '~/locale';
+
export default {
props: {
documentationPath: {
@@ -41,35 +43,35 @@ export default {
states: {
gettingStarted: {
svgUrl: this.emptyGettingStartedSvgPath,
- title: 'Get started with performance monitoring',
- description: `Stay updated about the performance and health
- of your environment by configuring Prometheus to monitor your deployments.`,
- buttonText: 'Install on clusters',
+ title: __('Get started with performance monitoring'),
+ description: __(`Stay updated about the performance and health
+ of your environment by configuring Prometheus to monitor your deployments.`),
+ buttonText: __('Install on clusters'),
buttonPath: this.clustersPath,
- secondaryButtonText: 'Configure existing installation',
+ secondaryButtonText: __('Configure existing installation'),
secondaryButtonPath: this.settingsPath,
},
loading: {
svgUrl: this.emptyLoadingSvgPath,
- title: 'Waiting for performance data',
- description: `Creating graphs uses the data from the Prometheus server.
- If this takes a long time, ensure that data is available.`,
- buttonText: 'View documentation',
+ title: __('Waiting for performance data'),
+ description: __(`Creating graphs uses the data from the Prometheus server.
+ If this takes a long time, ensure that data is available.`),
+ buttonText: __('View documentation'),
buttonPath: this.documentationPath,
},
noData: {
svgUrl: this.emptyNoDataSvgPath,
- title: 'No data found',
- description: `You are connected to the Prometheus server, but there is currently
- no data to display.`,
- buttonText: 'Configure Prometheus',
+ title: __('No data found'),
+ description: __(`You are connected to the Prometheus server, but there is currently
+ no data to display.`),
+ buttonText: __('Configure Prometheus'),
buttonPath: this.settingsPath,
},
unableToConnect: {
svgUrl: this.emptyUnableToConnectSvgPath,
- title: 'Unable to connect to Prometheus server',
+ title: __('Unable to connect to Prometheus server'),
description: 'Ensure connectivity is available from the GitLab server to the ',
- buttonText: 'View documentation',
+ buttonText: __('View documentation'),
buttonPath: this.documentationPath,
},
},
@@ -90,7 +92,9 @@ export default {
<template>
<div class="row empty-state js-empty-state">
<div class="col-12">
- <div class="state-svg svg-content"><img :src="currentState.svgUrl" /></div>
+ <div class="state-svg svg-content">
+ <img :src="currentState.svgUrl" />
+ </div>
</div>
<div class="col-12">
@@ -98,20 +102,22 @@ export default {
<h4 class="state-title text-center">{{ currentState.title }}</h4>
<p class="state-description">
{{ currentState.description }}
- <a v-if="showButtonDescription" :href="settingsPath"> Prometheus server </a>
+ <a v-if="showButtonDescription" :href="settingsPath">{{ __('Prometheus server') }}</a>
</p>
<div class="text-center">
- <a v-if="currentState.buttonPath" :href="currentState.buttonPath" class="btn btn-success">
- {{ currentState.buttonText }}
- </a>
+ <a
+ v-if="currentState.buttonPath"
+ :href="currentState.buttonPath"
+ class="btn btn-success"
+ >{{ currentState.buttonText }}</a
+ >
<a
v-if="currentState.secondaryButtonPath"
:href="currentState.secondaryButtonPath"
class="btn"
+ >{{ currentState.secondaryButtonText }}</a
>
- {{ currentState.secondaryButtonText }}
- </a>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 1d33537b3b2..97d149e9ad5 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
import store from './stores';
@@ -7,10 +8,14 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
- store.dispatch(
- 'monitoringDashboard/setDashboardEnabled',
- gon.features.environmentMetricsUsePrometheusEndpoint,
- );
+ if (gon.features) {
+ store.dispatch('monitoringDashboard/setFeatureFlags', {
+ prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint,
+ multipleDashboardsEnabled: gon.features.environmentMetricsShowMultipleDashboards,
+ });
+ }
+
+ const [currentDashboard] = getParameterValues('dashboard');
// eslint-disable-next-line no-new
new Vue({
@@ -20,6 +25,7 @@ export default (props = {}) => {
return createElement(Dashboard, {
props: {
...el.dataset,
+ currentDashboard,
hasMetrics: parseBoolean(el.dataset.hasMetrics),
...props,
},
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index f41e215cb5d..0fa2a5d6370 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -35,14 +35,24 @@ export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, endpoints);
};
-export const setDashboardEnabled = ({ commit }, enabled) => {
- commit(types.SET_DASHBOARD_ENABLED, enabled);
+export const setFeatureFlags = (
+ { commit },
+ { prometheusEndpointEnabled, multipleDashboardsEnabled },
+) => {
+ commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled);
+ commit(types.SET_MULTIPLE_DASHBOARDS_ENABLED, multipleDashboardsEnabled);
};
export const requestMetricsDashboard = ({ commit }) => {
commit(types.REQUEST_METRICS_DATA);
};
-export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => {
+export const receiveMetricsDashboardSuccess = (
+ { state, commit, dispatch },
+ { response, params },
+) => {
+ if (state.multipleDashboardsEnabled) {
+ commit(types.SET_ALL_DASHBOARDS, response.all_dashboards);
+ }
commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
dispatch('fetchPrometheusMetrics', params);
};
@@ -95,6 +105,11 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
export const fetchDashboard = ({ state, dispatch }, params) => {
dispatch('requestMetricsDashboard');
+ if (state.currentDashboard) {
+ // eslint-disable-next-line no-param-reassign
+ params.dashboard = state.currentDashboard;
+ }
+
return axios
.get(state.dashboardEndpoint, { params })
.then(resp => resp.data)
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 63894e83362..2c78a0b9315 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -10,6 +10,8 @@ export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAIL
export const SET_QUERY_RESULT = 'SET_QUERY_RESULT';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
+export const SET_MULTIPLE_DASHBOARDS_ENABLED = 'SET_MULTIPLE_DASHBOARDS_ENABLED';
+export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index d4b816e2717..a85a7723c1f 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -74,10 +74,14 @@ export default {
state.environmentsEndpoint = endpoints.environmentsEndpoint;
state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
state.dashboardEndpoint = endpoints.dashboardEndpoint;
+ state.currentDashboard = endpoints.currentDashboard;
},
[types.SET_DASHBOARD_ENABLED](state, enabled) {
state.useDashboardEndpoint = enabled;
},
+ [types.SET_MULTIPLE_DASHBOARDS_ENABLED](state, enabled) {
+ state.multipleDashboardsEnabled = enabled;
+ },
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
},
@@ -85,4 +89,7 @@ export default {
state.showEmptyState = true;
state.emptyState = 'noData';
},
+ [types.SET_ALL_DASHBOARDS](state, dashboards) {
+ state.allDashboards = dashboards;
+ },
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index c33529cd588..de711d6ccae 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -8,10 +8,13 @@ export default () => ({
deploymentsEndpoint: null,
dashboardEndpoint: invalidUrl,
useDashboardEndpoint: false,
+ multipleDashboardsEnabled: false,
emptyState: 'gettingStarted',
showEmptyState: true,
groups: [],
deploymentData: [],
environments: [],
metricsWithData: [],
+ allDashboards: [],
+ currentDashboard: null,
});
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 075c28e8d07..5a4b5f9398b 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -65,7 +65,7 @@ export default {
return this.getUserData.id;
},
commentButtonTitle() {
- return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
+ return this.noteType === constants.COMMENT ? 'Comment' : 'Start thread';
},
startDiscussionDescription() {
let text = 'Discuss a specific suggestion or question';
@@ -418,7 +418,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
- <strong>Start discussion</strong>
+ <strong>Start thread</strong>
<p>{{ startDiscussionDescription }}</p>
</div>
</button>
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
index 22cca756ef6..f4570c1292c 100644
--- a/app/assets/javascripts/notes/components/discussion_actions.vue
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -40,7 +40,11 @@ export default {
<template>
<div class="discussion-with-resolve-btn">
- <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" />
+ <reply-placeholder
+ :button-text="s__('MergeRequests|Reply...')"
+ class="qa-discussion-reply"
+ @onClick="$emit('showReplyForm')"
+ />
<resolve-discussion-button
v-if="discussion.resolvable"
:is-resolving="isResolving"
@@ -53,6 +57,17 @@ export default {
v-if="shouldShowJumpToNextDiscussion"
@onClick="$emit('jumpToNextDiscussion')"
/>
+ <resolve-with-issue-button
+ v-if="discussion.resolvable && resolveWithIssuePath"
+ :url="resolveWithIssuePath"
+ />
+ </div>
+
+ <div
+ v-if="discussion.resolvable && shouldShowJumpToNextDiscussion"
+ class="btn-group discussion-actions ml-sm-2"
+ >
+ <jump-to-next-discussion-button @onClick="$emit('jumpToNextDiscussion')" />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue
index efd84f5722c..d7ffa0abb79 100644
--- a/app/assets/javascripts/notes/components/discussion_counter.vue
+++ b/app/assets/javascripts/notes/components/discussion_counter.vue
@@ -61,7 +61,7 @@ export default {
</span>
<span class="line-resolve-text">
{{ resolvedDiscussionsCount }}/{{ resolvableDiscussionsCount }}
- {{ n__('discussion resolved', 'discussions resolved', resolvableDiscussionsCount) }}
+ {{ n__('thread resolved', 'threads resolved', resolvableDiscussionsCount) }}
</span>
</div>
<div
@@ -72,7 +72,7 @@ export default {
<a
v-gl-tooltip
:href="resolveAllDiscussionsIssuePath"
- :title="s__('Resolve all discussions in new issue')"
+ :title="s__('Resolve all threads in new issue')"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
>
<icon name="issue-new" />
@@ -81,7 +81,7 @@ export default {
<div v-if="isLoggedIn && !allResolved" class="btn-group btn-group-sm" role="group">
<button
v-gl-tooltip
- title="Jump to first unresolved discussion"
+ title="Jump to first unresolved thread"
class="btn btn-default discussion-next-btn"
@click="jumpToFirstUnresolvedDiscussion"
>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
index 228bb652597..2ff0fee62f3 100644
--- a/app/assets/javascripts/notes/components/discussion_notes.vue
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -1,11 +1,11 @@
<script>
-import { mapGetters } from 'vuex';
+import { mapGetters, mapActions } from 'vuex';
import { SYSTEM_NOTE } from '../constants';
import { __ } from '~/locale';
-import NoteableNote from './noteable_note.vue';
-import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import NoteableNote from './noteable_note.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
import NoteEditedText from './note_edited_text.vue';
@@ -72,6 +72,7 @@ export default {
},
},
methods: {
+ ...mapActions(['toggleDiscussion']),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
@@ -101,12 +102,12 @@ export default {
<component
:is="componentName(firstNote)"
:note="componentData(firstNote)"
- :line="line"
+ :line="line || diffLine"
:commit="commit"
:help-page-path="helpPagePath"
:show-reply-button="userCanReply"
- @handle-delete-note="$emit('deleteNote')"
- @start-replying="$emit('startReplying')"
+ @handleDeleteNote="$emit('deleteNote')"
+ @startReplying="$emit('startReplying')"
>
<note-edited-text
v-if="discussion.resolved"
@@ -118,23 +119,29 @@ export default {
/>
<slot slot="avatar-badge" name="avatar-badge"></slot>
</component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="!isExpanded"
- :replies="replies"
- @toggle="$emit('toggleDiscussion')"
- />
- <template v-if="isExpanded">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="line"
- @handle-delete-note="$emit('deleteNote')"
+ <div
+ :class="discussion.diff_discussion ? 'discussion-collapsible bordered-box clearfix' : ''"
+ >
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ :class="{ 'discussion-toggle-replies': discussion.diff_discussion }"
+ @toggle="toggleDiscussion({ discussionId: discussion.id })"
/>
- </template>
+ <template v-if="isExpanded">
+ <component
+ :is="componentName(note)"
+ v-for="note in replies"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
+ @handleDeleteNote="$emit('deleteNote')"
+ />
+ </template>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
+ </div>
</template>
<template v-else>
<component
@@ -144,12 +151,12 @@ export default {
:note="componentData(note)"
:help-page-path="helpPagePath"
:line="diffLine"
- @handle-delete-note="$emit('deleteNote')"
+ @handleDeleteNote="$emit('deleteNote')"
>
<slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
</component>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</template>
</ul>
- <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
index ea590905e3c..0204169214b 100644
--- a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -1,6 +1,12 @@
<script>
export default {
name: 'ReplyPlaceholder',
+ props: {
+ buttonText: {
+ type: String,
+ required: true,
+ },
+ },
};
</script>
@@ -12,6 +18,6 @@ export default {
:title="s__('MergeRequests|Add a reply')"
@click="$emit('onClick')"
>
- {{ s__('MergeRequests|Reply...') }}
+ {{ buttonText }}
</button>
</template>
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
index e413398696a..f03e6fd73d7 100644
--- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -25,7 +25,7 @@ export default {
<gl-button
v-gl-tooltip
:href="url"
- :title="s__('MergeRequests|Resolve this discussion in a new issue')"
+ :title="s__('MergeRequests|Resolve this thread in a new issue')"
class="new-issue-for-discussion discussion-create-issue-btn"
>
<icon name="issue-new" />
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 042ed196933..01be4f2b094 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -283,11 +283,11 @@ export default {
type="checkbox"
class="qa-unresolve-review-discussion"
/>
- {{ __('Unresolve discussion') }}
+ {{ __('Unresolve thread') }}
</template>
<template v-else>
<input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" />
- {{ __('Resolve discussion') }}
+ {{ __('Resolve thread') }}
</template>
</label>
</p>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index fbf82fab9e9..6466ab3acbe 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -70,7 +70,7 @@ export default {
@click="handleToggle"
>
<i :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
- {{ __('Toggle discussion') }}
+ {{ __('Toggle thread') }}
</button>
</div>
<a
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 10b15a9c38c..a71a89cfffc 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -126,16 +126,13 @@ export default {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
shouldShowJumpToNextDiscussion() {
- return this.showJumpToNextDiscussion(
- this.discussion.id,
- this.discussionsByDiffOrder ? 'diff' : 'discussion',
- );
+ return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion');
},
shouldRenderDiffs() {
return this.discussion.diff_discussion && this.renderDiffFile;
},
shouldGroupReplies() {
- return !this.shouldRenderDiffs && !this.discussion.diff_discussion;
+ return !this.shouldRenderDiffs;
},
wrapperComponent() {
return this.shouldRenderDiffs ? diffWithNote : 'div';
@@ -177,22 +174,20 @@ export default {
active: isActive,
} = this.discussion;
- let text = s__('MergeRequests|started a discussion');
+ let text = s__('MergeRequests|started a thread');
if (isForCommit) {
- text = s__(
- 'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}',
- );
+ text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}');
} else if (isDiffDiscussion && commitId) {
text = isActive
- ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}')
+ ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}')
: s__(
- 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
+ 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
);
} else if (isDiffDiscussion) {
text = isActive
- ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}')
+ ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}')
: s__(
- 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}',
+ 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}',
);
}
@@ -253,6 +248,11 @@ export default {
clearDraft(this.autosaveKey);
},
saveReply(noteText, form, callback) {
+ if (!noteText) {
+ this.cancelReplyForm();
+ callback();
+ return;
+ }
const postData = {
in_reply_to_discussion_id: this.discussion.reply_id,
target_type: this.getNoteableData.targetType,
@@ -366,7 +366,6 @@ Please check your network connection and try again.`;
:line="line"
:should-group-replies="shouldGroupReplies"
@startReplying="showReplyForm"
- @toggleDiscussion="toggleDiscussionHandler"
@deleteNote="deleteNoteHandler"
>
<slot slot="avatar-badge" name="avatar-badge"></slot>
@@ -379,7 +378,7 @@ Please check your network connection and try again.`;
<div
v-else-if="showReplies"
:class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
+ class="discussion-reply-holder clearfix"
>
<user-avatar-link
v-if="!isReplying && userCanReply"
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 2329727bca2..16b7598ee09 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -20,13 +20,13 @@ export default {
resolveButtonTitle() {
if (this.updatedNoteBody) {
if (this.discussionResolved) {
- return __('Comment & unresolve discussion');
+ return __('Comment & unresolve thread');
}
- return __('Comment & resolve discussion');
+ return __('Comment & resolve thread');
}
- return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion');
+ return this.discussionResolved ? __('Unresolve thread') : __('Resolve thread');
},
},
methods: {
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 63658d49a05..9054b4779aa 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -51,7 +51,7 @@ export const fetchDiscussions = ({ commit, dispatch }, { path, filter }) =>
.then(res => res.json())
.then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
});
export const updateDiscussion = ({ commit, state }, discussion) => {
@@ -67,7 +67,7 @@ export const deleteNote = ({ commit, dispatch, state }, note) =>
commit(types.DELETE_NOTE, note);
dispatch('updateMergeRequestWidget');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
if (isInMRPage()) {
dispatch('diffs/removeDiscussionsFromDiff', discussion);
@@ -117,7 +117,7 @@ export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoi
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
} else {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
}
@@ -135,7 +135,7 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
}
return res;
});
@@ -168,7 +168,7 @@ export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved,
commit(mutationType, res);
- dispatch('updateResolvableDiscussonsCounts');
+ dispatch('updateResolvableDiscussionsCounts');
dispatch('updateMergeRequestWidget');
});
@@ -442,7 +442,7 @@ export const startTaskList = ({ dispatch }) =>
}),
);
-export const updateResolvableDiscussonsCounts = ({ commit }) =>
+export const updateResolvableDiscussionsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index d7982be3e4b..8aa8f5037b3 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -61,15 +61,13 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo
export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
-export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => {
+export const showJumpToNextDiscussion = (state, getters) => (mode = 'discussion') => {
const orderedDiffs =
mode !== 'discussion'
? getters.unresolvedDiscussionsIdsByDiff
: getters.unresolvedDiscussionsIdsByDate;
- const indexOf = orderedDiffs.indexOf(discussionId);
-
- return indexOf !== -1 && indexOf < orderedDiffs.length - 1;
+ return orderedDiffs.length > 1;
};
export const isDiscussionResolved = (state, getters) => discussionId =>
diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js
index 9055738f86e..2ffeed8a584 100644
--- a/app/assets/javascripts/pages/dashboard/issues/index.js
+++ b/app/assets/javascripts/pages/dashboard/issues/index.js
@@ -2,6 +2,7 @@ import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
+import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
@@ -10,4 +11,5 @@ document.addEventListener('DOMContentLoaded', () => {
});
projectSelect();
+ initManualOrdering();
});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 35d4b034654..23fb5656008 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -2,6 +2,7 @@ import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
+import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
@@ -12,4 +13,5 @@ document.addEventListener('DOMContentLoaded', () => {
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
projectSelect();
+ initManualOrdering();
});
diff --git a/app/assets/javascripts/pages/projects/branches/index/index.js b/app/assets/javascripts/pages/projects/branches/index/index.js
index 29de3b7806c..37e8c75f299 100644
--- a/app/assets/javascripts/pages/projects/branches/index/index.js
+++ b/app/assets/javascripts/pages/projects/branches/index/index.js
@@ -5,5 +5,5 @@ import initDiverganceGraph from '~/branches/divergence_graph';
document.addEventListener('DOMContentLoaded', () => {
AjaxLoadingSpinner.init();
new DeleteModal(); // eslint-disable-line no-new
- initDiverganceGraph();
+ initDiverganceGraph(document.querySelector('.js-branch-list').dataset.divergingCountsEndpoint);
});
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index c34aff02111..c73ebb31eb3 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -7,6 +7,7 @@ import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
+import initManualOrdering from '~/manual_ordering';
document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
@@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => {
new ShortcutsNavigation();
new UsersSelect();
+ initManualOrdering();
});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
index ff6dadeff7d..533065b2d4d 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_feature_setting.vue
@@ -1,5 +1,6 @@
<script>
-import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
+import { featureAccessLevelNone } from '../constants';
export default {
components: {
@@ -43,7 +44,7 @@ export default {
if (this.featureEnabled) {
return this.options;
}
- return [[0, 'Enable feature to choose access level']];
+ return [featureAccessLevelNone];
},
displaySelectInput() {
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index dea7c586868..b4d24f3aa36 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -1,16 +1,26 @@
<script>
+import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
+import { __ } from '~/locale';
import projectFeatureSetting from './project_feature_setting.vue';
-import projectFeatureToggle from '../../../../../vue_shared/components/toggle_button.vue';
+import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import projectSettingRow from './project_setting_row.vue';
-import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
+import {
+ visibilityOptions,
+ visibilityLevelDescriptions,
+ featureAccessLevelMembers,
+ featureAccessLevelEveryone,
+} from '../constants';
import { toggleHiddenClassBySelector } from '../external';
+const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone');
+
export default {
components: {
projectFeatureSetting,
projectFeatureToggle,
projectSettingRow,
},
+ mixins: [settingsMixin],
props: {
currentSettings: {
@@ -37,6 +47,11 @@ export default {
required: false,
default: false,
},
+ packagesAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
visibilityHelpPath: {
type: String,
required: false,
@@ -67,8 +82,12 @@ export default {
required: false,
default: '',
},
+ packagesHelpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
-
data() {
const defaults = {
visibilityOptions,
@@ -91,9 +110,9 @@ export default {
computed: {
featureAccessLevelOptions() {
- const options = [[10, 'Only Project Members']];
+ const options = [featureAccessLevelMembers];
if (this.visibilityLevel !== visibilityOptions.PRIVATE) {
- options.push([20, 'Everyone With Access']);
+ options.push(featureAccessLevelEveryone);
}
return options;
},
@@ -106,7 +125,7 @@ export default {
pagesFeatureAccessLevelOptions() {
if (this.visibilityLevel !== visibilityOptions.PUBLIC) {
- return this.featureAccessLevelOptions.concat([[30, 'Everyone']]);
+ return this.featureAccessLevelOptions.concat([[30, PAGE_FEATURE_ACCESS_LEVEL]]);
}
return this.featureAccessLevelOptions;
},
@@ -148,24 +167,6 @@ export default {
}
},
- repositoryAccessLevel(value, oldValue) {
- if (value < oldValue) {
- // sub-features cannot have more premissive access level
- this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
- this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
-
- if (value === 0) {
- this.containerRegistryEnabled = false;
- this.lfsEnabled = false;
- }
- } else if (oldValue === 0) {
- this.mergeRequestsAccessLevel = value;
- this.buildsAccessLevel = value;
- this.containerRegistryEnabled = true;
- this.lfsEnabled = true;
- }
- },
-
issuesAccessLevel(value, oldValue) {
if (value === 0) toggleHiddenClassBySelector('.issues-feature', true);
else if (oldValue === 0) toggleHiddenClassBySelector('.issues-feature', false);
@@ -207,23 +208,20 @@ export default {
<option
:value="visibilityOptions.PRIVATE"
:disabled="!visibilityAllowed(visibilityOptions.PRIVATE)"
+ >{{ __('Private') }}</option
>
- Private
- </option>
<option
:value="visibilityOptions.INTERNAL"
:disabled="!visibilityAllowed(visibilityOptions.INTERNAL)"
+ >{{ __('Internal') }}</option
>
- Internal
- </option>
<option
:value="visibilityOptions.PUBLIC"
:disabled="!visibilityAllowed(visibilityOptions.PUBLIC)"
+ >{{ __('Public') }}</option
>
- Public
- </option>
</select>
- <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"> </i>
+ <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
</div>
</div>
<span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
@@ -299,6 +297,18 @@ export default {
name="project[lfs_enabled]"
/>
</project-setting-row>
+ <project-setting-row
+ v-if="packagesAvailable"
+ :help-path="packagesHelpPath"
+ label="Packages"
+ help-text="Every project can have its own space to store its packages"
+ >
+ <project-feature-toggle
+ v-model="packagesEnabled"
+ :disabled-input="!repositoryEnabled"
+ name="project[packages_enabled]"
+ />
+ </project-setting-row>
</div>
<project-setting-row label="Wiki" help-text="Pages for project documentation">
<project-feature-setting
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index ac0dca31c37..73269c6f3ba 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -15,3 +15,30 @@ export const visibilityLevelDescriptions = {
'The project can be accessed by anyone, regardless of authentication.',
),
};
+
+const featureAccessLevel = {
+ NOT_ENABLED: 0,
+ PROJECT_MEMBERS: 10,
+ EVERYONE: 20,
+};
+
+const featureAccessLevelDescriptions = {
+ [featureAccessLevel.NOT_ENABLED]: __('Enable feature to choose access level'),
+ [featureAccessLevel.PROJECT_MEMBERS]: __('Only Project Members'),
+ [featureAccessLevel.EVERYONE]: __('Everyone With Access'),
+};
+
+export const featureAccessLevelNone = [
+ featureAccessLevel.NOT_ENABLED,
+ featureAccessLevelDescriptions[featureAccessLevel.NOT_ENABLED],
+];
+
+export const featureAccessLevelMembers = [
+ featureAccessLevel.PROJECT_MEMBERS,
+ featureAccessLevelDescriptions[featureAccessLevel.PROJECT_MEMBERS],
+];
+
+export const featureAccessLevelEveryone = [
+ featureAccessLevel.EVERYONE,
+ featureAccessLevelDescriptions[featureAccessLevel.EVERYONE],
+];
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js
new file mode 100644
index 00000000000..fcbd81416f2
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js
@@ -0,0 +1,26 @@
+export default {
+ data() {
+ return {
+ packagesEnabled: false,
+ };
+ },
+ watch: {
+ repositoryAccessLevel(value, oldValue) {
+ if (value < oldValue) {
+ // sub-features cannot have more premissive access level
+ this.mergeRequestsAccessLevel = Math.min(this.mergeRequestsAccessLevel, value);
+ this.buildsAccessLevel = Math.min(this.buildsAccessLevel, value);
+
+ if (value === 0) {
+ this.containerRegistryEnabled = false;
+ this.lfsEnabled = false;
+ }
+ } else if (oldValue === 0) {
+ this.mergeRequestsAccessLevel = value;
+ this.buildsAccessLevel = value;
+ this.containerRegistryEnabled = true;
+ this.lfsEnabled = true;
+ }
+ },
+ },
+};
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 7f800d20835..1d8b388e935 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -18,12 +18,12 @@ import UserOverviewBlock from './user_overview_block';
*
* <ul class="nav-links">
* <li class="activity-tab active">
- * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/u/username">
+ * <a data-action="activity" data-target="#activity" data-toggle="tab" href="/username">
* Activity
* </a>
* </li>
* <li class="groups-tab">
- * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/u/username/groups">
+ * <a data-action="groups" data-target="#groups" data-toggle="tab" href="/users/username/groups">
* Groups
* </a>
* </li>
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index 8f3ba9779fb..d5f1cea8356 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -92,7 +92,9 @@ export default {
</template>
<template v-else>
<tr>
- <td>No {{ header.toLowerCase() }} for this request.</td>
+ <td>
+ {{ sprintf(__('No %{header} for this request.'), { header: header.toLowerCase() }) }}
+ </td>
</tr>
</template>
</table>
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 48515cf785c..015c1527500 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -4,13 +4,12 @@ import { glEmojiTag } from '~/emoji';
import detailedMetric from './detailed_metric.vue';
import requestSelector from './request_selector.vue';
-import simpleMetric from './simple_metric.vue';
+import { s__ } from '~/locale';
export default {
components: {
detailedMetric,
requestSelector,
- simpleMetric,
},
props: {
store: {
@@ -35,15 +34,20 @@ export default {
},
},
detailedMetrics: [
- { metric: 'pg', header: 'SQL queries', details: 'queries', keys: ['sql'] },
+ { metric: 'pg', header: s__('PerformanceBar|SQL queries'), details: 'queries', keys: ['sql'] },
{
metric: 'gitaly',
- header: 'Gitaly calls',
+ header: s__('PerformanceBar|Gitaly calls'),
details: 'details',
keys: ['feature', 'request'],
},
+ {
+ metric: 'redis',
+ header: 'Redis calls',
+ details: 'details',
+ keys: ['cmd'],
+ },
],
- simpleMetrics: ['redis'],
data() {
return { currentRequestId: '' };
},
@@ -99,7 +103,8 @@ export default {
class="current-host"
:class="{ canary: currentRequest.details.host.canary }"
>
- <span v-html="birdEmoji"></span> {{ currentRequest.details.host.hostname }}
+ <span v-html="birdEmoji"></span>
+ {{ currentRequest.details.host.hostname }}
</span>
</div>
<detailed-metric
@@ -118,16 +123,10 @@ export default {
data-toggle="modal"
data-target="#modal-peek-line-profile"
>
- profile
+ {{ s__('PerformanceBar|profile') }}
</button>
- <a v-else :href="profileUrl"> profile </a>
+ <a v-else :href="profileUrl">{{ s__('PerformanceBar|profile') }}</a>
</div>
- <simple-metric
- v-for="metric in $options.simpleMetrics"
- :key="metric"
- :current-request="currentRequest"
- :metric="metric"
- />
<div id="peek-view-gc" class="view">
<span v-if="currentRequest.details" class="bold">
<span title="Invoke Time">{{ currentRequest.details.gc.gc_time }}</span
@@ -139,7 +138,7 @@ export default {
id="peek-view-trace"
class="view"
>
- <a :href="currentRequest.details.tracing.tracing_url"> trace </a>
+ <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a>
</div>
<request-selector
v-if="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/components/simple_metric.vue b/app/assets/javascripts/performance_bar/components/simple_metric.vue
deleted file mode 100644
index 358a57d5bc5..00000000000
--- a/app/assets/javascripts/performance_bar/components/simple_metric.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- props: {
- currentRequest: {
- type: Object,
- required: true,
- },
- metric: {
- type: String,
- required: true,
- },
- },
- computed: {
- duration() {
- return (
- this.currentRequest.details[this.metric] &&
- this.currentRequest.details[this.metric].duration
- );
- },
- calls() {
- return (
- this.currentRequest.details[this.metric] && this.currentRequest.details[this.metric].calls
- );
- },
- },
-};
-</script>
-<template>
- <div :id="`peek-view-${metric}`" class="view">
- <span v-if="currentRequest.details" class="bold"> {{ duration }} / {{ calls }} </span>
- {{ metric }}
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index b2e365e5cde..39afa87afc3 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -2,6 +2,7 @@
import { GlLoadingIcon } from '@gitlab/ui';
import ciHeader from '../../vue_shared/components/header_ci_component.vue';
import eventHub from '../event_hub';
+import { __ } from '~/locale';
export default {
name: 'PipelineHeaderSection',
@@ -54,7 +55,7 @@ export default {
if (this.pipeline.retry_path) {
actions.push({
- label: 'Retry',
+ label: __('Retry'),
path: this.pipeline.retry_path,
cssClass: 'js-retry-button btn btn-inverted-secondary',
type: 'button',
@@ -64,7 +65,7 @@ export default {
if (this.pipeline.cancel_path) {
actions.push({
- label: 'Cancel running',
+ label: __('Cancel running'),
path: this.pipeline.cancel_path,
cssClass: 'js-btn-cancel-pipeline btn btn-danger',
type: 'button',
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 65a2b61396c..3f021a26ec5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -94,9 +94,8 @@ export default {
tabindex="0"
class="js-pipeline-url-autodevops badge badge-info autodevops-badge"
role="button"
+ >{{ __('Auto DevOps') }}</gl-link
>
- Auto DevOps
- </gl-link>
<span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning">
{{ __('stuck') }}
</span>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 03d332cd430..d3ba0c97f6b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -44,6 +44,11 @@ export default {
cancelingPipeline: null,
};
},
+ watch: {
+ pipelines() {
+ this.cancelingPipeline = null;
+ },
+ },
created() {
eventHub.$on('openConfirmationModal', this.setModalData);
},
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index e32e2f785bd..5275de3bc8b 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -241,7 +241,11 @@ export default {
return this.cancelingPipeline === this.pipeline.id;
},
},
-
+ watch: {
+ pipeline() {
+ this.isRetrying = false;
+ },
+ },
methods: {
handleCancelClick() {
eventHub.$emit('openConfirmationModal', {
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 3cc9d0a3a4e..a6243366375 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -107,8 +107,8 @@ export default {
}
// Stop polling
this.poll.stop();
- // Update the table
- return this.getPipelines().then(() => this.poll.restart());
+ // Restarting the poll also makes an initial request
+ this.poll.restart();
},
fetchPipelines() {
if (!this.isMakingRequest) {
@@ -153,7 +153,7 @@ export default {
postAction(endpoint) {
this.service
.postAction(endpoint)
- .then(() => this.fetchPipelines())
+ .then(() => this.updateTable())
.catch(() => Flash(__('An error occurred while making the request.')));
},
},
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index d67d88c4dba..c8819cf35cf 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -1,8 +1,8 @@
import Visibility from 'visibilityjs';
+import PipelineStore from 'ee_else_ce/pipelines/stores/pipeline_store';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import { __ } from '../locale';
-import PipelineStore from './stores/pipeline_store';
import PipelineService from './services/pipeline_service';
export default class pipelinesMediator {
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index ea82ff4e340..9066844f687 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
-import { slugifyWithHyphens } from '../lib/utils/text_utility';
+import { slugify } from '../lib/utils/text_utility';
import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
@@ -34,7 +34,7 @@ const deriveProjectPathFromUrl = $projectImportUrl => {
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
- const slug = slugifyWithHyphens($projectNameInput.val());
+ const slug = slugify($projectNameInput.val());
$projectPathInput.val(slug);
};
diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
index bfc55013a71..03281aa1317 100644
--- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
+++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue
@@ -3,7 +3,7 @@ import Visibility from 'visibilityjs';
import ciIcon from '~/vue_shared/components/ci_icon.vue';
import Poll from '~/lib/utils/poll';
import Flash from '~/flash';
-import { s__, sprintf } from '~/locale';
+import { __, s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import { GlLoadingIcon } from '@gitlab/ui';
import CommitPipelineService from '../services/commit_pipeline_service';
@@ -56,7 +56,7 @@ export default {
},
errorCallback() {
this.ciStatus = {
- text: 'not found',
+ text: __('not found'),
icon: 'status_notfound',
group: 'notfound',
};
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue
index ee973017387..7752723baac 100644
--- a/app/assets/javascripts/registry/components/app.vue
+++ b/app/assets/javascripts/registry/components/app.vue
@@ -3,22 +3,81 @@ import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import store from '../stores';
import CollapsibleContainer from './collapsible_container.vue';
+import SvgMessage from './svg_message.vue';
+import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
CollapsibleContainer,
GlLoadingIcon,
+ SvgMessage,
},
props: {
endpoint: {
type: String,
required: true,
},
+ characterError: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ noContainersImage: {
+ type: String,
+ required: true,
+ },
+ containersErrorImage: {
+ type: String,
+ required: true,
+ },
+ repositoryUrl: {
+ type: String,
+ required: true,
+ },
},
store,
computed: {
...mapGetters(['isLoading', 'repos']),
+ dockerConnectionErrorText() {
+ return sprintf(
+ s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
+ issue with your project name or path. For more information, please review the
+ %{docLinkStart}Container Registry documentation%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}#docker-connection-error">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ introText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
+ project can have its own space to store its Docker images. Learn more about the
+ %{docLinkStart}Container Registry%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
+ noContainerImagesText() {
+ return sprintf(
+ s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
+ store its Docker images. Learn more about the %{docLinkStart}Container Registry%{docLinkEnd}.`),
+ {
+ docLinkStart: `<a href="${this.helpPagePath}">`,
+ docLinkEnd: '</a>',
+ },
+ false,
+ );
+ },
},
created() {
this.setMainEndpoint(this.endpoint);
@@ -33,20 +92,44 @@ export default {
</script>
<template>
<div>
- <gl-loading-icon v-if="isLoading" size="md" />
+ <svg-message v-if="characterError" id="invalid-characters" :svg-path="containersErrorImage">
+ <h4>
+ {{ s__('ContainerRegistry|Docker connection error') }}
+ </h4>
+ <p v-html="dockerConnectionErrorText"></p>
+ </svg-message>
+
+ <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" />
+
+ <div v-else-if="!isLoading && !characterError && repos.length">
+ <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
+ <p v-html="introText"></p>
+ <collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
+ </div>
+
+ <svg-message
+ v-else-if="!isLoading && !characterError && !repos.length"
+ id="no-container-images"
+ :svg-path="noContainersImage"
+ >
+ <h4>
+ {{ s__('ContainerRegistry|There are no container images stored for this project') }}
+ </h4>
+ <p v-html="noContainerImagesText"></p>
- <collapsible-container
- v-for="item in repos"
- v-else-if="!isLoading && repos.length"
- :key="item.id"
- :repo="item"
- />
+ <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
+ <p>
+ {{
+ s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ )
+ }}
+ </p>
- <p v-else-if="!isLoading && !repos.length">
- {{
- __(`No container images stored for this project.
- Add one by following the instructions above.`)
- }}
- </p>
+ <pre>
+ docker build -t {{ repositoryUrl }} .
+ docker push {{ repositoryUrl }}
+ </pre>
+ </svg-message>
</div>
</template>
diff --git a/app/assets/javascripts/registry/components/svg_message.vue b/app/assets/javascripts/registry/components/svg_message.vue
new file mode 100644
index 00000000000..d0d44bf2d14
--- /dev/null
+++ b/app/assets/javascripts/registry/components/svg_message.vue
@@ -0,0 +1,24 @@
+<script>
+export default {
+ name: 'RegistrySvgMessage',
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ svgPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div :id="id" class="empty-state container-message mw-70p">
+ <div class="svg-content">
+ <img :src="svgPath" class="flex-align-self-center" />
+ </div>
+ <slot></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js
index 025afefe7f0..d8daec29fda 100644
--- a/app/assets/javascripts/registry/index.js
+++ b/app/assets/javascripts/registry/index.js
@@ -14,12 +14,22 @@ export default () =>
const { dataset } = document.querySelector(this.$options.el);
return {
endpoint: dataset.endpoint,
+ characterError: Boolean(dataset.characterError),
+ helpPagePath: dataset.helpPagePath,
+ noContainersImage: dataset.noContainersImage,
+ containersErrorImage: dataset.containersErrorImage,
+ repositoryUrl: dataset.repositoryUrl,
};
},
render(createElement) {
return createElement('registry-app', {
props: {
endpoint: this.endpoint,
+ characterError: this.characterError,
+ helpPagePath: this.helpPagePath,
+ noContainersImage: this.noContainersImage,
+ containersErrorImage: this.containersErrorImage,
+ repositoryUrl: this.repositoryUrl,
},
});
},
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index f510b905a2e..0031ba04d78 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -4,7 +4,7 @@ import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
-import { sprintf } from '../../locale';
+import { __, sprintf } from '../../locale';
export default {
name: 'ReleaseBlock',
@@ -27,13 +27,13 @@ export default {
},
computed: {
releasedTimeAgo() {
- return sprintf('released %{time}', {
- time: this.timeFormated(this.release.created_at),
+ return sprintf(__('released %{time}'), {
+ time: this.timeFormated(this.release.released_at),
});
},
userImageAltDescription() {
return this.author && this.author.username
- ? sprintf("%{username}'s avatar", { username: this.author.username })
+ ? sprintf(__("%{username}'s avatar"), { username: this.author.username })
: null;
},
commit() {
@@ -56,8 +56,8 @@ export default {
<div class="card-body">
<h2 class="card-title mt-0">
{{ release.name }}
- <gl-badge v-if="release.pre_release" variant="warning" class="align-middle">{{
- __('Pre-release')
+ <gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
+ __('Upcoming Release')
}}</gl-badge>
</h2>
@@ -74,7 +74,7 @@ export default {
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
{{ releasedTimeAgo }}
</span>
</div>
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 01a30809e1a..2be9c37b00a 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -1,6 +1,6 @@
<script>
import IssueStatusIcon from '~/reports/components/issue_status_icon.vue';
-import { components, componentNames } from '~/reports/components/issue_body';
+import { components, componentNames } from 'ee_else_ce/reports/components/issue_body';
export default {
name: 'ReportItem',
diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index 0d4d431855c..67963dc1923 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -36,7 +36,7 @@ export default {
to: `/tree/${this.ref}${path}`,
});
},
- [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}` }],
+ [{ name: this.projectShortPath, path: '/', to: `/tree/${this.ref}/` }],
);
},
},
diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue
index f25cee9bb57..26493556063 100644
--- a/app/assets/javascripts/repository/components/last_commit.vue
+++ b/app/assets/javascripts/repository/components/last_commit.vue
@@ -1,10 +1,9 @@
<script>
-import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import Icon from '../../vue_shared/components/icon.vue';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
-import CommitPipelineStatus from '../../projects/tree/components/commit_pipeline_status_component.vue';
import CiIcon from '../../vue_shared/components/ci_icon.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import getRefMixin from '../mixins/get_ref';
@@ -16,11 +15,11 @@ export default {
Icon,
UserAvatarLink,
TimeagoTooltip,
- CommitPipelineStatus,
ClipboardButton,
CiIcon,
GlLink,
GlButton,
+ GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -39,7 +38,10 @@ export default {
path: this.currentPath.replace(/^\//, ''),
};
},
- update: data => data.project.repository.tree.commit,
+ update: data => data.project.repository.tree.lastCommit,
+ context: {
+ isSingleRequest: true,
+ },
},
},
props: {
@@ -59,14 +61,14 @@ export default {
computed: {
statusTitle() {
return sprintf(s__('Commits|Commit: %{commitText}'), {
- commitText: this.commit.pipeline.detailedStatus.text,
+ commitText: this.commit.latestPipeline.detailedStatus.text,
});
},
isLoading() {
return this.$apollo.queries.commit.loading;
},
showCommitId() {
- return this.commit.id.substr(0, 8);
+ return this.commit.sha.substr(0, 8);
},
},
methods: {
@@ -78,68 +80,75 @@ export default {
</script>
<template>
- <div v-if="!isLoading" class="info-well d-none d-sm-flex project-last-commit commit p-3">
- <user-avatar-link
- v-if="commit.author"
- :link-href="commit.author.webUrl"
- :img-src="commit.author.avatarUrl"
- :img-size="40"
- class="avatar-cell"
- />
- <div class="commit-detail flex-list">
- <div class="commit-content qa-commit-content">
- <gl-link :href="commit.webUrl" class="commit-row-message item-title">
- {{ commit.title }}
- </gl-link>
- <gl-button
- v-if="commit.description"
- :class="{ open: showDescription }"
- :aria-label="__('Show commit description')"
- class="text-expander"
- @click="toggleShowDescription"
- >
- <icon name="ellipsis_h" />
- </gl-button>
- <div class="committer">
+ <div class="info-well d-none d-sm-flex project-last-commit commit p-3">
+ <gl-loading-icon v-if="isLoading" size="md" class="mx-auto" />
+ <template v-else>
+ <user-avatar-link
+ v-if="commit.author"
+ :link-href="commit.author.webUrl"
+ :img-src="commit.author.avatarUrl"
+ :img-size="40"
+ class="avatar-cell"
+ />
+ <div class="commit-detail flex-list">
+ <div class="commit-content qa-commit-content">
+ <gl-link :href="commit.webUrl" class="commit-row-message item-title">
+ {{ commit.title }}
+ </gl-link>
+ <gl-button
+ v-if="commit.description"
+ :class="{ open: showDescription }"
+ :aria-label="__('Show commit description')"
+ class="text-expander"
+ @click="toggleShowDescription"
+ >
+ <icon name="ellipsis_h" />
+ </gl-button>
+ <div class="committer">
+ <gl-link
+ v-if="commit.author"
+ :href="commit.author.webUrl"
+ class="commit-author-link js-user-link"
+ >
+ {{ commit.author.name }}
+ </gl-link>
+ authored
+ <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" />
+ </div>
+ <pre
+ v-if="commit.description"
+ v-show="showDescription"
+ class="commit-row-description append-bottom-8"
+ >
+ {{ commit.description }}
+ </pre>
+ </div>
+ <div class="commit-actions flex-row">
<gl-link
- v-if="commit.author"
- :href="commit.author.webUrl"
- class="commit-author-link js-user-link"
+ v-if="commit.latestPipeline"
+ v-gl-tooltip
+ :href="commit.latestPipeline.detailedStatus.detailsPath"
+ :title="statusTitle"
+ class="js-commit-pipeline"
>
- {{ commit.author.name }}
+ <ci-icon
+ :status="commit.latestPipeline.detailedStatus"
+ :size="24"
+ :aria-label="statusTitle"
+ />
</gl-link>
- authored
- <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" />
- </div>
- <pre
- v-if="commit.description"
- v-show="showDescription"
- class="commit-row-description append-bottom-8"
- >
- {{ commit.description }}
- </pre>
- </div>
- <div class="commit-actions flex-row">
- <gl-link
- v-if="commit.pipeline"
- v-gl-tooltip
- :href="commit.pipeline.detailedStatus.detailsPath"
- :title="statusTitle"
- class="js-commit-pipeline"
- >
- <ci-icon :status="commit.pipeline.detailedStatus" :size="24" :aria-label="statusTitle" />
- </gl-link>
- <div class="commit-sha-group d-flex">
- <div class="label label-monospace monospace">
- {{ showCommitId }}
+ <div class="commit-sha-group d-flex">
+ <div class="label label-monospace monospace">
+ {{ showCommitId }}
+ </div>
+ <clipboard-button
+ :text="commit.sha"
+ :title="__('Copy commit SHA to clipboard')"
+ tooltip-placement="bottom"
+ />
</div>
- <clipboard-button
- :text="commit.id"
- :title="__('Copy commit SHA to clipboard')"
- tooltip-placement="bottom"
- />
</div>
</div>
- </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
index 891e3fe9d16..1e66ccbfa29 100644
--- a/app/assets/javascripts/repository/components/table/index.vue
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -131,7 +131,9 @@ export default {
v-for="entry in val"
:id="entry.id"
:key="`${entry.flatPath}-${entry.id}`"
+ :project-path="projectPath"
:current-path="path"
+ :name="entry.name"
:path="entry.flatPath"
:type="entry.type"
:url="entry.webUrl"
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
index 4519f82fc93..3e060e9ecb6 100644
--- a/app/assets/javascripts/repository/components/table/row.vue
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -1,12 +1,30 @@
<script>
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { getIconName } from '../../utils/icon';
import getRefMixin from '../../mixins/get_ref';
+import getCommit from '../../queries/getCommit.query.graphql';
export default {
components: {
GlBadge,
+ GlLink,
+ GlSkeletonLoading,
+ TimeagoTooltip,
+ },
+ apollo: {
+ commit: {
+ query: getCommit,
+ variables() {
+ return {
+ fileName: this.name,
+ type: this.type,
+ path: this.currentPath,
+ projectPath: this.projectPath,
+ };
+ },
+ },
},
mixins: [getRefMixin],
props: {
@@ -14,10 +32,18 @@ export default {
type: String,
required: true,
},
+ projectPath: {
+ type: String,
+ required: true,
+ },
currentPath: {
type: String,
required: true,
},
+ name: {
+ type: String,
+ required: true,
+ },
path: {
type: String,
required: true,
@@ -37,6 +63,11 @@ export default {
default: null,
},
},
+ data() {
+ return {
+ commit: null,
+ };
+ },
computed: {
routerLinkTo() {
return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null;
@@ -73,20 +104,26 @@ export default {
</script>
<template>
- <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow">
+ <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>
<component :is="linkComponent" :to="routerLinkTo" :href="url" class="str-truncated">
{{ fullPath }}
</component>
- <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">
- LFS
- </gl-badge>
+ <gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge>
<template v-if="isSubmodule">
- @ <a href="#" class="commit-sha">{{ shortSha }}</a>
+ @ <gl-link href="#" class="commit-sha">{{ shortSha }}</gl-link>
</template>
</td>
- <td class="d-none d-sm-table-cell tree-commit"></td>
- <td class="tree-time-ago text-right"></td>
+ <td class="d-none d-sm-table-cell tree-commit">
+ <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link">
+ {{ commit.message }}
+ </gl-link>
+ <gl-skeleton-loading v-else :lines="1" class="h-auto" />
+ </td>
+ <td class="tree-time-ago text-right">
+ <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" />
+ <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" />
+ </td>
</tr>
</template>
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
index ef147ec15cb..6cb253c8169 100644
--- a/app/assets/javascripts/repository/graphql.js
+++ b/app/assets/javascripts/repository/graphql.js
@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
+import { fetchLogsTree } from './log_tree';
Vue.use(VueApollo);
@@ -13,7 +14,21 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
});
const defaultClient = createDefaultClient(
- {},
+ {
+ Query: {
+ commit(_, { path, fileName, type }) {
+ return new Promise(resolve => {
+ fetchLogsTree(defaultClient, path, '0', {
+ resolve,
+ entry: {
+ name: fileName,
+ type,
+ },
+ });
+ });
+ },
+ },
+ },
{
cacheConfig: {
fragmentMatcher,
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index d9216e88676..ea051eaa414 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -16,6 +16,7 @@ export default function setupVueRepositoryList() {
projectPath,
projectShortPath,
ref,
+ commits: [],
},
});
@@ -49,23 +50,19 @@ export default function setupVueRepositoryList() {
},
});
- const commitEl = document.getElementById('js-last-commit');
-
- if (commitEl) {
- // eslint-disable-next-line no-new
- new Vue({
- el: commitEl,
- router,
- apolloProvider,
- render(h) {
- return h(LastCommit, {
- props: {
- currentPath: this.$route.params.pathMatch,
- },
- });
- },
- });
- }
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: document.getElementById('js-last-commit'),
+ router,
+ apolloProvider,
+ render(h) {
+ return h(LastCommit, {
+ props: {
+ currentPath: this.$route.params.pathMatch,
+ },
+ });
+ },
+ });
return new Vue({
el,
diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js
new file mode 100644
index 00000000000..2c19aca2397
--- /dev/null
+++ b/app/assets/javascripts/repository/log_tree.js
@@ -0,0 +1,64 @@
+import axios from '~/lib/utils/axios_utils';
+import getCommits from './queries/getCommits.query.graphql';
+import getProjectPath from './queries/getProjectPath.query.graphql';
+import getRef from './queries/getRef.query.graphql';
+
+let fetchpromise;
+let resolvers = [];
+
+export function normalizeData(data) {
+ return data.map(d => ({
+ sha: d.commit.id,
+ message: d.commit.message,
+ committedDate: d.commit.committed_date,
+ commitPath: d.commit_path,
+ fileName: d.file_name,
+ type: d.type,
+ __typename: 'LogTreeCommit',
+ }));
+}
+
+export function resolveCommit(commits, { resolve, entry }) {
+ const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type);
+
+ if (commit) {
+ resolve(commit);
+ }
+}
+
+export function fetchLogsTree(client, path, offset, resolver = null) {
+ if (resolver) {
+ resolvers.push(resolver);
+ }
+
+ if (fetchpromise) return fetchpromise;
+
+ const { projectPath } = client.readQuery({ query: getProjectPath });
+ const { ref } = client.readQuery({ query: getRef });
+
+ fetchpromise = axios
+ .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, {
+ params: { format: 'json', offset },
+ })
+ .then(({ data, headers }) => {
+ const headerLogsOffset = headers['more-logs-offset'];
+ const { commits } = client.readQuery({ query: getCommits });
+ const newCommitData = [...commits, ...normalizeData(data)];
+ client.writeQuery({
+ query: getCommits,
+ data: { commits: newCommitData },
+ });
+
+ resolvers.forEach(r => resolveCommit(newCommitData, r));
+
+ fetchpromise = null;
+
+ if (headerLogsOffset) {
+ fetchLogsTree(client, path, headerLogsOffset);
+ } else {
+ resolvers = [];
+ }
+ });
+
+ return fetchpromise;
+}
diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql
new file mode 100644
index 00000000000..e2a2d831e47
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql
@@ -0,0 +1,10 @@
+query getCommit($fileName: String!, $type: String!, $path: String!) {
+ commit(path: $path, fileName: $fileName, type: $type) @client {
+ sha
+ message
+ committedDate
+ commitPath
+ fileName
+ type
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql
new file mode 100644
index 00000000000..df9e67cc440
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql
@@ -0,0 +1,10 @@
+query getCommits {
+ commits @client {
+ sha
+ message
+ committedDate
+ commitPath
+ fileName
+ type
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql
index ef924fde556..4c24fc4087f 100644
--- a/app/assets/javascripts/repository/queries/getFiles.query.graphql
+++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql
@@ -1,5 +1,6 @@
fragment TreeEntry on Entry {
id
+ name
flatPath
type
}
diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
index 90901f54d54..3bdfd979fa4 100644
--- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
+++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql
@@ -2,8 +2,8 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
project(fullPath: $projectPath) {
repository {
tree(path: $path, ref: $ref) {
- commit {
- id
+ lastCommit {
+ sha
title
message
webUrl
@@ -13,7 +13,7 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
avatarUrl
webUrl
}
- pipeline {
+ latestPipeline {
detailedStatus {
detailsPath
icon
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
index 32c9d6eccb8..a1a8cd3acbd 100644
--- a/app/assets/javascripts/serverless/components/area.vue
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -4,6 +4,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import dateFormat from 'dateformat';
import { X_INTERVAL } from '../constants';
import { validateGraphData } from '../utils';
+import { __ } from '~/locale';
let debouncedResize;
@@ -42,7 +43,7 @@ export default {
},
generateSeries() {
return {
- name: 'Invocations',
+ name: __('Invocations'),
type: 'line',
data: this.chartData.requests.map(data => [data.time, data.value]),
symbolSize: 0,
@@ -124,7 +125,9 @@ export default {
<div class="prometheus-graph">
<div class="prometheus-graph-header">
<h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ <div ref="graphWidgets" class="prometheus-graph-widgets">
+ <slot></slot>
+ </div>
</div>
<gl-area-chart
ref="areaChart"
@@ -135,12 +138,8 @@ export default {
:width="width"
:include-legend-avg-max="false"
>
- <template slot="tooltipTitle">
- {{ tooltipPopoverTitle }}
- </template>
- <template slot="tooltipContent">
- {{ tooltipPopoverContent }}
- </template>
+ <template slot="tooltipTitle">{{ tooltipPopoverTitle }}</template>
+ <template slot="tooltipContent">{{ tooltipPopoverContent }}</template>
</gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
index b8906cfca4e..d542dad8119 100644
--- a/app/assets/javascripts/serverless/components/function_details.vue
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -89,7 +89,9 @@ export default {
}}
</p>
</div>
- <div v-else><p>No pods loaded at this time.</p></div>
+ <div v-else>
+ <p>{{ s__('ServerlessDetails|No pods loaded at this time.') }}</p>
+ </div>
<area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
<missing-prometheus
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 94341050b86..9e66869515c 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,4 +1,5 @@
<script>
+import { sprintf, s__ } from '~/locale';
import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
@@ -37,6 +38,28 @@ export default {
isInstalled() {
return this.installed === true;
},
+ noServerlessConfigFile() {
+ return sprintf(
+ s__(
+ 'Serverless|Your repository does not have a corresponding %{startTag}serverless.yml%{endTag} file.',
+ ),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
+ noGitlabYamlConfigured() {
+ return sprintf(
+ s__('Serverless|Your %{startTag}.gitlab-ci.yml%{endTag} file is not properly configured.'),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
+ mismatchedServerlessFunctions() {
+ return sprintf(
+ s__(
+ "Serverless|The functions listed in the %{startTag}serverless.yml%{endTag} file don't match the namespace of your cluster.",
+ ),
+ { startTag: '<code>', endTag: '</code>' },
+ );
+ },
},
created() {
this.fetchFunctions({
@@ -82,25 +105,29 @@ export default {
<h4 class="state-title text-center">{{ s__('Serverless|No functions available') }}</h4>
<p class="state-description">
{{
- s__(`Serverless|There is currently no function data available from Knative.
- This could be for a variety of reasons including:`)
+ s__(
+ 'Serverless|There is currently no function data available from Knative. This could be for a variety of reasons including:',
+ )
}}
</p>
<ul>
- <li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li>
- <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li>
<li>
- The functions listed in the <code>serverless.yml</code> file don't match the namespace
- of your cluster.
+ {{ noServerlessConfigFile }}
+ </li>
+ <li>
+ {{ noGitlabYamlConfigured }}
+ </li>
+ <li>
+ {{ mismatchedServerlessFunctions }}
</li>
- <li>The deploy job has not finished.</li>
+ <li>{{ s__('Serverless|The deploy job has not finished.') }}</li>
</ul>
<p>
{{
- s__(`Serverless|If you believe none of these apply, please check
- back later as the function data may be in the process of becoming
- available.`)
+ s__(
+ 'Serverless|If you believe none of these apply, please check back later as the function data may be in the process of becoming available.',
+ )
}}
</p>
<div class="text-center">
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
index f4d926cd3ec..bc263bc36e4 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
@@ -28,11 +28,16 @@ export default {
type: String,
required: true,
},
+ limitToHours: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
parsedTimeRemaining() {
const diffSeconds = this.timeEstimate - this.timeSpent;
- return parseSeconds(diffSeconds);
+ return parseSeconds(diffSeconds, { limitToHours: this.limitToHours });
},
timeRemainingHumanReadable() {
return stringifyTime(this.parsedTimeRemaining);
@@ -65,9 +70,6 @@ export default {
:title="timeRemainingTooltip"
:class="timeRemainingStatusClass"
class="compare-meter"
- data-toggle="tooltip"
- data-placement="top"
- role="timeRemainingDisplay"
>
<gl-progress-bar :value="timeRemainingPercent" :variant="progressBarVariant" />
<div class="compare-display-container">
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
index 8e8b9f19b6e..018b30d2a67 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.vue
@@ -53,6 +53,7 @@ export default {
:time-spent="store.totalTimeSpent"
:human-time-estimate="store.humanTimeEstimate"
:human-time-spent="store.humanTotalTimeSpent"
+ :limit-to-hours="store.timeTrackingLimitToHours"
:root-path="store.rootPath"
/>
</div>
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index d84d5344935..682ca600b6a 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -37,6 +37,10 @@ export default {
required: false,
default: '',
},
+ limitToHours: {
+ type: Boolean,
+ default: false,
+ },
rootPath: {
type: String,
required: true,
@@ -129,6 +133,7 @@ export default {
:time-spent="timeSpent"
:time-spent-human-readable="humanTimeSpent"
:time-estimate-human-readable="humanTimeEstimate"
+ :limit-to-hours="limitToHours"
/>
<transition name="help-state-toggle">
<time-tracking-help-state v-if="showHelpState" :root-path="rootPath" />
diff --git a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
index 1ebdbec7bc9..d934463382f 100644
--- a/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
+++ b/app/assets/javascripts/sidebar/mount_milestone_sidebar.js
@@ -1,5 +1,6 @@
import Vue from 'vue';
import timeTracker from './components/time_tracking/time_tracker.vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
export default class SidebarMilestone {
constructor() {
@@ -7,7 +8,7 @@ export default class SidebarMilestone {
if (!el) return;
- const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent } = el.dataset;
+ const { timeEstimate, timeSpent, humanTimeEstimate, humanTimeSpent, limitToHours } = el.dataset;
// eslint-disable-next-line no-new
new Vue({
@@ -22,6 +23,7 @@ export default class SidebarMilestone {
timeSpent: parseInt(timeSpent, 10),
humanTimeEstimate,
humanTimeSpent,
+ limitToHours: parseBoolean(limitToHours),
rootPath: '/',
},
}),
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 22ac8df9699..643fe6c00b6 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -1,7 +1,7 @@
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import Service from './services/sidebar_service';
-import Store from './stores/sidebar_store';
+import Store from 'ee_else_ce/sidebar/stores/sidebar_store';
import { __ } from '~/locale';
export default class SidebarMediator {
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index 7b8b4c5d856..63c4a2a3f84 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -8,7 +8,7 @@ export default class SidebarStore {
}
initSingleton(options) {
- const { currentUser, rootPath, editable } = options;
+ const { currentUser, rootPath, editable, timeTrackingLimitToHours } = options;
this.currentUser = currentUser;
this.rootPath = rootPath;
this.editable = editable;
@@ -16,6 +16,7 @@ export default class SidebarStore {
this.totalTimeSpent = 0;
this.humanTimeEstimate = '';
this.humanTimeSpent = '';
+ this.timeTrackingLimitToHours = timeTrackingLimitToHours;
this.assignees = [];
this.isFetching = {
assignees: true,
diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js
index 2fec96d1435..04bfb5e9532 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/comment.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/comment.js
@@ -1,54 +1,62 @@
import { BLACK, COMMENT_BOX, MUTED, LOGOUT } from './constants';
-import { clearNote, note, postError } from './note';
-import { buttonClearStyles, selectCommentBox, selectCommentButton, selectNote } from './utils';
+import { clearNote, postError } from './note';
+import {
+ buttonClearStyles,
+ selectCommentBox,
+ selectCommentButton,
+ selectNote,
+ selectNoteContainer,
+} from './utils';
const comment = `
<div>
<textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true"></textarea>
- ${note}
<p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p>
</div>
<div class="gitlab-button-wrapper">
- <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Logout </button>
+ <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button>
<button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button>
</div>
`;
-const resetCommentBox = () => {
- const commentBox = selectCommentBox();
+const resetCommentButton = () => {
const commentButton = selectCommentButton();
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
commentButton.innerText = 'Send feedback';
commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success');
commentButton.style.opacity = 1;
+};
+const resetCommentBox = () => {
+ const commentBox = selectCommentBox();
commentBox.style.pointerEvents = 'auto';
commentBox.style.color = BLACK;
};
-const resetCommentButton = () => {
+const resetCommentText = () => {
const commentBox = selectCommentBox();
- const currentNote = selectNote();
-
commentBox.value = '';
- currentNote.innerText = '';
};
const resetComment = () => {
- resetCommentBox();
resetCommentButton();
+ resetCommentBox();
+ resetCommentText();
};
-const confirmAndClear = mergeRequestId => {
+const confirmAndClear = feedbackInfo => {
const commentButton = selectCommentButton();
const currentNote = selectNote();
+ const noteContainer = selectNoteContainer();
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
commentButton.innerText = 'Feedback sent';
- /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
- currentNote.innerText = `Your comment was successfully posted to merge request #${mergeRequestId}`;
- setTimeout(resetComment, 2000);
+ noteContainer.style.visibility = 'visible';
+ currentNote.insertAdjacentHTML('beforeend', feedbackInfo);
+
+ setTimeout(resetComment, 1000);
+ setTimeout(clearNote, 6000);
};
const setInProgressState = () => {
@@ -71,6 +79,7 @@ const postComment = ({
innerWidth,
innerHeight,
projectId,
+ projectPath,
mergeRequestId,
mrUrl,
token,
@@ -86,6 +95,7 @@ const postComment = ({
/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */
postError('Your comment appears to be empty.', COMMENT_BOX);
resetCommentBox();
+ resetCommentButton();
return;
}
@@ -114,18 +124,24 @@ const postComment = ({
})
.then(response => {
if (response.ok) {
- confirmAndClear(mergeRequestId);
- return;
+ return response.json();
}
throw new Error(`${response.status}: ${response.statusText}`);
})
+ .then(data => {
+ const commentId = data.notes[0].id;
+ const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`;
+ const feedbackInfo = `Feedback sent. View at <a class="gitlab-link" href="${feedbackLink}">${projectPath} #${mergeRequestId} (comment ${commentId})</a>`;
+ confirmAndClear(feedbackInfo);
+ })
.catch(err => {
postError(
`Your comment could not be sent. Please try again. Error: ${err.message}`,
COMMENT_BOX,
);
resetCommentBox();
+ resetCommentButton();
});
};
diff --git a/app/assets/javascripts/visual_review_toolbar/components/constants.js b/app/assets/javascripts/visual_review_toolbar/components/constants.js
index 32ed1153515..07fcb179d15 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/constants.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/constants.js
@@ -2,10 +2,12 @@
const COLLAPSE_BUTTON = 'gitlab-collapse';
const COMMENT_BOX = 'gitlab-comment';
const COMMENT_BUTTON = 'gitlab-comment-button';
-const FORM = 'gitlab-form-wrapper';
+const FORM = 'gitlab-form';
+const FORM_CONTAINER = 'gitlab-form-wrapper';
const LOGIN = 'gitlab-login';
const LOGOUT = 'gitlab-logout-button';
const NOTE = 'gitlab-validation-note';
+const NOTE_CONTAINER = 'gitlab-note-wrapper';
const REMEMBER_TOKEN = 'gitlab-remember_token';
const REVIEW_CONTAINER = 'gitlab-review-container';
const TOKEN_BOX = 'gitlab-token';
@@ -16,16 +18,18 @@ const BLACK = 'rgba(46, 46, 46, 1)';
const CLEAR = 'rgba(255, 255, 255, 0)';
const MUTED = 'rgba(223, 223, 223, 0.5)';
const RED = 'rgba(219, 59, 33, 1)';
-const WHITE = 'rgba(255, 255, 255, 1)';
+const WHITE = 'rgba(250, 250, 250, 1)';
export {
COLLAPSE_BUTTON,
COMMENT_BOX,
COMMENT_BUTTON,
FORM,
+ FORM_CONTAINER,
LOGIN,
LOGOUT,
NOTE,
+ NOTE_CONTAINER,
REMEMBER_TOKEN,
REVIEW_CONTAINER,
TOKEN_BOX,
diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js
index 43581818152..50b52d7d3a2 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/index.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/index.js
@@ -1,22 +1,32 @@
import { comment, postComment } from './comment';
-import { COLLAPSE_BUTTON, COMMENT_BUTTON, LOGIN, LOGOUT, REVIEW_CONTAINER } from './constants';
+import {
+ COLLAPSE_BUTTON,
+ COMMENT_BUTTON,
+ FORM_CONTAINER,
+ LOGIN,
+ LOGOUT,
+ REVIEW_CONTAINER,
+} from './constants';
import { authorizeUser, login } from './login';
+import { note } from './note';
import { selectContainer } from './utils';
-import { form, logoutUser, toggleForm } from './wrapper';
+import { buttonAndForm, logoutUser, toggleForm } from './wrapper';
import { collapseButton } from './wrapper_icons';
export {
authorizeUser,
+ buttonAndForm,
collapseButton,
comment,
- form,
login,
logoutUser,
+ note,
postComment,
selectContainer,
toggleForm,
COLLAPSE_BUTTON,
COMMENT_BUTTON,
+ FORM_CONTAINER,
LOGIN,
LOGOUT,
REVIEW_CONTAINER,
diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js
index ce713cdc520..0a71299f041 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/login.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/login.js
@@ -1,5 +1,5 @@
import { LOGIN, REMEMBER_TOKEN, TOKEN_BOX } from './constants';
-import { clearNote, note, postError } from './note';
+import { clearNote, postError } from './note';
import { buttonClearStyles, selectRemember, selectToken } from './utils';
import { addCommentForm } from './wrapper';
@@ -7,7 +7,6 @@ const login = `
<div>
<label for="${TOKEN_BOX}" class="gitlab-label">Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a></label>
<input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" aria-required="true" autocomplete="current-password">
- ${note}
</div>
<div class="gitlab-checkbox-wrapper">
<input type="checkbox" id="${REMEMBER_TOKEN}" name="${REMEMBER_TOKEN}" value="remember">
diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js
index dfebf58fd95..0150f640aae 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/note.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/note.js
@@ -1,14 +1,19 @@
-import { NOTE, RED } from './constants';
-import { selectById, selectNote } from './utils';
+import { NOTE, NOTE_CONTAINER, RED } from './constants';
+import { selectById, selectNote, selectNoteContainer } from './utils';
const note = `
- <p id=${NOTE} class='gitlab-message'></p>
+ <div id="${NOTE_CONTAINER}" style="visibility: hidden;">
+ <p id="${NOTE}" class="gitlab-message"></p>
+ </div>
`;
const clearNote = inputId => {
const currentNote = selectNote();
+ const noteContainer = selectNoteContainer();
+
currentNote.innerText = '';
currentNote.style.color = '';
+ noteContainer.style.visibility = 'hidden';
if (inputId) {
const field = document.getElementById(inputId);
@@ -18,10 +23,13 @@ const clearNote = inputId => {
const postError = (message, inputId) => {
const currentNote = selectNote();
+ const noteContainer = selectNoteContainer();
const field = selectById(inputId);
field.style.borderColor = RED;
currentNote.style.color = RED;
currentNote.innerText = message;
+ noteContainer.style.visibility = 'visible';
+ setTimeout(clearNote.bind(null, inputId), 5000);
};
export { clearNote, note, postError };
diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js
index 7bc2e5a905b..00f4460925d 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/utils.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/utils.js
@@ -5,7 +5,9 @@ import {
COMMENT_BOX,
COMMENT_BUTTON,
FORM,
+ FORM_CONTAINER,
NOTE,
+ NOTE_CONTAINER,
REMEMBER_TOKEN,
REVIEW_CONTAINER,
TOKEN_BOX,
@@ -24,7 +26,9 @@ const selectCommentBox = () => document.getElementById(COMMENT_BOX);
const selectCommentButton = () => document.getElementById(COMMENT_BUTTON);
const selectContainer = () => document.getElementById(REVIEW_CONTAINER);
const selectForm = () => document.getElementById(FORM);
+const selectFormContainer = () => document.getElementById(FORM_CONTAINER);
const selectNote = () => document.getElementById(NOTE);
+const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER);
const selectRemember = () => document.getElementById(REMEMBER_TOKEN);
const selectToken = () => document.getElementById(TOKEN_BOX);
@@ -36,7 +40,9 @@ export {
selectCommentBox,
selectCommentButton,
selectForm,
+ selectFormContainer,
selectNote,
+ selectNoteContainer,
selectRemember,
selectToken,
};
diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js
index 233b7ec496c..f2eaf1d7916 100644
--- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js
+++ b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js
@@ -1,15 +1,28 @@
import { comment } from './comment';
-import { CLEAR, FORM, WHITE } from './constants';
+import { CLEAR, FORM, FORM_CONTAINER, WHITE } from './constants';
import { login } from './login';
-import { selectCollapseButton, selectContainer, selectForm } from './utils';
+import { clearNote } from './note';
+import {
+ selectCollapseButton,
+ selectForm,
+ selectFormContainer,
+ selectNoteContainer,
+} from './utils';
import { commentIcon, compressIcon } from './wrapper_icons';
const form = content => `
- <form id=${FORM}>
+ <form id="${FORM}">
${content}
</form>
`;
+const buttonAndForm = ({ content, toggleButton }) => `
+ <div id="${FORM_CONTAINER}" class="gitlab-form-open">
+ ${toggleButton}
+ ${form(content)}
+ </div>
+`;
+
const addCommentForm = () => {
const formWrapper = selectForm();
formWrapper.innerHTML = comment;
@@ -31,13 +44,15 @@ function logoutUser() {
return;
}
+ clearNote();
addLoginForm();
}
function toggleForm() {
- const container = selectContainer();
const collapseButton = selectCollapseButton();
const currentForm = selectForm();
+ const formContainer = selectFormContainer();
+ const noteContainer = selectNoteContainer();
const OPEN = 'open';
const CLOSED = 'closed';
@@ -49,7 +64,7 @@ function toggleForm() {
const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open'];
const closedButtonClasses = [...openButtonClasses].reverse();
- const openContainerClasses = ['gitlab-closed-wrapper', 'gitlab-open-wrapper'];
+ const openContainerClasses = ['gitlab-wrapper-closed', 'gitlab-wrapper-open'];
const closedContainerClasses = [...openContainerClasses].reverse();
const stateVals = {
@@ -72,11 +87,16 @@ function toggleForm() {
const nextState = collapseButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN;
const currentVals = stateVals[nextState];
- container.classList.replace(...currentVals.containerClasses);
- container.style.backgroundColor = currentVals.backgroundColor;
+ formContainer.classList.replace(...currentVals.containerClasses);
+ formContainer.style.backgroundColor = currentVals.backgroundColor;
+ formContainer.classList.toggle('gitlab-form-open');
currentForm.style.display = currentVals.display;
collapseButton.classList.replace(...currentVals.buttonClasses);
collapseButton.innerHTML = currentVals.icon;
+
+ if (noteContainer && noteContainer.innerText.length > 0) {
+ noteContainer.style.display = currentVals.display;
+ }
}
-export { addCommentForm, addLoginForm, form, logoutUser, toggleForm };
+export { addCommentForm, addLoginForm, buttonAndForm, logoutUser, toggleForm };
diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js
index 941d77e25b4..f94eb88835a 100644
--- a/app/assets/javascripts/visual_review_toolbar/index.js
+++ b/app/assets/javascripts/visual_review_toolbar/index.js
@@ -1,6 +1,6 @@
import './styles/toolbar.css';
-import { form, selectContainer, REVIEW_CONTAINER } from './components';
+import { buttonAndForm, note, selectContainer, REVIEW_CONTAINER } from './components';
import { debounce, eventLookup, getInitialView, initializeState, updateWindowSize } from './store';
/*
@@ -20,12 +20,11 @@ import { debounce, eventLookup, getInitialView, initializeState, updateWindowSiz
window.addEventListener('load', () => {
initializeState(window, document);
- const { content, toggleButton } = getInitialView(window);
+ const mainContent = buttonAndForm(getInitialView(window));
const container = document.createElement('div');
-
container.setAttribute('id', REVIEW_CONTAINER);
- container.insertAdjacentHTML('beforeend', toggleButton);
- container.insertAdjacentHTML('beforeend', form(content));
+ container.insertAdjacentHTML('beforeend', note);
+ container.insertAdjacentHTML('beforeend', mainContent);
document.body.insertBefore(container, document.body.firstChild);
diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js
index f5ede6e85b2..22702d524b8 100644
--- a/app/assets/javascripts/visual_review_toolbar/store/state.js
+++ b/app/assets/javascripts/visual_review_toolbar/store/state.js
@@ -34,7 +34,7 @@ const initializeState = (wind, doc) => {
const browser = getBrowserId(userAgent);
const scriptEl = doc.getElementById('review-app-toolbar-script');
- const { projectId, mergeRequestId, mrUrl } = scriptEl.dataset;
+ const { projectId, mergeRequestId, mrUrl, projectPath } = scriptEl.dataset;
// This mutates our default state object above. It's weird but it makes the linter happy.
Object.assign(state, {
@@ -46,6 +46,7 @@ const initializeState = (wind, doc) => {
mrUrl,
platform,
projectId,
+ projectPath,
userAgent,
});
};
diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
index 342b3599a44..00a55c0027a 100644
--- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
+++ b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css
@@ -6,23 +6,42 @@
pointer-events: none;
}
-#gitlab-form-wrapper {
+#gitlab-comment {
+ background-color: #fafafa;
+}
+
+#gitlab-form {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ margin-bottom: 0;
+}
+
+#gitlab-note-wrapper {
display: flex;
flex-direction: column;
- width: 100%
+ background-color: #fafafa;
+ border-radius: 4px;
+ margin-bottom: .5rem;
+ padding: 1rem;
+}
+
+#gitlab-form-wrapper {
+ overflow: auto;
+ display: flex;
+ flex-direction: row-reverse;
+ border-radius: 4px;
}
#gitlab-review-container {
max-width: 22rem;
max-height: 22rem;
- overflow: scroll;
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
position: fixed;
bottom: 1rem;
right: 1rem;
- display: flex;
- flex-direction: row-reverse;
- padding: 1rem;
- background-color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
@@ -31,12 +50,12 @@
color: #2e2e2e;
}
-.gitlab-open-wrapper {
+.gitlab-wrapper-open {
max-width: 22rem;
max-height: 22rem;
}
-.gitlab-closed-wrapper {
+.gitlab-wrapper-closed {
max-width: 3.4rem;
max-height: 3.4rem;
}
@@ -47,7 +66,7 @@
}
.gitlab-button-secondary {
- background: none #fff;
+ background: none #fafafa;
margin: 0 .5rem;
border: 1px solid #e3e3e3;
}
@@ -113,6 +132,11 @@
align-items: baseline;
}
+.gitlab-form-open {
+ padding: 1rem;
+ background-color: #fafafa;
+}
+
.gitlab-label {
font-weight: 600;
display: inline-block;
@@ -126,6 +150,10 @@
background-image: none;
}
+.gitlab-link:hover {
+ text-decoration: underline;
+}
+
.gitlab-message {
padding: .25rem 0;
margin: 0;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
index a9fb40a4949..d4a5fdb4b97 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue
@@ -20,7 +20,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- {{ s__('mrWidget|There are unresolved discussions. Please resolve these discussions') }}
+ {{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }}
</span>
<a
v-if="mr.createIssueToResolveDiscussionsPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 41386178a1e..a79da476890 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -162,7 +162,8 @@ export default {
removeWIPPath: store.removeWIPPath,
sourceBranchPath: store.sourceBranchPath,
ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath,
- statusPath: store.statusPath,
+ mergeRequestBasicPath: store.mergeRequestBasicPath,
+ mergeRequestWidgetPath: store.mergeRequestWidgetPath,
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
};
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 0bb70bfd658..1dae53039d5 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -30,11 +30,11 @@ export default class MRWidgetService {
}
poll() {
- return axios.get(`${this.endpoints.statusPath}?serializer=basic`);
+ return axios.get(this.endpoints.mergeRequestBasicPath);
}
checkStatus() {
- return axios.get(`${this.endpoints.statusPath}?serializer=widget`);
+ return axios.get(this.endpoints.mergeRequestWidgetPath);
}
fetchMergeActionsContent() {
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index bfa3e7f4a59..581fee7477f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -86,7 +86,8 @@ export default class MergeRequestStore {
this.mergePath = data.merge_path;
this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased);
- this.statusPath = data.status_path;
+ this.mergeRequestBasicPath = data.merge_request_basic_path;
+ this.mergeRequestWidgetPath = data.merge_request_widget_path;
this.emailPatchesPath = data.email_patches_path;
this.plainDiffPath = data.plain_diff_path;
this.newBlobPath = data.new_blob_path;
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 1bfa91500cb..fe5289ff371 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -131,7 +131,7 @@ export default {
</script>
<template>
- <div>
+ <div v-if="!file.moved">
<file-header v-if="file.isHeader" :path="file.path" />
<div
v-else
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
index 05ad7710a62..eb0f666422f 100644
--- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -1,6 +1,6 @@
<script>
import '~/commons/bootstrap';
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlTooltip, GlTooltipDirective } from '@gitlab/ui';
import { sprintf } from '~/locale';
import IssueMilestone from '../../components/issue/issue_milestone.vue';
import IssueAssignees from '../../components/issue/issue_assignees.vue';
@@ -13,6 +13,7 @@ export default {
IssueMilestone,
IssueAssignees,
CiIcon,
+ GlTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -24,11 +25,6 @@ export default {
required: false,
default: false,
},
- greyLinkWhenMerged: {
- type: Boolean,
- required: false,
- default: false,
- },
},
computed: {
stateTitle() {
@@ -41,10 +37,12 @@ export default {
},
);
},
- issueableLinkClass() {
- return this.greyLinkWhenMerged
- ? `sortable-link ${this.state === 'merged' ? ' text-secondary' : ''}`
- : 'sortable-link';
+ heightStyle() {
+ return {
+ minHeight: '32px',
+ width: '0px',
+ visibility: 'hidden',
+ };
},
},
};
@@ -56,20 +54,25 @@ export default {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
- class="item-body d-flex align-items-center p-2 p-lg-3 p-xl-2 pl-xl-3"
+ class="item-body d-flex align-items-center p-2 p-lg-3 py-xl-2 px-xl-3"
>
<div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
- <div class="item-title d-flex align-items-center mb-1 mb-xl-0">
- <icon
- v-if="hasState"
- v-tooltip
- :css-classes="iconClass"
- :name="iconName"
- :size="16"
- :title="stateTitle"
- :aria-label="state"
- data-html="true"
- />
+ <!-- Title area: Status icon (XL) and title -->
+ <div class="item-title d-flex align-items-center mb-xl-0">
+ <span ref="iconElementXL">
+ <icon
+ v-if="hasState"
+ ref="iconElementXL"
+ :css-classes="iconClass"
+ :name="iconName"
+ :size="16"
+ :title="stateTitle"
+ :aria-label="state"
+ />
+ </span>
+ <gl-tooltip :target="() => $refs.iconElementXL">
+ <span v-html="stateTitle"></span>
+ </gl-tooltip>
<icon
v-if="confidential"
v-gl-tooltip
@@ -79,55 +82,81 @@ export default {
class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
:aria-label="__('Confidential')"
/>
- <a :href="computedPath" :class="issueableLinkClass">{{ title }}</a>
+ <a :href="computedPath" class="sortable-link">{{ title }}</a>
</div>
- <div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap">
- <div
- class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto"
- >
- <icon
- v-if="hasState"
- v-tooltip
- :css-classes="iconClass"
- :name="iconName"
- :size="16"
- :title="stateTitle"
- :aria-label="state"
- data-html="true"
- class="d-xl-none"
- />
- <span v-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
- itemPath
- }}</span>
- {{ pathIdSeparator }}{{ itemId }}
- </div>
+
+ <!-- Info area: meta, path, and assignees -->
+ <div class="item-info-area d-flex flex-xl-grow-1 flex-shrink-0">
+ <!-- Meta area: path and attributes -->
+ <!-- If there is no room beside the path, meta attributes are put ABOVE it (flex-wrap-reverse). -->
+ <!-- See design: https://gitlab-org.gitlab.io/gitlab-design/hosted/pedro/%2383-issue-mr-rows-cards-spec-previews/#artboard16 -->
<div
- class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap"
+ class="item-meta d-flex flex-wrap-reverse justify-content-start justify-content-md-between"
>
- <span v-if="hasPipeline" class="mr-ci-status pr-2">
- <a :href="pipelineStatus.details_path">
- <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
- </a>
- </span>
- <issue-milestone
- v-if="hasMilestone"
- :milestone="milestone"
- class="d-flex align-items-center item-milestone"
- />
- <slot name="dueDate"></slot>
- <slot name="weight"></slot>
+ <!-- Path area: status icon (<XL), path, issue # -->
+ <div
+ class="item-path-area item-path-id d-flex align-items-center mr-2 mt-2 mt-xl-0 ml-xl-2"
+ >
+ <span ref="iconElement">
+ <icon
+ v-if="hasState"
+ :css-classes="iconClass"
+ :name="iconName"
+ :title="stateTitle"
+ :aria-label="state"
+ data-html="true"
+ class="d-xl-none"
+ />
+ </span>
+ <gl-tooltip :target="() => this.$refs.iconElement">
+ <span v-html="stateTitle"></span>
+ </gl-tooltip>
+ <span v-gl-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
+ itemPath
+ }}</span>
+ <span>{{ pathIdSeparator }}{{ itemId }}</span>
+ </div>
+
+ <!-- Attributes area: CI, epic count, weight, milestone -->
+ <!-- They have a different order on large screen sizes -->
+ <div class="item-attributes-area d-flex align-items-center mt-2 mt-xl-0">
+ <span v-if="hasPipeline" class="mr-ci-status order-md-last">
+ <a :href="pipelineStatus.details_path">
+ <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
+ </a>
+ </span>
+
+ <issue-milestone
+ v-if="hasMilestone"
+ :milestone="milestone"
+ class="d-flex align-items-center item-milestone order-md-first ml-md-0"
+ />
+
+ <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue -->
+ <slot name="dueDate"></slot>
+ <slot name="weight"></slot>
+
+ <issue-assignees
+ v-if="hasAssignees"
+ :assignees="assignees"
+ class="item-assignees align-items-center align-self-end flex-shrink-0 order-md-2 d-none d-md-flex"
+ />
+ </div>
</div>
+
+ <!-- Assignees. On small layouts, these are put here, at the end of the card. -->
<issue-assignees
- v-if="assignees.length"
+ v-if="assignees.length !== 0"
:assignees="assignees"
- class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1"
+ class="item-assignees d-flex align-items-center align-self-end flex-shrink-0 d-md-none ml-2"
/>
</div>
</div>
+
<button
v-if="canRemove"
ref="removeButton"
- v-tooltip
+ v-gl-tooltip
:disabled="removeDisabled"
type="button"
class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
@@ -137,5 +166,9 @@ export default {
>
<icon :size="16" class="btn-item-remove-icon" name="close" />
</button>
+
+ <!-- This element serves to set the issue card's height at a minimum of 32 px. -->
+ <!-- It fixes #59594: when the remove button is missing, issues have inconsistent heights. -->
+ <span :style="heightStyle"></span>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index baed26a157c..af02b8969ee 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -39,7 +39,7 @@ export default {
</script>
<template>
- <timeline-entry-item class="note being-posted fade-in-half">
+ <timeline-entry-item class="note note-wrapper being-posted fade-in-half">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
index 8e0e4baa75a..3c727cb7b3f 100644
--- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -126,6 +126,9 @@ const mixins = {
hasTitle() {
return this.title.length > 0;
},
+ hasAssignees() {
+ return this.assignees.length > 0;
+ },
hasMilestone() {
return !_.isEmpty(this.milestone);
},
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index a2f518cd24e..d5ef66af31a 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -11,10 +11,10 @@
// like a table or typography then make changes in the framework/ directory.
// If you need to add unique style that should affect only one page - use pages/
// directory.
-@import "../../../node_modules/at.js/dist/css/jquery.atwho";
-@import "../../../node_modules/pikaday/scss/pikaday";
-@import "../../../node_modules/dropzone/dist/basic";
-@import "../../../node_modules/select2/select2";
+@import "at.js/dist/css/jquery.atwho";
+@import "pikaday/scss/pikaday";
+@import "dropzone/dist/basic";
+@import "select2/select2";
// GitLab UI framework
@import "framework";
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
index 7f9cf1266b1..59224d37744 100644
--- a/app/assets/stylesheets/components/related_items_list.scss
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -83,6 +83,20 @@ $item-weight-max-width: 48px;
flex-basis: 100%;
}
+ .item-attributes-area {
+ > * {
+ margin-left: 8px;
+ }
+
+ .board-card-info {
+ margin-right: 0;
+ }
+
+ @include media-breakpoint-down(sm) {
+ margin-left: -8px;
+ }
+ }
+
.item-milestone,
.item-weight {
cursor: help;
@@ -101,39 +115,39 @@ $item-weight-max-width: 48px;
.item-weight {
max-width: $item-weight-max-width;
}
+}
- .item-assignees {
- .user-avatar-link {
- margin-right: -$gl-padding-4;
-
- &:nth-of-type(1) {
- z-index: 2;
- }
+.item-assignees {
+ .user-avatar-link {
+ margin-right: -$gl-padding-4;
- &:nth-of-type(2) {
- z-index: 1;
- }
+ &:nth-of-type(1) {
+ z-index: 2;
+ }
- &:last-child {
- margin-right: 0;
- }
+ &:nth-of-type(2) {
+ z-index: 1;
}
- .avatar {
- height: $gl-padding;
- width: $gl-padding;
+ &:last-child {
margin-right: 0;
- vertical-align: bottom;
}
+ }
- .avatar-counter {
- height: $gl-padding;
- border: 1px solid transparent;
- background-color: $gl-text-color-tertiary;
- font-weight: $gl-font-weight-bold;
- padding: 0 $gl-padding-4;
- line-height: $gl-padding;
- }
+ .avatar {
+ height: $gl-padding;
+ width: $gl-padding;
+ margin-right: 0;
+ vertical-align: bottom;
+ }
+
+ .avatar-counter {
+ height: $gl-padding;
+ border: 1px solid transparent;
+ background-color: $gl-text-color-tertiary;
+ font-weight: $gl-font-weight-bold;
+ padding: 0 $gl-padding-4;
+ line-height: $gl-padding;
}
}
@@ -150,12 +164,6 @@ $item-weight-max-width: 48px;
.issue-token-state-icon-closed {
display: block;
}
-
- @include media-breakpoint-down(sm) {
- &:not(.mr-item-path) {
- order: 1;
- }
- }
}
.btn-item-remove {
@@ -179,6 +187,10 @@ $item-weight-max-width: 48px;
}
@include media-breakpoint-up(sm) {
+ .item-info-area {
+ flex-basis: 100%;
+ }
+
.sortable-link {
max-width: 90%;
}
@@ -241,7 +253,8 @@ $item-weight-max-width: 48px;
.item-title {
min-width: 0;
width: auto;
- flex-basis: unset;
+ flex-basis: auto;
+ flex-shrink: 1;
font-weight: $gl-font-weight-normal;
.issue-token-state-icon-open,
@@ -250,6 +263,10 @@ $item-weight-max-width: 48px;
margin-right: $gl-padding-8;
}
}
+
+ .item-info-area {
+ flex-basis: auto;
+ }
}
.item-contents {
diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss
index 33e1c4e5349..acbd909d595 100644
--- a/app/assets/stylesheets/components/toast.scss
+++ b/app/assets/stylesheets/components/toast.scss
@@ -21,7 +21,7 @@
background-color: rgba($gray-900, $toast-background-opacity);
@include media-breakpoint-down(xs) {
- .action:first-child {
+ .action:first-of-type {
// Ensures actions buttons are right aligned on mobile
margin-left: auto;
}
@@ -33,7 +33,7 @@
text-transform: none;
font-size: $gl-font-size;
- &:first-child {
+ &:first-of-type {
padding-right: 0;
}
}
diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss
index acaa41e2677..87c59cd42c0 100644
--- a/app/assets/stylesheets/csslab.scss
+++ b/app/assets/stylesheets/csslab.scss
@@ -1 +1 @@
-@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
+@import "@gitlab/csslab/dist/css/csslab-slim";
diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss
index 8c32b6c8985..d287215096e 100644
--- a/app/assets/stylesheets/errors.scss
+++ b/app/assets/stylesheets/errors.scss
@@ -2,12 +2,12 @@
* This is a minimal stylesheet, meant to be used for error pages.
*/
@import 'framework/variables';
-@import '../../../node_modules/bootstrap/scss/functions';
-@import '../../../node_modules/bootstrap/scss/variables';
-@import '../../../node_modules/bootstrap/scss/mixins';
-@import '../../../node_modules/bootstrap/scss/reboot';
-@import '../../../node_modules/bootstrap/scss/buttons';
-@import '../../../node_modules/bootstrap/scss/forms';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/reboot';
+@import 'bootstrap/scss/buttons';
+@import 'bootstrap/scss/forms';
$body-color: #666;
$header-color: #456;
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 14f4652e847..9b1d9d51f9c 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -2,7 +2,7 @@
@import 'framework/variables_overrides';
@import 'framework/mixins';
-@import '../../../node_modules/@gitlab/ui/scss/gitlab_ui';
+@import '@gitlab/ui/scss/gitlab_ui';
@import 'bootstrap_migration';
@import 'framework/layout';
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
index 6205ccaa52f..5c298d5a588 100644
--- a/app/assets/stylesheets/framework/system_messages.scss
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -98,14 +98,4 @@
top: auto;
bottom: auto;
}
-
- .content-wrapper {
- .with-system-header & {
- margin-top: 0;
- }
-
- .with-system-footer & {
- margin-top: 0;
- }
- }
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 5e3652db48f..343cca96851 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -92,9 +92,20 @@
width: 400px;
}
- &.is-expandable {
- .board-header {
- cursor: pointer;
+ .board-title-caret {
+ cursor: pointer;
+ border-radius: $border-radius-default;
+ padding: 4px;
+
+ &:hover {
+ background-color: $gray-dark;
+ transition: background-color 0.1s linear;
+ }
+ }
+
+ &:not(.is-collapsed) {
+ .board-title-caret {
+ margin: 0 $gl-padding-4 0 -10px;
}
}
@@ -102,20 +113,51 @@
width: 50px;
.board-title {
- > span {
- width: 100%;
- margin-top: -12px;
+ flex-direction: column;
+ height: 100%;
+ padding: $gl-padding-8 0;
+ }
+
+ .board-title-caret {
+ margin-top: 1px;
+ }
+
+ .user-avatar-link,
+ .milestone-icon {
+ margin-top: $gl-padding-8;
+ transform: rotate(90deg);
+ }
+
+ .board-title-text {
+ flex-grow: 0;
+ margin: $gl-padding-8 0;
+
+ .board-title-main-text {
display: block;
- transform: rotate(90deg) translate(35px, 0);
- overflow: initial;
+ }
+
+ .board-title-sub-text {
+ display: none;
}
}
- .board-title-expandable-toggle {
- position: absolute;
- top: 50%;
- left: 50%;
- margin-left: -10px;
+ .issue-count-badge {
+ border: 0;
+ white-space: nowrap;
+ }
+
+ .board-title-text > span,
+ .issue-count-badge > span {
+ height: 16px;
+
+ // Force the height to be equal to the parent's width while centering the contents.
+ // The contents *should* be about 16 px.
+ // We do this because the flow of elements isn't affected by the rotate transform, so we must ensure that a
+ // rotated element has square dimensions so it won't overlap with its siblings.
+ margin: calc(50% - 8px) 0;
+
+ transform: rotate(90deg);
+ transform-origin: center;
}
}
}
@@ -152,12 +194,14 @@
}
.board-title {
+ align-items: center;
font-size: 1em;
border-bottom: 1px solid $border-color;
+ padding: $gl-padding-8 $gl-padding;
}
.board-title-text {
- margin: $gl-vert-padding auto $gl-vert-padding 0;
+ flex-grow: 1;
}
.board-delete {
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
index dfff3e15556..cca5214a508 100644
--- a/app/assets/stylesheets/pages/container_registry.scss
+++ b/app/assets/stylesheets/pages/container_registry.scss
@@ -2,6 +2,12 @@
* Container Registry
*/
+.container-message {
+ pre {
+ white-space: pre-line;
+ }
+}
+
.container-image {
border-bottom: 1px solid $white-normal;
}
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index d2d35d91e0b..623c44e062f 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -1093,6 +1093,17 @@ table.code {
line-height: 0;
}
+.discussion-collapsible {
+ margin: 0 $gl-padding $gl-padding 71px;
+}
+
+.parallel {
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
+}
+
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
@include fixed-width-container;
@@ -1110,6 +1121,11 @@ table.code {
padding-right: 0;
}
}
+
+ .discussion-collapsible {
+ margin: $gl-padding;
+ margin-top: 0;
+ }
}
.image-diff-overlay,
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index dcbb23684d1..6a0127eb51c 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -594,18 +594,18 @@
padding: 16px 0;
small {
- color: $gray-darkest;
+ color: $gray-700;
}
}
.edited-text {
- color: $gray-darkest;
+ color: $gray-700;
display: block;
margin: 16px 0 0;
font-size: 85%;
.author-link {
- color: $gray-darkest;
+ color: $gray-700;
}
}
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index 48289c8f381..8359a60ec9f 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -1,4 +1,18 @@
.issues-list {
+ &.manual-ordering {
+ background-color: $gray-light;
+ border-radius: $border-radius-default;
+ padding: $gl-padding-8;
+
+ .issue {
+ background-color: $white-light;
+ margin-bottom: $gl-padding-8;
+ border-radius: $border-radius-default;
+ border: 1px solid $gray-100;
+ box-shadow: 0 1px 2px $issue-boards-card-shadow;
+ }
+ }
+
.issue {
padding: 10px 0 10px $gl-padding;
position: relative;
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 5cacd42bf0d..7bd1a4138e4 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -134,6 +134,16 @@ $note-form-margin-left: 72px;
}
}
+ .discussion-toggle-replies {
+ border-top: 0;
+ border-radius: 4px 4px 0 0;
+
+ &.collapsed {
+ border: 0;
+ border-radius: 4px;
+ }
+ }
+
.note-created-ago,
.note-updated-at {
white-space: normal;
@@ -462,6 +472,14 @@ $note-form-margin-left: 72px;
position: relative;
}
+ .notes-content .discussion-notes.diff-discussions {
+ border-bottom: 1px solid $border-color;
+
+ &:nth-last-child(1) {
+ border-bottom: 0;
+ }
+ }
+
.notes_holder {
font-family: $regular-font;
@@ -517,6 +535,17 @@ $note-form-margin-left: 72px;
.discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default;
position: relative;
+
+ .discussion-form {
+ width: 100%;
+ background-color: $gray-light;
+ padding: 0;
+ }
+
+ .disabled-comment {
+ padding: $gl-vert-padding 0;
+ width: 100%;
+ }
}
}
@@ -628,7 +657,7 @@ $note-form-margin-left: 72px;
.note-headline-meta {
.system-note-separator {
- color: $gl-text-color-disabled;
+ color: $gray-700;
}
.note-timestamp {
@@ -657,6 +686,10 @@ $note-form-margin-left: 72px;
margin-left: -1px;
}
+ .btn-group > .discussion-create-issue-btn {
+ margin-left: -2px;
+ }
+
svg {
height: 15px;
}
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 42634bf611e..a570da61d54 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -64,7 +64,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
private
def set_application_setting
- @application_setting = Gitlab::CurrentSettings.current_application_settings
+ @application_setting = ApplicationSetting.current_without_cache
end
def whitelist_query_limiting
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7321f719deb..75108bf2646 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -516,4 +516,10 @@ class ApplicationController < ActionController::Base
def sentry_context
Gitlab::Sentry.context(current_user)
end
+
+ def allow_gitaly_ref_name_caching
+ ::Gitlab::GitalyClient.allow_ref_name_caching do
+ yield
+ end
+ end
end
diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb
index 7625600e452..9da2f888ead 100644
--- a/app/controllers/concerns/boards_responses.rb
+++ b/app/controllers/concerns/boards_responses.rb
@@ -3,8 +3,9 @@
module BoardsResponses
include Gitlab::Utils::StrongMemoize
+ # Overridden on EE module
def board_params
- params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
+ params.require(:board).permit(:name)
end
def parent
diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb
index 54c0510497f..d5830f6648c 100644
--- a/app/controllers/concerns/continue_params.rb
+++ b/app/controllers/concerns/continue_params.rb
@@ -6,7 +6,7 @@ module ContinueParams
def continue_params
continue_params = params[:continue]
- return unless continue_params
+ return {} unless continue_params
continue_params = continue_params.permit(:to, :notice, :notice_now)
continue_params[:to] = safe_redirect_path(continue_params[:to])
diff --git a/app/controllers/concerns/internal_redirect.rb b/app/controllers/concerns/internal_redirect.rb
index 6785e6972d0..fa3716502a0 100644
--- a/app/controllers/concerns/internal_redirect.rb
+++ b/app/controllers/concerns/internal_redirect.rb
@@ -5,8 +5,8 @@ module InternalRedirect
def safe_redirect_path(path)
return unless path
- # Verify that the string starts with a `/` but not a double `/`.
- return unless path =~ %r{^/\w.*$}
+ # Verify that the string starts with a `/` and a known route character.
+ return unless path =~ %r{^/[-\w].*$}
uri = URI(path)
# Ignore anything path of the redirect except for the path, querystring and,
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 88a0690938a..21b3949e361 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -42,7 +42,7 @@ module IssuableCollections
@issuables = @issuables.page(params[:page])
@issuables = per_page_for_relative_position if params[:sort] == 'relative_position'
- @issuable_meta_data = issuable_meta_data(@issuables, collection_type)
+ @issuable_meta_data = issuable_meta_data(@issuables, collection_type, current_user)
@total_pages = issuable_page_count
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index 18ed4027eac..4ad287c4a13 100644
--- a/app/controllers/concerns/issuable_collections_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -11,7 +11,7 @@ module IssuableCollectionsAction
.non_archived
.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@issues, collection_type)
+ @issuable_meta_data = issuable_meta_data(@issues, collection_type, current_user)
respond_to do |format|
format.html
@@ -22,7 +22,7 @@ module IssuableCollectionsAction
def merge_requests
@merge_requests = issuables_collection.page(params[:page])
- @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
+ @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type, current_user)
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
diff --git a/app/controllers/concerns/multiple_boards_actions.rb b/app/controllers/concerns/multiple_boards_actions.rb
new file mode 100644
index 00000000000..95a6800f55c
--- /dev/null
+++ b/app/controllers/concerns/multiple_boards_actions.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module MultipleBoardsActions
+ include Gitlab::Utils::StrongMemoize
+ extend ActiveSupport::Concern
+
+ included do
+ include BoardsActions
+
+ before_action :redirect_to_recent_board, only: [:index]
+ before_action :authenticate_user!, only: [:recent]
+ before_action :authorize_create_board!, only: [:create]
+ before_action :authorize_admin_board!, only: [:create, :update, :destroy]
+ end
+
+ def recent
+ recent_visits = ::Boards::VisitsFinder.new(parent, current_user).latest(4)
+ recent_boards = recent_visits.map(&:board)
+
+ render json: serialize_as_json(recent_boards)
+ end
+
+ def create
+ board = Boards::CreateService.new(parent, current_user, board_params).execute
+
+ respond_to do |format|
+ format.json do
+ if board.persisted?
+ extra_json = { board_path: board_path(board) }
+ render json: serialize_as_json(board).merge(extra_json)
+ else
+ render json: board.errors, status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ def update
+ service = Boards::UpdateService.new(parent, current_user, board_params)
+
+ respond_to do |format|
+ format.json do
+ if service.execute(board)
+ extra_json = { board_path: board_path(board) }
+ render json: serialize_as_json(board).merge(extra_json)
+ else
+ render json: board.errors, status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ def destroy
+ service = Boards::DestroyService.new(parent, current_user)
+ service.execute(board)
+
+ respond_to do |format|
+ format.json { head :ok }
+ format.html { redirect_to boards_path, status: :found }
+ end
+ end
+
+ private
+
+ def redirect_to_recent_board
+ return if request.format.json? || !parent.multiple_issue_boards_available? || !latest_visited_board
+
+ redirect_to board_path(latest_visited_board.board)
+ end
+
+ def latest_visited_board
+ @latest_visited_board ||= Boards::VisitsFinder.new(parent, current_user).latest
+ end
+
+ def authorize_create_board!
+ check_multiple_group_issue_boards_available! if group?
+ end
+
+ def authorize_admin_board!
+ return render_404 unless can?(current_user, :admin_board, parent)
+ end
+
+ def serializer
+ BoardSerializer.new(current_user: current_user)
+ end
+
+ def serialize_as_json(resource)
+ serializer.represent(resource, serializer: 'board', include_full_project_path: board.group_board?)
+ end
+end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index f96d1821095..0098c4cdf4c 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -203,17 +203,17 @@ module NotesActions
# These params are also sent by the client but we need to set these based on
# target_type and target_id because we're checking permissions based on that
- create_params[:noteable_type] = params[:target_type].classify
+ create_params[:noteable_type] = noteable.class.name
- case params[:target_type]
- when 'commit'
- create_params[:commit_id] = params[:target_id]
- when 'merge_request'
- create_params[:noteable_id] = params[:target_id]
+ case noteable
+ when Commit
+ create_params[:commit_id] = noteable.id
+ when MergeRequest
+ create_params[:noteable_id] = noteable.id
# Notes on MergeRequest can have an extra `commit_id` context
create_params[:commit_id] = params.dig(:note, :commit_id)
else
- create_params[:noteable_id] = params[:target_id]
+ create_params[:noteable_id] = noteable.id
end
end
end
diff --git a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
index 426f224d26b..f47ead2f0da 100644
--- a/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
+++ b/app/controllers/concerns/requires_whitelisted_monitoring_client.rb
@@ -14,6 +14,10 @@ module RequiresWhitelistedMonitoringClient
end
def client_ip_whitelisted?
+ # Always allow developers to access http://localhost:3000/-/metrics for
+ # debugging purposes
+ return true if Rails.env.development? && request.local?
+
ip_whitelist.any? { |e| e.include?(Gitlab::RequestContext.client_ip) }
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index 65d14781d92..d43f5393ecc 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -3,6 +3,7 @@
class Dashboard::ProjectsController < Dashboard::ApplicationController
include ParamsBackwardCompatibility
include RendersMemberAccess
+ include OnboardingExperimentHelper
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
before_action :set_non_archived_param
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 09cbc052c76..8f6fcb362d2 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -10,6 +10,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
def index
@sort = params[:sort]
@todos = @todos.page(params[:page])
+ @todos = @todos.with_entity_associations
return if redirect_out_of_range(@todos)
end
diff --git a/app/controllers/groups/clusters_controller.rb b/app/controllers/groups/clusters_controller.rb
index b846fb21266..92602fd8096 100644
--- a/app/controllers/groups/clusters_controller.rb
+++ b/app/controllers/groups/clusters_controller.rb
@@ -4,7 +4,6 @@ class Groups::ClustersController < Clusters::ClustersController
include ControllerWithCrossProjectAccessCheck
prepend_before_action :group
- prepend_before_action :check_group_clusters_feature_flag!
requires_cross_project_access
layout 'group'
@@ -18,12 +17,4 @@ class Groups::ClustersController < Clusters::ClustersController
def group
@group ||= find_routable!(Group, params[:group_id] || params[:id])
end
-
- def check_group_clusters_feature_flag!
- render_404 unless group_clusters_enabled?
- end
-
- def group_clusters_enabled?
- group.group_clusters_enabled?
- end
end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index e936d771502..797833e3f91 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -7,6 +7,10 @@ class GroupsController < Groups::ApplicationController
include PreviewMarkdown
include RecordUserLastActivity
+ before_action do
+ push_frontend_feature_flag(:manual_sorting)
+ end
+
respond_to :html
prepend_before_action(only: [:show, :issues]) { authenticate_sessionless_user!(:rss) }
@@ -197,8 +201,7 @@ class GroupsController < Groups::ApplicationController
params[:sort] ||= 'latest_activity_desc'
options = {}
- options[:only_owned] = true if params[:shared] == '0'
- options[:only_shared] = true if params[:shared] == '1'
+ options[:include_subgroups] = true
@projects = GroupProjectsFinder.new(params: params, group: group, options: options, current_user: current_user)
.execute
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index 80e4f54bbf4..b1f285f76d7 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -12,6 +12,11 @@ class Projects::ApplicationController < ApplicationController
helper_method :repository, :can_collaborate_with_project?, :user_access
+ rescue_from Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError do |exception|
+ log_exception(exception)
+ render_404
+ end
+
private
def project
@@ -87,10 +92,4 @@ class Projects::ApplicationController < ApplicationController
def check_issues_available!
return render_404 unless @project.feature_available?(:issues, current_user)
end
-
- def allow_gitaly_ref_name_caching
- ::Gitlab::GitalyClient.allow_ref_name_caching do
- yield
- end
- end
end
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 95897aaf980..14b02993e6e 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Projects::BoardsController < Projects::ApplicationController
- include BoardsActions
+ include MultipleBoardsActions
include IssuableCollections
before_action :check_issues_available!
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index fc708400657..d77f64a84f5 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -25,15 +25,6 @@ class Projects::BranchesController < Projects::ApplicationController
@refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name))
@merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/48097
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- @max_commits = @branches.reduce(0) do |memo, branch|
- diverging_commit_counts = repository.diverging_commit_counts(branch)
- [memo, diverging_commit_counts.values_at(:behind, :ahead, :distance)]
- .flatten.compact.max
- end
- end
-
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48097
Gitlab::GitalyClient.allow_n_plus_1_calls do
render
@@ -51,6 +42,19 @@ class Projects::BranchesController < Projects::ApplicationController
@branches = @repository.recent_branches
end
+ def diverging_commit_counts
+ respond_to do |format|
+ format.json do
+ service = Branches::DivergingCommitCountsService.new(repository)
+ branches = BranchesFinder.new(repository, params.permit(names: [])).execute
+
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ render json: branches.to_h { |branch| [branch.name, service.call(branch)] }
+ end
+ end
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def create
branch_name = strip_tags(sanitize(params[:branch_name]))
@@ -64,8 +68,9 @@ class Projects::BranchesController < Projects::ApplicationController
success = (result[:status] == :success)
if params[:issue_iid] && success
- issue = IssuesFinder.new(current_user, project_id: @project.id).find_by(iid: params[:issue_iid])
- SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue
+ target_project = confidential_issue_project || @project
+ issue = IssuesFinder.new(current_user, project_id: target_project.id).find_by(iid: params[:issue_iid])
+ SystemNoteService.new_issue_branch(issue, target_project, current_user, branch_name, branch_project: @project) if issue
end
respond_to do |format|
@@ -162,4 +167,15 @@ class Projects::BranchesController < Projects::ApplicationController
@branches = Kaminari.paginate_array(@branches).page(params[:page])
end
end
+
+ def confidential_issue_project
+ return unless Feature.enabled?(:create_confidential_merge_request, @project)
+ return if params[:confidential_issue_project_id].blank?
+
+ confidential_issue_project = Project.find(params[:confidential_issue_project_id])
+
+ return unless can?(current_user, :update_issue, confidential_issue_project)
+
+ confidential_issue_project
+ end
end
diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb
index 7a1700a206a..ac1c4bc7fd3 100644
--- a/app/controllers/projects/forks_controller.rb
+++ b/app/controllers/projects/forks_controller.rb
@@ -46,18 +46,14 @@ class Projects::ForksController < Projects::ApplicationController
@forked_project ||= ::Projects::ForkService.new(project, current_user, namespace: namespace).execute
- if @forked_project.saved? && @forked_project.forked?
- if @forked_project.import_in_progress?
- redirect_to project_import_path(@forked_project, continue: continue_params)
- else
- if continue_params
- redirect_to continue_params[:to], notice: continue_params[:notice]
- else
- redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
- end
- end
- else
+ if !@forked_project.saved? || !@forked_project.forked?
render :error
+ elsif @forked_project.import_in_progress?
+ redirect_to project_import_path(@forked_project, continue: continue_params)
+ elsif continue_params[:to]
+ redirect_to continue_params[:to], notice: continue_params[:notice]
+ else
+ redirect_to project_path(@forked_project), notice: "The project '#{@forked_project.name}' was successfully forked."
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index afbf9fd7720..da32ab9e2e0 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -23,7 +23,7 @@ class Projects::ImportsController < Projects::ApplicationController
def show
if @project.import_finished?
- if continue_params&.key?(:to)
+ if continue_params[:to]
redirect_to continue_params[:to], notice: continue_params[:notice]
else
redirect_to project_path(@project), notice: finished_notice
@@ -31,11 +31,7 @@ class Projects::ImportsController < Projects::ApplicationController
elsif @project.import_failed?
redirect_to new_project_import_path(@project)
else
- if continue_params && continue_params[:notice_now]
- flash.now[:notice] = continue_params[:notice_now]
- end
-
- # Render
+ flash.now[:notice] = continue_params[:notice_now]
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index b16f3dd9d82..e275b417784 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -10,6 +10,10 @@ class Projects::IssuesController < Projects::ApplicationController
include SpammableActions
include RecordUserLastActivity
+ before_action do
+ push_frontend_feature_flag(:manual_sorting)
+ end
+
def issue_except_actions
%i[index calendar new create bulk_update import_csv]
end
@@ -168,6 +172,7 @@ class Projects::IssuesController < Projects::ApplicationController
def create_merge_request
create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
+ create_params[:target_project_id] = params[:target_project_id] if Feature.enabled?(:create_confidential_merge_request, @project)
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index d7c0039b234..02ff6e872c9 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -103,7 +103,7 @@ class Projects::JobsController < Projects::ApplicationController
@build.cancel
- if continue_params
+ if continue_params[:to]
redirect_to continue_params[:to]
else
redirect_to builds_project_pipeline_path(@project, @build.pipeline.id)
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index f2a6268b3e9..dcc272aecff 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -51,4 +51,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
Ci::Pipeline.none
end
end
+
+ def close_merge_request_if_no_source_project
+ return if @merge_request.source_project
+ return unless @merge_request.open?
+
+ @merge_request.close
+ end
end
diff --git a/app/controllers/projects/merge_requests/content_controller.rb b/app/controllers/projects/merge_requests/content_controller.rb
new file mode 100644
index 00000000000..6e026b83ee3
--- /dev/null
+++ b/app/controllers/projects/merge_requests/content_controller.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class Projects::MergeRequests::ContentController < Projects::MergeRequests::ApplicationController
+ # @merge_request.check_mergeability is not executed here since
+ # widget serializer calls it via mergeable? method
+ # but we might want to call @merge_request.check_mergeability
+ # for other types of serialization
+
+ before_action :close_merge_request_if_no_source_project
+ around_action :allow_gitaly_ref_name_caching
+
+ def widget
+ respond_to do |format|
+ format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
+ serializer = MergeRequestSerializer.new(current_user: current_user, project: merge_request.project)
+ render json: serializer.represent(merge_request, serializer: 'widget')
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index fc37ce1dbc4..7ee8e0ea8f8 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -235,12 +235,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
params[:auto_merge_strategy].present? || params[:merge_when_pipeline_succeeds].present?
end
- def close_merge_request_if_no_source_project
- if !@merge_request.source_project && @merge_request.open?
- @merge_request.close
- end
- end
-
private
def ci_environments_status_on_merge_result?
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index b3447812ef2..b4ca9074ca9 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -55,6 +55,7 @@ class Projects::RefsController < Projects::ApplicationController
format.html { render_404 }
format.json do
response.headers["More-Logs-Url"] = @more_log_url if summary.more?
+ response.headers["More-Logs-Offset"] = summary.next_offset if summary.more?
render json: @logs
end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
index 6d60117c37d..e205e2fd4f8 100644
--- a/app/controllers/projects/registry/repositories_controller.rb
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -46,6 +46,8 @@ module Projects
repository.save! if repository.has_tags?
end
end
+ rescue ContainerRegistry::Path::InvalidRegistryPathError
+ @character_error = true
end
end
end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index ac3004d069f..bc2ce15286f 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -99,7 +99,7 @@ module Projects
end
def deploy_token_params
- params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry)
+ params.require(:deploy_token).permit(:name, :expires_at, :read_repository, :read_registry, :username)
end
end
end
diff --git a/app/controllers/projects/templates_controller.rb b/app/controllers/projects/templates_controller.rb
index 7ceea4e5b96..f987033a26c 100644
--- a/app/controllers/projects/templates_controller.rb
+++ b/app/controllers/projects/templates_controller.rb
@@ -1,7 +1,9 @@
# frozen_string_literal: true
class Projects::TemplatesController < Projects::ApplicationController
- before_action :authenticate_user!, :get_template_class
+ before_action :authenticate_user!
+ before_action :authorize_can_read_issuable!
+ before_action :get_template_class
def show
template = @template_type.find(params[:key], project)
@@ -13,9 +15,20 @@ class Projects::TemplatesController < Projects::ApplicationController
private
+ # User must have:
+ # - `read_merge_request` to see merge request templates, or
+ # - `read_issue` to see issue templates
+ #
+ # Note params[:template_type] has a route constraint to limit it to
+ # `merge_request` or `issue`
+ def authorize_can_read_issuable!
+ action = [:read_, params[:template_type]].join
+
+ authorize_action!(action)
+ end
+
def get_template_class
template_types = { issue: Gitlab::Template::IssueTemplate, merge_request: Gitlab::Template::MergeRequestTemplate }.with_indifferent_access
@template_type = template_types[params[:template_type]]
- render json: [], status: :not_found unless @template_type
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 12db493978b..330e2d0f8a5 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -298,7 +298,7 @@ class ProjectsController < Projects::ApplicationController
elsif @project.feature_available?(:issues, current_user)
@issues = issuables_collection.page(params[:page])
@collection_type = 'Issue'
- @issuable_meta_data = issuable_meta_data(@issues, @collection_type)
+ @issuable_meta_data = issuable_meta_data(@issues, @collection_type, current_user)
end
render :show
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 07b38371ab9..b2b151bbcf0 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -3,6 +3,7 @@
class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
include AcceptsPendingInvitations
+ include RecaptchaExperimentHelper
prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, only: [:destroy]
@@ -15,13 +16,6 @@ class RegistrationsController < Devise::RegistrationsController
end
def create
- # To avoid duplicate form fields on the login page, the registration form
- # names fields using `new_user`, but Devise still wants the params in
- # `user`.
- if params["new_#{resource_name}"].present? && params[resource_name].blank?
- params[resource_name] = params.delete(:"new_#{resource_name}")
- end
-
accept_pending_invitations
super do |new_user|
@@ -74,19 +68,35 @@ class RegistrationsController < Devise::RegistrationsController
end
def after_sign_up_path_for(user)
- Gitlab::AppLogger.info("User Created: username=#{user.username} email=#{user.email} ip=#{request.remote_ip} confirmed:#{user.confirmed?}")
+ Gitlab::AppLogger.info(user_created_message(confirmed: user.confirmed?))
user.confirmed? ? stored_location_for(user) || dashboard_projects_path : users_almost_there_path
end
def after_inactive_sign_up_path_for(resource)
- Gitlab::AppLogger.info("User Created: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip} confirmed:false")
+ Gitlab::AppLogger.info(user_created_message)
users_almost_there_path
end
private
+ def user_created_message(confirmed: false)
+ "User Created: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip} confirmed:#{confirmed}"
+ end
+
+ def ensure_correct_params!
+ # To avoid duplicate form fields on the login page, the registration form
+ # names fields using `new_user`, but Devise still wants the params in
+ # `user`.
+ if params["new_#{resource_name}"].present? && params[resource_name].blank?
+ params[resource_name] = params.delete(:"new_#{resource_name}")
+ end
+ end
+
def check_captcha
- return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true)
+ ensure_correct_params!
+
+ return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true) # reCAPTCHA on the UI will still display however
+ return unless show_recaptcha_sign_up?
return unless Gitlab::Recaptcha.load_configurations!
return if verify_recaptcha
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index a80ab3bcd28..8c674be58c5 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -5,6 +5,8 @@ class SearchController < ApplicationController
include SearchHelper
include RendersCommits
+ around_action :allow_gitaly_ref_name_caching
+
skip_before_action :authenticate_user!
requires_cross_project_access if: -> do
search_term_present = params[:search].present? || params[:term].present?
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index eee14b0faf4..612897f27e6 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -5,8 +5,8 @@ class Snippets::NotesController < ApplicationController
include ToggleAwardEmoji
skip_before_action :authenticate_user!, only: [:index]
- before_action :snippet
- before_action :authorize_read_snippet!, only: [:show, :index, :create]
+ before_action :authorize_read_snippet!, only: [:show, :index]
+ before_action :authorize_create_note!, only: [:create]
private
@@ -33,4 +33,8 @@ class Snippets::NotesController < ApplicationController
def authorize_read_snippet!
return render_404 unless can?(current_user, :read_personal_snippet, snippet)
end
+
+ def authorize_create_note!
+ access_denied! unless can?(current_user, :create_note, noteable)
+ end
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index 8ea5450b4e8..fad036b8df8 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -137,7 +137,7 @@ class SnippetsController < ApplicationController
def move_temporary_files
params[:files].each do |file|
- FileMover.new(file, @snippet).execute
+ FileMover.new(file, from_model: current_user, to_model: @snippet).execute
end
end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index 5d28635232b..94bd18f70d4 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -41,7 +41,11 @@ class UploadsController < ApplicationController
when Note
can?(current_user, :read_project, model.project)
when User
- true
+ # We validate the current user has enough (writing)
+ # access to itself when a secret is given.
+ # For instance, user avatars are readable by anyone,
+ # while temporary, user snippet uploads are not.
+ !secret? || can?(current_user, :update_user, model)
when Appearance
true
else
@@ -56,9 +60,13 @@ class UploadsController < ApplicationController
def authorize_create_access!
return unless model
- # for now we support only personal snippets comments. Only personal_snippet
- # is allowed as a model to #create through routing.
- authorized = can?(current_user, :create_note, model)
+ authorized =
+ case model
+ when User
+ can?(current_user, :update_user, model)
+ else
+ can?(current_user, :create_note, model)
+ end
render_unauthorized unless authorized
end
@@ -75,6 +83,10 @@ class UploadsController < ApplicationController
User === model || Appearance === model
end
+ def secret?
+ params[:secret].present?
+ end
+
def upload_model_class
MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
end
diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
index f38c187799c..78a17312e26 100644
--- a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
+++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
@@ -22,8 +22,7 @@ module Autocomplete
end
def filter_by_name(tags)
- return tags unless search
- return tags.none if search.empty?
+ return tags unless search.present?
if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING
tags.named_like(search)
diff --git a/app/finders/boards/visits_finder.rb b/app/finders/boards/visits_finder.rb
new file mode 100644
index 00000000000..d17a27f72dc
--- /dev/null
+++ b/app/finders/boards/visits_finder.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Boards
+ class VisitsFinder
+ attr_accessor :params, :current_user, :parent
+
+ def initialize(parent, current_user)
+ @current_user = current_user
+ @parent = parent
+ end
+
+ def execute(count = nil)
+ return unless current_user
+
+ recent_visit_model.latest(current_user, parent, count: count)
+ end
+
+ alias_method :latest, :execute
+
+ private
+
+ def recent_visit_model
+ parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit
+ end
+ end
+end
diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb
index 45d5591e81b..b462c8053fa 100644
--- a/app/finders/branches_finder.rb
+++ b/app/finders/branches_finder.rb
@@ -9,6 +9,7 @@ class BranchesFinder
def execute
branches = repository.branches_sorted_by(sort)
branches = by_search(branches)
+ branches = by_names(branches)
branches
end
@@ -16,6 +17,10 @@ class BranchesFinder
attr_reader :repository, :params
+ def names
+ @params[:names].presence
+ end
+
def search
@params[:search].presence
end
@@ -59,4 +64,13 @@ class BranchesFinder
def find_exact_match_index(matches, term)
matches.index { |branch| branch.name.casecmp(term) == 0 }
end
+
+ def by_names(branches)
+ return branches unless names
+
+ branch_names = names.to_set
+ branches.filter do |branch|
+ branch_names.include?(branch.name)
+ end
+ end
end
diff --git a/app/graphql/mutations/.keep b/app/graphql/mutations/.keep
deleted file mode 100644
index e69de29bb2d..00000000000
--- a/app/graphql/mutations/.keep
+++ /dev/null
diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb
new file mode 100644
index 00000000000..8e050dd6d29
--- /dev/null
+++ b/app/graphql/mutations/award_emojis/add.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AwardEmojis
+ class Add < Base
+ graphql_name 'AddAwardEmoji'
+
+ def resolve(args)
+ awardable = authorized_find!(id: args[:awardable_id])
+
+ check_object_is_awardable!(awardable)
+
+ # TODO this will be handled by AwardEmoji::AddService
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782
+ award = awardable.create_award_emoji(args[:name], current_user)
+
+ {
+ award_emoji: (award if award.persisted?),
+ errors: errors_on_object(award)
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/award_emojis/base.rb b/app/graphql/mutations/award_emojis/base.rb
new file mode 100644
index 00000000000..d868db84f9d
--- /dev/null
+++ b/app/graphql/mutations/award_emojis/base.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AwardEmojis
+ class Base < BaseMutation
+ include Gitlab::Graphql::Authorize::AuthorizeResource
+
+ authorize :award_emoji
+
+ argument :awardable_id,
+ GraphQL::ID_TYPE,
+ required: true,
+ description: 'The global id of the awardable resource'
+
+ argument :name,
+ GraphQL::STRING_TYPE,
+ required: true,
+ description: copy_field_description(Types::AwardEmojis::AwardEmojiType, :name)
+
+ field :award_emoji,
+ Types::AwardEmojis::AwardEmojiType,
+ null: true,
+ description: 'The award emoji after mutation'
+
+ private
+
+ def find_object(id:)
+ GitlabSchema.object_from_id(id)
+ end
+
+ # Called by mutations methods after performing an authorization check
+ # of an awardable object.
+ def check_object_is_awardable!(object)
+ unless object.is_a?(Awardable) && object.emoji_awardable?
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'Cannot award emoji to this resource'
+ end
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb
new file mode 100644
index 00000000000..3ba85e445b8
--- /dev/null
+++ b/app/graphql/mutations/award_emojis/remove.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AwardEmojis
+ class Remove < Base
+ graphql_name 'RemoveAwardEmoji'
+
+ def resolve(args)
+ awardable = authorized_find!(id: args[:awardable_id])
+
+ check_object_is_awardable!(awardable)
+
+ # TODO this check can be removed once AwardEmoji services are available.
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782
+ unless awardable.awarded_emoji?(args[:name], current_user)
+ raise Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'You have not awarded emoji of type name to the awardable'
+ end
+
+ # TODO this will be handled by AwardEmoji::DestroyService
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782
+ awardable.remove_award_emoji(args[:name], current_user)
+
+ {
+ # Mutation response is always a `nil` award_emoji
+ errors: []
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb
new file mode 100644
index 00000000000..c03902e8035
--- /dev/null
+++ b/app/graphql/mutations/award_emojis/toggle.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Mutations
+ module AwardEmojis
+ 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'
+
+ def resolve(args)
+ awardable = authorized_find!(id: args[:awardable_id])
+
+ check_object_is_awardable!(awardable)
+
+ # TODO this will be handled by AwardEmoji::ToggleService
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782
+ award = awardable.toggle_award_emoji(args[:name], current_user)
+
+ # Destroy returns a collection :(
+ award = award.first if award.is_a?(Array)
+
+ errors = errors_on_object(award)
+
+ toggled_on = awardable.awarded_emoji?(args[:name], current_user)
+
+ {
+ # For consistency with the AwardEmojis::Remove mutation, only return
+ # the AwardEmoji if it was created and not destroyed
+ award_emoji: (award if toggled_on),
+ errors: errors,
+ toggled_on: toggled_on
+ }
+ end
+ end
+ end
+end
diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb
index eb03dfe1624..08d2a1f18a3 100644
--- a/app/graphql/mutations/base_mutation.rb
+++ b/app/graphql/mutations/base_mutation.rb
@@ -2,6 +2,8 @@
module Mutations
class BaseMutation < GraphQL::Schema::RelayClassicMutation
+ prepend Gitlab::Graphql::CopyFieldDescription
+
field :errors, [GraphQL::STRING_TYPE],
null: false,
description: "Reasons why the mutation failed."
@@ -9,5 +11,10 @@ module Mutations
def current_user
context[:current_user]
end
+
+ # Returns Array of errors on an ActiveRecord object
+ def errors_on_object(record)
+ record.errors.full_messages
+ 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
new file mode 100644
index 00000000000..8daf699a112
--- /dev/null
+++ b/app/graphql/types/award_emojis/award_emoji_type.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module Types
+ module AwardEmojis
+ class AwardEmojiType < BaseObject
+ graphql_name 'AwardEmoji'
+
+ authorize :read_emoji
+
+ present_using AwardEmojiPresenter
+
+ field :name,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The emoji name'
+
+ field :description,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The emoji description'
+
+ field :unicode,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The emoji in unicode'
+
+ field :emoji,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The emoji as an icon'
+
+ field :unicode_version,
+ GraphQL::STRING_TYPE,
+ null: false,
+ description: 'The unicode version for this emoji'
+
+ field :user,
+ Types::UserType,
+ null: false,
+ description: 'The user who awarded the emoji',
+ resolve: -> (award_emoji, _args, _context) {
+ Gitlab::Graphql::Loaders::BatchModelLoader.new(User, award_emoji.user_id).find
+ }
+ end
+ end
+end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
index 2987354b556..5f7d7a934ce 100644
--- a/app/graphql/types/ci/detailed_status_type.rb
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Types
module Ci
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `PipelineType` that has its own authorization
class DetailedStatusType < BaseObject
graphql_name 'DetailedStatus'
@@ -13,5 +15,6 @@ module Types
field :text, GraphQL::STRING_TYPE, null: false
field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb
new file mode 100644
index 00000000000..d73dd73affd
--- /dev/null
+++ b/app/graphql/types/commit_type.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Types
+ class CommitType < BaseObject
+ graphql_name 'Commit'
+
+ authorize :download_code
+
+ present_using CommitPresenter
+
+ field :id, type: GraphQL::ID_TYPE, null: false
+ field :sha, type: GraphQL::STRING_TYPE, null: false
+ field :title, type: GraphQL::STRING_TYPE, null: true
+ field :description, type: GraphQL::STRING_TYPE, null: true
+ field :message, type: GraphQL::STRING_TYPE, null: true
+ field :authored_date, type: Types::TimeType, null: true
+ field :web_url, type: GraphQL::STRING_TYPE, null: false
+
+ # models/commit lazy loads the author by email
+ field :author, type: Types::UserType, null: true
+
+ field :latest_pipeline,
+ type: Types::Ci::PipelineType,
+ null: true,
+ description: "Latest pipeline for this commit",
+ resolve: -> (obj, ctx, args) do
+ Gitlab::Graphql::Loaders::PipelineForShaLoader.new(obj.project, obj.sha).find_last
+ end
+ end
+end
diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb
index 6521407fc9d..70c34fbe491 100644
--- a/app/graphql/types/issue_state_enum.rb
+++ b/app/graphql/types/issue_state_enum.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is a BaseEnum through IssuableEnum, so it does not need authorization
class IssueStateEnum < IssuableStateEnum
graphql_name 'IssueState'
description 'State of a GitLab issue'
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb
index 50eb1b89c61..3aeda2e7953 100644
--- a/app/graphql/types/label_type.rb
+++ b/app/graphql/types/label_type.rb
@@ -4,6 +4,8 @@ module Types
class LabelType < BaseObject
graphql_name 'Label'
+ authorize :read_label
+
field :description, GraphQL::STRING_TYPE, null: true
markdown_field :description_html, null: true
field :title, GraphQL::STRING_TYPE, null: false
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
index 92f52726ab3..37c890a3c8d 100644
--- a/app/graphql/types/merge_request_state_enum.rb
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is a BaseEnum through IssuableEnum, so it does not need authorization
class MergeRequestStateEnum < IssuableStateEnum
graphql_name 'MergeRequestState'
description 'State of a GitLab merge request'
value 'merged'
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
index 2d8bad0614b..7d7813a7652 100644
--- a/app/graphql/types/metadata_type.rb
+++ b/app/graphql/types/metadata_type.rb
@@ -4,6 +4,8 @@ module Types
class MetadataType < ::Types::BaseObject
graphql_name 'Metadata'
+ authorize :read_instance_metadata
+
field :version, GraphQL::STRING_TYPE, null: false
field :revision, GraphQL::STRING_TYPE, null: false
end
diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb
index 2b4ef299296..6ef1d816b7c 100644
--- a/app/graphql/types/mutation_type.rb
+++ b/app/graphql/types/mutation_type.rb
@@ -6,6 +6,9 @@ module Types
graphql_name "Mutation"
+ mount_mutation Mutations::AwardEmojis::Add
+ mount_mutation Mutations::AwardEmojis::Remove
+ mount_mutation Mutations::AwardEmojis::Toggle
mount_mutation Mutations::MergeRequests::SetWip
end
end
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
index 62feccaa660..f105e9e6e28 100644
--- a/app/graphql/types/namespace_type.rb
+++ b/app/graphql/types/namespace_type.rb
@@ -4,6 +4,8 @@ module Types
class NamespaceType < BaseObject
graphql_name 'Namespace'
+ authorize :read_namespace
+
field :id, GraphQL::ID_TYPE, null: false
field :name, GraphQL::STRING_TYPE, null: false
diff --git a/app/graphql/types/notes/diff_position_type.rb b/app/graphql/types/notes/diff_position_type.rb
index 104ccb79bbb..ebc24451715 100644
--- a/app/graphql/types/notes/diff_position_type.rb
+++ b/app/graphql/types/notes/diff_position_type.rb
@@ -2,6 +2,8 @@
module Types
module Notes
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `NoteType` that has its own authorization
class DiffPositionType < BaseObject
graphql_name 'DiffPosition'
@@ -42,5 +44,6 @@ module Types
description: "The total height of the image",
resolve: -> (position, _args, _ctx) { position.height if position.on_image? }
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index ac957eafafc..c25688ab043 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -67,14 +67,14 @@ module Types
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
- field :namespace, Types::NamespaceType, null: false
+ field :namespace, Types::NamespaceType, null: true
field :group, Types::GroupType, null: true
field :statistics, Types::ProjectStatisticsType,
null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find }
- field :repository, Types::RepositoryType, null: false
+ field :repository, Types::RepositoryType, null: true
field :merge_requests,
Types::MergeRequestType.connection_type,
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 536bdb077ad..53d36b43576 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -22,10 +22,7 @@ module Types
field :metadata, Types::MetadataType,
null: true,
resolver: Resolvers::MetadataResolver,
- description: 'Metadata about GitLab' do |*args|
-
- authorize :read_instance_metadata
- end
+ description: 'Metadata about GitLab'
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
end
diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb
index c289802509d..ac128481ac4 100644
--- a/app/graphql/types/task_completion_status.rb
+++ b/app/graphql/types/task_completion_status.rb
@@ -1,6 +1,9 @@
# frozen_string_literal: true
module Types
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is used in `IssueType` and `MergeRequestType` both of which have their
+ # own authorization
class TaskCompletionStatus < BaseObject
graphql_name 'TaskCompletionStatus'
description 'Completion status of tasks'
@@ -8,4 +11,5 @@ module Types
field :count, GraphQL::INT_TYPE, null: false
field :completed_count, GraphQL::INT_TYPE, null: false
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
index 760781f3612..9497e378dc0 100644
--- a/app/graphql/types/tree/blob_type.rb
+++ b/app/graphql/types/tree/blob_type.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Types
module Tree
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `Repository` that has its own authorization
class BlobType < BaseObject
implements Types::Tree::EntryType
@@ -12,6 +14,7 @@ module Types
field :lfs_oid, GraphQL::STRING_TYPE, null: true, resolve: -> (blob, args, ctx) do
Gitlab::Graphql::Loaders::BatchLfsOidLoader.new(blob.repository, blob.id).find
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
end
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
index cea76dbfd2a..8cb1e04f5ba 100644
--- a/app/graphql/types/tree/submodule_type.rb
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -1,10 +1,13 @@
# frozen_string_literal: true
module Types
module Tree
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `Repository` that has its own authorization
class SubmoduleType < BaseObject
implements Types::Tree::EntryType
graphql_name 'Submodule'
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
index 23ec2ef0ec2..d7faa633706 100644
--- a/app/graphql/types/tree/tree_entry_type.rb
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module Types
module Tree
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `Repository` that has its own authorization
class TreeEntryType < BaseObject
implements Types::Tree::EntryType
@@ -11,5 +13,6 @@ module Types
field :web_url, GraphQL::STRING_TYPE, null: true
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
index 1ee93ed9542..b947713074e 100644
--- a/app/graphql/types/tree/tree_type.rb
+++ b/app/graphql/types/tree/tree_type.rb
@@ -1,9 +1,16 @@
# frozen_string_literal: true
module Types
module Tree
+ # rubocop: disable Graphql/AuthorizeTypes
+ # This is presented through `Repository` that has its own authorization
class TreeType < BaseObject
graphql_name 'Tree'
+ # Complexity 10 as it triggers a Gitaly call on each render
+ field :last_commit, Types::CommitType, null: true, complexity: 10, resolve: -> (tree, args, ctx) do
+ tree.repository.last_commit_for_path(tree.sha, tree.path)
+ end
+
field :trees, Types::Tree::TreeEntryType.connection_type, null: false, resolve: -> (obj, args, ctx) do
Gitlab::Graphql::Representation::TreeEntry.decorate(obj.trees, obj.repository)
end
@@ -13,6 +20,7 @@ module Types
field :blobs, Types::Tree::BlobType.connection_type, null: false, resolve: -> (obj, args, ctx) do
Gitlab::Graphql::Representation::TreeEntry.decorate(obj.blobs, obj.repository)
end
+ # rubocop: enable Graphql/AuthorizeTypes
end
end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 4469118f065..4bf9b708401 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -69,7 +69,7 @@ module ApplicationSettingsHelper
# toggle button effect.
def import_sources_checkboxes(help_block_id, options = {})
Gitlab::ImportSources.options.map do |name, source|
- checked = Gitlab::CurrentSettings.import_sources.include?(source)
+ checked = @application_setting.import_sources.include?(source)
css_class = checked ? 'active' : ''
checkbox_name = 'application_setting[import_sources][]'
@@ -85,7 +85,7 @@ module ApplicationSettingsHelper
def oauth_providers_checkboxes
button_based_providers.map do |source|
- disabled = Gitlab::CurrentSettings.disabled_oauth_sign_in_sources.include?(source.to_s)
+ disabled = @application_setting.disabled_oauth_sign_in_sources.include?(source.to_s)
css_class = ['btn']
css_class << 'active' unless disabled
checkbox_name = 'application_setting[enabled_oauth_sign_in_sources][]'
@@ -165,8 +165,6 @@ module ApplicationSettingsHelper
:authorized_keys_enabled,
:auto_devops_enabled,
:auto_devops_domain,
- :clientside_sentry_dsn,
- :clientside_sentry_enabled,
:container_registry_token_expire_delay,
:default_artifacts_expire_in,
:default_branch_protection,
@@ -189,6 +187,8 @@ module ApplicationSettingsHelper
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
+ :grafana_enabled,
+ :grafana_url,
:gravatar_enabled,
:hashed_storage_enabled,
:help_page_hide_commercial_content,
@@ -235,8 +235,6 @@ module ApplicationSettingsHelper
:restricted_visibility_levels,
:rsa_key_restriction,
:send_user_confirmation_email,
- :sentry_dsn,
- :sentry_enabled,
:session_expire_delay,
:shared_runners_enabled,
:shared_runners_text,
@@ -253,6 +251,7 @@ module ApplicationSettingsHelper
:throttle_unauthenticated_enabled,
:throttle_unauthenticated_period_in_seconds,
:throttle_unauthenticated_requests_per_period,
+ :time_tracking_limit_to_hours,
:two_factor_grace_period,
:unique_ips_limit_enabled,
:unique_ips_limit_per_user,
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 076976175a9..31c4b27273b 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module AuthHelper
- PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
+ PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq salesforce).freeze
LDAP_PROVIDER = /\Aldap/.freeze
def ldap_enabled?
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index 1640f4fc93f..8ef68018d23 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -14,7 +14,9 @@ module BoardsHelper
issue_link_base: build_issue_link_base,
root_path: root_path,
bulk_update_path: @bulk_issues_path,
- default_avatar: image_path(default_avatar)
+ default_avatar: image_path(default_avatar),
+ time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
+ recent_boards_endpoint: recent_boards_path
}
end
@@ -86,6 +88,18 @@ module BoardsHelper
end
def boards_link_text
- s_("IssueBoards|Board")
+ if current_board_parent.multiple_issue_boards_available?
+ s_("IssueBoards|Boards")
+ else
+ s_("IssueBoards|Board")
+ end
+ end
+
+ def recent_boards_path
+ recent_project_boards_path(@project) if current_board_parent.is_a?(Project)
+ end
+
+ def current_board_json
+ board.to_json
end
end
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index eadf48205fc..c759882d7f8 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -8,12 +8,4 @@ module BranchesHelper
def protected_branch?(project, branch)
ProtectedBranch.protected?(project, branch.name)
end
-
- def diverging_count_label(count)
- if count >= Repository::MAX_DIVERGING_COUNT
- "#{Repository::MAX_DIVERGING_COUNT - 1}+"
- else
- count.to_s
- end
- end
end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index a3f53ca8dd6..5aed7e313e6 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -142,7 +142,7 @@ module GroupsHelper
can?(current_user, "read_group_#{resource}".to_sym, @group)
end
- if can?(current_user, :read_cluster, @group) && @group.group_clusters_enabled?
+ if can?(current_user, :read_cluster, @group)
links << :kubernetes
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index aa53c649b2a..67685ba4e1d 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -280,7 +280,7 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
- data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue)
+ data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue)
if parent.is_a?(Group)
data[:groupPath] = parent.path
@@ -430,7 +430,8 @@ module IssuablesHelper
editable: issuable.dig(:current_user, :can_edit),
currentUser: issuable[:current_user],
rootPath: root_path,
- fullPath: issuable[:project_full_path]
+ fullPath: issuable[:project_full_path],
+ timeTrackingLimitToHours: Gitlab::CurrentSettings.time_tracking_limit_to_hours
}
end
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 59332c0b100..dfadcfc33b2 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -5,6 +5,7 @@ module IssuesHelper
classes = ["issue"]
classes << "closed" if issue.closed?
classes << "today" if issue.today?
+ classes << "user-can-drag" if @sort == 'relative_position'
classes.join(' ')
end
diff --git a/app/helpers/onboarding_experiment_helper.rb b/app/helpers/onboarding_experiment_helper.rb
new file mode 100644
index 00000000000..ad49d333d7a
--- /dev/null
+++ b/app/helpers/onboarding_experiment_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module OnboardingExperimentHelper
+ def allow_access_to_onboarding?
+ ::Gitlab.com? && Feature.enabled?(:user_onboarding)
+ end
+end
diff --git a/app/helpers/recaptcha_experiment_helper.rb b/app/helpers/recaptcha_experiment_helper.rb
new file mode 100644
index 00000000000..d2eb9ac54f6
--- /dev/null
+++ b/app/helpers/recaptcha_experiment_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module RecaptchaExperimentHelper
+ def show_recaptcha_sign_up?
+ !!Gitlab::Recaptcha.enabled?
+ end
+end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index dfa34ad7020..f5c4686a3bf 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -169,18 +169,17 @@ module SearchHelper
autocomplete: 'off'
}
+ opts[:data]['runner-tags-endpoint'] = tag_list_admin_runners_path
+
if @project.present?
opts[:data]['project-id'] = @project.id
- opts[:data]['base-endpoint'] = project_path(@project)
opts[:data]['labels-endpoint'] = project_labels_path(@project)
opts[:data]['milestones-endpoint'] = project_milestones_path(@project)
elsif @group.present?
opts[:data]['group-id'] = @group.id
- opts[:data]['base-endpoint'] = group_canonical_path(@group)
opts[:data]['labels-endpoint'] = group_labels_path(@group)
opts[:data]['milestones-endpoint'] = group_milestones_path(@group)
else
- opts[:data]['base-endpoint'] = root_dashboard_path
opts[:data]['labels-endpoint'] = dashboard_labels_path
opts[:data]['milestones-endpoint'] = dashboard_milestones_path
end
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index ecb2b2d707b..6ccc1fb2ed1 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -1,6 +1,16 @@
# frozen_string_literal: true
module SnippetsHelper
+ def snippets_upload_path(snippet, user)
+ return unless user
+
+ if snippet&.persisted?
+ upload_path('personal_snippet', id: snippet.id)
+ else
+ upload_path('user', id: user.id)
+ end
+ end
+
def reliable_snippet_path(snippet, opts = nil)
if snippet.project_id?
project_snippet_path(snippet.project, snippet, opts)
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 6bd78336ed3..645160077f5 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -170,7 +170,7 @@ module TodosHelper
end
def todo_group_options
- groups = current_user.authorized_groups.map do |group|
+ groups = current_user.authorized_groups.with_route.map do |group|
{ id: group.id, text: group.full_name }
end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 506c8d251b7..04db1980b99 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -51,7 +51,7 @@ module Emails
def note_thread_options(recipient_id)
{
from: sender(@note.author_id),
- to: recipient(recipient_id, @group),
+ to: recipient(recipient_id, @project&.group || @group),
subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})")
}
end
@@ -60,7 +60,7 @@ module Emails
# `note_id` is a `Note` when originating in `NotifyPreview`
@note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project
- @group = @project.try(:group) || @note.noteable.try(:group)
+ @group = @note.noteable.try(:group)
if (@project || @group) && @note.persisted?
@sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index bbe2d2e8fd4..8e558487c1c 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -23,13 +23,12 @@ class ApplicationSetting < ApplicationRecord
serialize :domain_blacklist, Array # rubocop:disable Cop/ActiveRecordSerialize
serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize
- ignore_column :circuitbreaker_failure_count_threshold
- ignore_column :circuitbreaker_failure_reset_time
- ignore_column :circuitbreaker_storage_timeout
- ignore_column :circuitbreaker_access_retries
- ignore_column :circuitbreaker_check_interval
ignore_column :koding_url
ignore_column :koding_enabled
+ ignore_column :sentry_enabled
+ ignore_column :sentry_dsn
+ ignore_column :clientside_sentry_enabled
+ ignore_column :clientside_sentry_dsn
cache_markdown_field :sign_in_text
cache_markdown_field :help_page_text
@@ -75,14 +74,6 @@ class ApplicationSetting < ApplicationRecord
presence: true,
if: :recaptcha_enabled
- validates :sentry_dsn,
- presence: true,
- if: :sentry_enabled
-
- validates :clientside_sentry_dsn,
- presence: true,
- if: :clientside_sentry_enabled
-
validates :akismet_api_key,
presence: true,
if: :akismet_enabled
@@ -264,7 +255,6 @@ class ApplicationSetting < ApplicationRecord
encode: true
before_validation :ensure_uuid!
- before_validation :strip_sentry_values
before_save :ensure_runners_registration_token
before_save :ensure_health_check_access_token
@@ -282,4 +272,12 @@ class ApplicationSetting < ApplicationRecord
# We already have an ApplicationSetting record, so just return it.
current_without_cache
end
+
+ # By default, the backend is Rails.cache, which uses
+ # ActiveSupport::Cache::RedisStore. Since loading ApplicationSetting
+ # can cause a significant amount of load on Redis, let's cache it in
+ # memory.
+ def self.cache_backend
+ Gitlab::ThreadMemoryCache.cache_backend
+ end
end
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 904d650ef96..df4caed175d 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -82,6 +82,7 @@ module ApplicationSettingImplementation
throttle_unauthenticated_enabled: false,
throttle_unauthenticated_period_in_seconds: 3600,
throttle_unauthenticated_requests_per_period: 3600,
+ time_tracking_limit_to_hours: false,
two_factor_grace_period: 48,
unique_ips_limit_enabled: false,
unique_ips_limit_per_user: 10,
@@ -179,27 +180,6 @@ module ApplicationSettingImplementation
super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) })
end
- def strip_sentry_values
- sentry_dsn.strip! if sentry_dsn.present?
- clientside_sentry_dsn.strip! if clientside_sentry_dsn.present?
- end
-
- def sentry_enabled
- Gitlab.config.sentry.enabled || read_attribute(:sentry_enabled)
- end
-
- def sentry_dsn
- Gitlab.config.sentry.dsn || read_attribute(:sentry_dsn)
- end
-
- def clientside_sentry_enabled
- Gitlab.config.sentry.enabled || read_attribute(:clientside_sentry_enabled)
- end
-
- def clientside_sentry_dsn
- Gitlab.config.sentry.clientside_dsn || read_attribute(:clientside_sentry_dsn)
- end
-
def performance_bar_allowed_group
Group.find_by_id(performance_bar_allowed_group_id)
end
diff --git a/app/models/board.rb b/app/models/board.rb
index e08db764f65..50b6ca9b70f 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -4,11 +4,14 @@ class Board < ApplicationRecord
belongs_to :group
belongs_to :project
- has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :lists, -> { ordered }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :destroyable_lists, -> { destroyable.ordered }, class_name: "List"
validates :project, presence: true, if: :project_needed?
validates :group, presence: true, unless: :project
+ scope :with_associations, -> { preload(:destroyable_lists) }
+
def project_needed?
!group
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 0fd8dca70b4..da4584228ce 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -45,7 +45,7 @@ class BroadcastMessage < ApplicationRecord
end
def self.cache_expires_in
- nil
+ 2.weeks
end
def active?
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 3727a9861aa..fd5aa216174 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -295,6 +295,11 @@ module Ci
end
end
+ def self.latest_for_shas(shas)
+ max_id_per_sha = for_sha(shas).group(:sha).select("max(id)")
+ where(id: max_id_per_sha)
+ end
+
def self.latest_successful_ids_per_project
success.group(:project_id).select('max(id) as id')
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index a1023f44049..1430b82c2f2 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -35,11 +35,8 @@ module Clusters
'stable/nginx-ingress'
end
- # We will implement this in future MRs.
- # Basically we need to check all dependent applications are not installed
- # first.
def allowed_to_uninstall?
- false
+ external_ip_or_hostname? && application_jupyter_nil_or_installable?
end
def install_command
@@ -52,6 +49,10 @@ module Clusters
)
end
+ def external_ip_or_hostname?
+ external_ip.present? || external_hostname.present?
+ end
+
def schedule_status_update
return unless installed?
return if external_ip
@@ -63,6 +64,12 @@ module Clusters
def ingress_service
cluster.kubeclient.get_service('ingress-nginx-ingress-controller', Gitlab::Kubernetes::Helm::NAMESPACE)
end
+
+ private
+
+ def application_jupyter_nil_or_installable?
+ cluster.application_jupyter.nil? || cluster.application_jupyter&.installable?
+ end
end
end
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 4aaa1f941e5..9ede0615fa3 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -23,9 +23,7 @@ module Clusters
return unless cluster&.application_ingress_available?
ingress = cluster.application_ingress
- if ingress.external_ip || ingress.external_hostname
- self.status = 'installable'
- end
+ self.status = 'installable' if ingress.external_ip_or_hostname?
end
def chart
@@ -40,12 +38,6 @@ module Clusters
content_values.to_yaml
end
- # Will be addressed in future MRs
- # We need to investigate and document what will be permanently deleted.
- def allowed_to_uninstall?
- false
- end
-
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index a6b7617b830..805c8a73f8c 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -49,14 +49,6 @@ module Clusters
)
end
- def uninstall_command
- Gitlab::Kubernetes::Helm::DeleteCommand.new(
- name: name,
- rbac: cluster.platform_kubernetes_rbac?,
- files: files
- )
- end
-
def upgrade_command(values)
::Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index db7fd8524c2..f0256ff4d41 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.5.2'.freeze
+ VERSION = '0.6.0'.freeze
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb
index d8a888d53ba..f21dbdf7f26 100644
--- a/app/models/clusters/instance.rb
+++ b/app/models/clusters/instance.rb
@@ -9,9 +9,5 @@ module Clusters
def feature_available?(feature)
::Feature.enabled?(feature, default_enabled: true)
end
-
- def self.enabled?
- ::Feature.enabled?(:instance_clusters, default_enabled: true)
- end
end
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 5afb193cf86..9296c28776b 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -4,7 +4,6 @@ module Clusters
module Platforms
class Kubernetes < ApplicationRecord
include Gitlab::Kubernetes
- include ReactiveCaching
include EnumWithNil
include AfterCommitQueue
@@ -46,8 +45,6 @@ module Clusters
validate :prevent_modification, on: :update
- after_save :clear_reactive_cache!
-
alias_attribute :ca_pem, :ca_cert
delegate :enabled?, to: :cluster, allow_nil: true
@@ -96,27 +93,16 @@ module Clusters
end
end
- # Constructs a list of terminals from the reactive cache
- #
- # Returns nil if the cache is empty, in which case you should try again a
- # short time later
- def terminals(environment)
- with_reactive_cache do |data|
- project = environment.project
-
- pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
- terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact
- terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
- end
- end
-
- # Caches resources in the namespace so other calls don't need to block on
- # network access
- def calculate_reactive_cache
+ def calculate_reactive_cache_for(environment)
return unless enabled?
- # We may want to cache extra things in the future
- { pods: read_pods }
+ { pods: read_pods(environment.deployment_namespace) }
+ end
+
+ def terminals(environment, data)
+ pods = filter_by_project_environment(data[:pods], environment.project.full_path_slug, environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, environment.deployment_namespace, pod) }.compact
+ terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
def kubeclient
@@ -133,6 +119,12 @@ module Clusters
ca_pem: ca_pem)
end
+ def read_pods(namespace)
+ kubeclient.get_pods(namespace: namespace).as_json
+ rescue Kubeclient::ResourceNotFoundError
+ []
+ end
+
def build_kube_client!
raise "Incomplete settings" unless api_url
@@ -148,19 +140,6 @@ module Clusters
)
end
- # Returns a hash of all pods in the namespace
- def read_pods
- # TODO: The project lookup here should be moved (to environment?),
- # which will enable reading pods from the correct namespace for group
- # and instance clusters.
- # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156
- return [] unless cluster.project_type?
-
- kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json
- rescue Kubeclient::ResourceNotFoundError
- []
- end
-
def kubeclient_ssl_options
opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER }
diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb
index 3d60f6924c1..8cbf4bcfaf7 100644
--- a/app/models/concerns/cacheable_attributes.rb
+++ b/app/models/concerns/cacheable_attributes.rb
@@ -36,7 +36,7 @@ module CacheableAttributes
end
def retrieve_from_cache
- record = Rails.cache.read(cache_key)
+ record = cache_backend.read(cache_key)
ensure_cache_setup if record.present?
record
@@ -58,7 +58,7 @@ module CacheableAttributes
end
def expire
- Rails.cache.delete(cache_key)
+ cache_backend.delete(cache_key)
rescue
# Gracefully handle when Redis is not available. For example,
# omnibus may fail here during gitlab:assets:compile.
@@ -69,9 +69,13 @@ module CacheableAttributes
# to be loaded when read from cache: https://github.com/rails/rails/issues/27348
define_attribute_methods
end
+
+ def cache_backend
+ Rails.cache
+ end
end
def cache!
- Rails.cache.write(self.class.cache_key, self, expires_in: 1.minute)
+ self.class.cache_backend.write(self.class.cache_key, self, expires_in: 1.minute)
end
end
diff --git a/app/models/concerns/deployable.rb b/app/models/concerns/deployable.rb
index bc12b06b5af..957b72f3721 100644
--- a/app/models/concerns/deployable.rb
+++ b/app/models/concerns/deployable.rb
@@ -18,6 +18,7 @@ module Deployable
return unless environment.persisted?
create_deployment!(
+ cluster_id: environment.deployment_platform&.cluster_id,
project_id: environment.project_id,
environment: environment,
ref: ref,
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index 1bd8a799f0d..5a358ae2ef6 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -13,8 +13,8 @@ module DeploymentPlatform
def find_deployment_platform(environment)
find_cluster_platform_kubernetes(environment: environment) ||
- find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) ||
- find_instance_cluster_platform_kubernetes_with_feature_guard(environment: environment)
+ find_group_cluster_platform_kubernetes(environment: environment) ||
+ find_instance_cluster_platform_kubernetes(environment: environment)
end
# EE would override this and utilize environment argument
@@ -23,24 +23,12 @@ module DeploymentPlatform
.last&.platform_kubernetes
end
- def find_group_cluster_platform_kubernetes_with_feature_guard(environment: nil)
- return unless group_clusters_enabled?
-
- find_group_cluster_platform_kubernetes(environment: environment)
- end
-
# EE would override this and utilize environment argument
def find_group_cluster_platform_kubernetes(environment: nil)
Clusters::Cluster.enabled.default_environment.ancestor_clusters_for_clusterable(self)
.first&.platform_kubernetes
end
- def find_instance_cluster_platform_kubernetes_with_feature_guard(environment: nil)
- return unless Clusters::Instance.enabled?
-
- find_instance_cluster_platform_kubernetes(environment: environment)
- end
-
# EE would override this and utilize environment argument
def find_instance_cluster_platform_kubernetes(environment: nil)
Clusters::Instance.new.clusters.enabled.default_environment
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 127430cc68f..299e413321d 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -29,7 +29,11 @@ module Issuable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
# lists avoiding n+1 queries and improving performance.
- IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count)
+ IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :mrs_count) do
+ def merge_requests_count(user = nil)
+ mrs_count
+ end
+ end
included do
cache_markdown_field :title, pipeline: :single_line
diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb
index 46d2c345758..22b6b1d720c 100644
--- a/app/models/concerns/relative_positioning.rb
+++ b/app/models/concerns/relative_positioning.rb
@@ -25,7 +25,7 @@ module RelativePositioning
relative_position = position_between(max_relative_position, MAX_POSITION)
object.relative_position = relative_position
max_relative_position = relative_position
- object.save
+ object.save(touch: false)
end
end
end
@@ -159,7 +159,7 @@ module RelativePositioning
def save_positionable_neighbours
return unless @positionable_neighbours
- status = @positionable_neighbours.all?(&:save)
+ status = @positionable_neighbours.all? { |issue| issue.save(touch: false) }
@positionable_neighbours = nil
status
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index 1f881249322..570a735973f 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -19,9 +19,9 @@
#
# - `statistic_attribute` must be an ActiveRecord attribute
# - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation
-#
module UpdateProjectStatistics
extend ActiveSupport::Concern
+ include AfterCommitQueue
class_methods do
attr_reader :project_statistics_name, :statistic_attribute
@@ -31,7 +31,6 @@ module UpdateProjectStatistics
#
# - project_statistics_name: A column of `ProjectStatistics` to update
# - statistic_attribute: An attribute of the current model, default to `size`
- #
def update_project_statistics(project_statistics_name:, statistic_attribute: :size)
@project_statistics_name = project_statistics_name
@statistic_attribute = statistic_attribute
@@ -51,6 +50,7 @@ module UpdateProjectStatistics
delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
update_project_statistics(delta)
+ schedule_namespace_aggregation_worker
end
def update_project_statistics_attribute_changed?
@@ -59,6 +59,8 @@ module UpdateProjectStatistics
def update_project_statistics_after_destroy
update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i)
+
+ schedule_namespace_aggregation_worker
end
def project_destroyed?
@@ -68,5 +70,18 @@ module UpdateProjectStatistics
def update_project_statistics(delta)
ProjectStatistics.increment_statistic(project_id, self.class.project_statistics_name, delta)
end
+
+ def schedule_namespace_aggregation_worker
+ run_after_commit do
+ next unless schedule_aggregation_worker?
+
+ Namespaces::ScheduleAggregationWorker.perform_async(project.namespace_id)
+ end
+ end
+
+ def schedule_aggregation_worker?
+ !project.nil? &&
+ Feature.enabled?(:update_statistics_namespace, project.root_ancestor)
+ end
end
end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index b0e570f52ba..33f0be91632 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -16,6 +16,14 @@ class DeployToken < ApplicationRecord
has_many :projects, through: :project_deploy_tokens
validate :ensure_at_least_one_scope
+ validates :username,
+ length: { maximum: 255 },
+ allow_nil: true,
+ format: {
+ with: /\A[a-zA-Z0-9\.\+_-]+\z/,
+ message: "can contain only letters, digits, '_', '-', '+', and '.'"
+ }
+
before_save :ensure_token
accepts_nested_attributes_for :project_deploy_tokens
@@ -39,7 +47,7 @@ class DeployToken < ApplicationRecord
end
def username
- "gitlab+deploy-token-#{id}"
+ super || default_username
end
def has_access_to?(requested_project)
@@ -75,4 +83,8 @@ class DeployToken < ApplicationRecord
def ensure_at_least_one_scope
errors.add(:base, "Scopes can't be blank") unless read_repository || read_registry
end
+
+ def default_username
+ "gitlab+deploy-token-#{id}" if persisted?
+ end
end
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index ee6e830d3ec..a8f5642f726 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -7,6 +7,7 @@ class Deployment < ApplicationRecord
belongs_to :project, required: true
belongs_to :environment, required: true
+ belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true
belongs_to :user
belongs_to :deployable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -84,12 +85,9 @@ class Deployment < ApplicationRecord
Commit.truncate_sha(sha)
end
- def cluster
- platform = project.deployment_platform(environment: environment.name)
-
- if platform.present? && platform.respond_to?(:cluster)
- platform.cluster
- end
+ # Deprecated - will be replaced by a persisted cluster_id
+ def deployment_platform_cluster
+ environment.deployment_platform&.cluster
end
def execute_hooks
@@ -199,7 +197,22 @@ class Deployment < ApplicationRecord
private
def prometheus_adapter
- environment.prometheus_adapter
+ service = project.find_or_initialize_service('prometheus')
+
+ if service.can_query?
+ service
+ else
+ cluster_prometheus
+ end
+ end
+
+ # TODO remove fallback case to deployment_platform_cluster.
+ # Otherwise we will continue to pay the performance penalty described in
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/63475
+ def cluster_prometheus
+ cluster_with_fallback = cluster || deployment_platform_cluster
+
+ cluster_with_fallback.application_prometheus if cluster_with_fallback&.application_prometheus_available?
end
def ref_path
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 1f7e8815c8e..b8ee54c1696 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -2,6 +2,8 @@
class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
+ include ReactiveCaching
+
# Used to generate random suffixes for the slug
LETTERS = ('a'..'z').freeze
NUMBERS = ('0'..'9').freeze
@@ -17,6 +19,7 @@ class Environment < ApplicationRecord
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
before_save :set_environment_type
+ after_save :clear_reactive_cache!
validates :name,
presence: true,
@@ -159,7 +162,21 @@ class Environment < ApplicationRecord
end
def terminals
- deployment_platform.terminals(self) if has_terminals?
+ with_reactive_cache do |data|
+ deployment_platform.terminals(self, data)
+ end
+ end
+
+ def calculate_reactive_cache
+ return unless has_terminals? && !project.pending_delete?
+
+ deployment_platform.calculate_reactive_cache_for(self)
+ end
+
+ def deployment_namespace
+ strong_memoize(:kubernetes_namespace) do
+ deployment_platform&.kubernetes_namespace_for(project)
+ end
end
def has_metrics?
diff --git a/app/models/group.rb b/app/models/group.rb
index dbec211935d..8e89c7ecfb1 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -410,10 +410,6 @@ class Group < Namespace
ensure_runners_token!
end
- def group_clusters_enabled?
- Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true)
- end
-
def project_creation_level
super || ::Gitlab::CurrentSettings.default_project_creation
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 30e29911758..982a94315bd 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -250,8 +250,8 @@ class Issue < ApplicationRecord
end
# rubocop: enable CodeReuse/ServiceClass
- def merge_requests_count
- merge_requests_closing_issues.count
+ def merge_requests_count(user = nil)
+ ::MergeRequestsClosingIssues.count_for_issue(self.id, user)
end
def labels_hook_attrs
diff --git a/app/models/list.rb b/app/models/list.rb
index 17b1a8510cf..d28a9bda82d 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -16,6 +16,7 @@ class List < ApplicationRecord
scope :destroyable, -> { where(list_type: list_types.slice(*destroyable_types).values) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
scope :preload_associations, -> { preload(:board, :label) }
+ scope :ordered, -> { order(:list_type, :position) }
class << self
def destroyable_types
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index df2dc9c49eb..8391d526d18 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1030,9 +1030,9 @@ class MergeRequest < ApplicationRecord
def mergeable_ci_state?
return true unless project.only_allow_merge_if_pipeline_succeeds?
- return true unless head_pipeline
+ return false unless actual_head_pipeline
- actual_head_pipeline&.success? || actual_head_pipeline&.skipped?
+ actual_head_pipeline.success? || actual_head_pipeline.skipped?
end
def environments_for(current_user)
@@ -1353,6 +1353,7 @@ class MergeRequest < ApplicationRecord
end
# TODO: remove once production database rename completes
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/47592
alias_attribute :allow_collaboration, :allow_maintainer_to_push
def allow_collaboration
diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb
index 61af50841ee..22cedf57b86 100644
--- a/app/models/merge_requests_closing_issues.rb
+++ b/app/models/merge_requests_closing_issues.rb
@@ -7,11 +7,38 @@ class MergeRequestsClosingIssues < ApplicationRecord
validates :merge_request_id, uniqueness: { scope: :issue_id }, presence: true
validates :issue_id, presence: true
+ scope :with_issues, ->(ids) { where(issue_id: ids) }
+ scope :with_merge_requests_enabled, -> do
+ joins(:merge_request)
+ .joins('INNER JOIN project_features ON merge_requests.target_project_id = project_features.project_id')
+ .where('project_features.merge_requests_access_level >= :access', access: ProjectFeature::ENABLED)
+ end
+
+ scope :accessible_by, ->(user) do
+ joins(:merge_request)
+ .joins('INNER JOIN project_features ON merge_requests.target_project_id = project_features.project_id')
+ .where('project_features.merge_requests_access_level >= :access OR EXISTS(:authorizations)',
+ access: ProjectFeature::ENABLED,
+ authorizations: user.authorizations_for_projects(min_access_level: Gitlab::Access::REPORTER, related_project_column: "merge_requests.target_project_id")
+ )
+ end
+
class << self
- def count_for_collection(ids)
- group(:issue_id)
- .where(issue_id: ids)
- .pluck('issue_id', 'COUNT(*) as count')
+ def count_for_collection(ids, current_user)
+ closing_merge_requests(ids, current_user).group(:issue_id).pluck('issue_id', 'COUNT(*) as count')
+ end
+
+ def count_for_issue(id, current_user)
+ closing_merge_requests(id, current_user).count
+ end
+
+ private
+
+ def closing_merge_requests(ids, current_user)
+ return with_issues(ids) if current_user&.admin?
+ return with_issues(ids).with_merge_requests_enabled if current_user.blank?
+
+ with_issues(ids).accessible_by(current_user)
end
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 3c270c7396a..af50293a179 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -35,6 +35,8 @@ class Namespace < ApplicationRecord
belongs_to :parent, class_name: "Namespace"
has_many :children, class_name: "Namespace", foreign_key: :parent_id
has_one :chat_team, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_one :root_storage_statistics, class_name: 'Namespace::RootStorageStatistics'
+ has_one :aggregation_schedule, class_name: 'Namespace::AggregationSchedule'
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
@@ -248,7 +250,9 @@ class Namespace < ApplicationRecord
end
def root_ancestor
- self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ strong_memoize(:root_ancestor) do
+ self_and_ancestors.reorder(nil).find_by(parent_id: nil)
+ end
end
def subgroup?
@@ -289,6 +293,10 @@ class Namespace < ApplicationRecord
end
end
+ def aggregation_scheduled?
+ aggregation_schedule.present?
+ end
+
private
def parent_changed?
diff --git a/app/models/namespace/aggregation_schedule.rb b/app/models/namespace/aggregation_schedule.rb
new file mode 100644
index 00000000000..355593597c6
--- /dev/null
+++ b/app/models/namespace/aggregation_schedule.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class Namespace::AggregationSchedule < ApplicationRecord
+ include AfterCommitQueue
+ include ExclusiveLeaseGuard
+
+ self.primary_key = :namespace_id
+
+ DEFAULT_LEASE_TIMEOUT = 3.hours
+ REDIS_SHARED_KEY = 'gitlab:update_namespace_statistics_delay'.freeze
+
+ belongs_to :namespace
+
+ after_create :schedule_root_storage_statistics
+
+ def self.delay_timeout
+ redis_timeout = Gitlab::Redis::SharedState.with do |redis|
+ redis.get(REDIS_SHARED_KEY)
+ end
+
+ redis_timeout.nil? ? DEFAULT_LEASE_TIMEOUT : redis_timeout.to_i
+ end
+
+ def schedule_root_storage_statistics
+ run_after_commit_or_now do
+ try_obtain_lease do
+ Namespaces::RootStatisticsWorker
+ .perform_async(namespace_id)
+
+ Namespaces::RootStatisticsWorker
+ .perform_in(self.class.delay_timeout, namespace_id)
+ end
+ end
+ end
+
+ private
+
+ # Used by ExclusiveLeaseGuard
+ def lease_timeout
+ self.class.delay_timeout
+ end
+
+ # Used by ExclusiveLeaseGuard
+ def lease_key
+ "namespace:namespaces_root_statistics:#{namespace_id}"
+ end
+end
diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb
new file mode 100644
index 00000000000..56c430013ee
--- /dev/null
+++ b/app/models/namespace/root_storage_statistics.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class Namespace::RootStorageStatistics < ApplicationRecord
+ STATISTICS_ATTRIBUTES = %w(storage_size repository_size wiki_size lfs_objects_size build_artifacts_size packages_size).freeze
+
+ self.primary_key = :namespace_id
+
+ belongs_to :namespace
+ has_one :route, through: :namespace
+
+ delegate :all_projects, to: :namespace
+
+ def recalculate!
+ update!(attributes_from_project_statistics)
+ end
+
+ private
+
+ def attributes_from_project_statistics
+ from_project_statistics
+ .take
+ .attributes
+ .slice(*STATISTICS_ATTRIBUTES)
+ end
+
+ def from_project_statistics
+ all_projects
+ .joins('INNER JOIN project_statistics ps ON ps.project_id = projects.id')
+ .select(
+ 'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
+ 'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
+ 'COALESCE(SUM(ps.wiki_size), 0) AS wiki_size',
+ 'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
+ )
+ end
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index b55af7d9b5e..4e9ea146485 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -452,7 +452,7 @@ class Note < ApplicationRecord
noteable_object&.touch
- # We return the noteable object so we can re-use it in EE for ElasticSearch.
+ # We return the noteable object so we can re-use it in EE for Elasticsearch.
noteable_object
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 07195c0bfd3..d6d879c6d89 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -3,6 +3,7 @@
class PagesDomain < ApplicationRecord
VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
VERIFICATION_THRESHOLD = 3.days.freeze
+ SSL_RENEWAL_THRESHOLD = 30.days.freeze
enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate
@@ -41,6 +42,15 @@ class PagesDomain < ApplicationRecord
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
+ scope :need_auto_ssl_renewal, -> do
+ expiring = where(certificate_valid_not_after: nil).or(
+ where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now)))
+
+ user_provided_or_expiring = certificate_user_provided.or(expiring)
+
+ where(auto_ssl_enabled: true).merge(user_provided_or_expiring)
+ end
+
scope :for_removal, -> { where("remove_at < ?", Time.now) }
def verified?
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index 74ccf23cf69..7a123deb719 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -28,7 +28,7 @@ module Postgresql
# We force the use of a transaction here so the query always goes to the
# primary, even when using the EE DB load balancer.
sizes = transaction { pluck(lag_function) }
- too_great = sizes.count { |size| size >= max }
+ too_great = sizes.compact.count { |size| size >= max }
# If too many replicas are falling behind too much, the availability of a
# GitLab instance might suffer. To prevent this from happening we require
diff --git a/app/models/project.rb b/app/models/project.rb
index 351d08eaf63..0f4fba5d0b6 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -306,7 +306,6 @@ 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 :group_clusters_enabled?, to: :group, 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
@@ -1446,11 +1445,6 @@ class Project < ApplicationRecord
end
def in_fork_network_of?(other_project)
- # TODO: Remove this in a next release when all fork_networks are populated
- # This makes sure all MergeRequests remain valid while the projects don't
- # have a fork_network yet.
- return true if forked_from?(other_project)
-
return false if fork_network.nil? || other_project.fork_network.nil?
fork_network == other_project.fork_network
@@ -1949,9 +1943,8 @@ class Project < ApplicationRecord
end
end
- # Overridden on EE module
def multiple_issue_boards_available?
- false
+ true
end
def full_path_before_last_save
diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb
index 1a2bb6a171b..8b79b5e9f0c 100644
--- a/app/models/project_services/bugzilla_service.rb
+++ b/app/models/project_services/bugzilla_service.rb
@@ -3,22 +3,14 @@
class BugzillaService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Bugzilla'
- end
+ def default_title
+ 'Bugzilla'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Bugzilla issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Bugzilla issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb
index b8f8072869c..535fcf6b94e 100644
--- a/app/models/project_services/custom_issue_tracker_service.rb
+++ b/app/models/project_services/custom_issue_tracker_service.rb
@@ -5,24 +5,12 @@ class CustomIssueTrackerService < IssueTrackerService
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Custom Issue Tracker'
- end
+ def default_title
+ 'Custom Issue Tracker'
end
- def title=(value)
- self.properties['title'] = value if self.properties
- end
-
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Custom issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Custom issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb
index fa9abf58e62..51032932eab 100644
--- a/app/models/project_services/gitlab_issue_tracker_service.rb
+++ b/app/models/project_services/gitlab_issue_tracker_service.rb
@@ -5,10 +5,18 @@ class GitlabIssueTrackerService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
default_value_for :default, true
+ def default_title
+ 'GitLab'
+ end
+
+ def default_description
+ s_('IssueTracker|GitLab issue tracker')
+ end
+
def self.to_param
'gitlab'
end
diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb
index f54497fc6d8..3a1130ffc15 100644
--- a/app/models/project_services/issue_tracker_service.rb
+++ b/app/models/project_services/issue_tracker_service.rb
@@ -5,6 +5,8 @@ class IssueTrackerService < Service
default_value_for :category, 'issue_tracker'
+ before_save :handle_properties
+
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
# This pattern does not support cross-project references
@@ -18,6 +20,37 @@ class IssueTrackerService < Service
end
end
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084
+ def title
+ if title_attribute = read_attribute(:title)
+ title_attribute
+ elsif self.properties && self.properties['title'].present?
+ self.properties['title']
+ else
+ default_title
+ end
+ end
+
+ # this will be removed as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/63084
+ def description
+ if description_attribute = read_attribute(:description)
+ description_attribute
+ elsif self.properties && self.properties['description'].present?
+ self.properties['description']
+ else
+ default_description
+ end
+ end
+
+ def handle_properties
+ properties.slice('title', 'description').each do |key, _|
+ current_value = self.properties.delete(key)
+ value = attribute_changed?(key) ? attribute_change(key).last : current_value
+
+ write_attribute(key, value)
+ end
+ end
+
def default?
default
end
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 7b4832b84a8..a3b89b2543a 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -14,17 +14,17 @@ class JiraService < IssueTrackerService
format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") },
allow_blank: true
- # JIRA cloud version is deprecating authentication via username and password.
- # We should use username/password for JIRA server and email/api_token for JIRA cloud,
+ # Jira Cloud version is deprecating authentication via username and password.
+ # We should use username/password for Jira Server and email/api_token for Jira Cloud,
# for more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/49936.
- prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id, :title, :description
+ prop_accessor :username, :password, :url, :api_url, :jira_issue_transition_id
before_update :reset_password
alias_method :project_url, :url
# When these are false GitLab does not create cross reference
- # comments on JIRA except when an issue gets transitioned.
+ # comments on Jira except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
@@ -37,7 +37,6 @@ class JiraService < IssueTrackerService
def initialize_properties
super do
self.properties = {
- title: issues_tracker['title'],
url: issues_tracker['url'],
api_url: issues_tracker['api_url']
}
@@ -69,25 +68,17 @@ class JiraService < IssueTrackerService
end
def help
- "You need to configure JIRA before enabling this service. For more details
+ "You need to configure Jira before enabling this service. For more details
read the
- [JIRA service documentation](#{help_page_url('user/project/integrations/jira')})."
+ [Jira service documentation](#{help_page_url('user/project/integrations/jira')})."
end
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'JIRA'
- end
+ def default_title
+ 'Jira'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- s_('JiraService|Jira issue tracker')
- end
+ def default_description
+ s_('JiraService|Jira issue tracker')
end
def self.to_param
@@ -97,7 +88,7 @@ class JiraService < IssueTrackerService
def fields
[
{ type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true },
- { type: 'text', name: 'api_url', title: s_('JiraService|JIRA API URL'), placeholder: s_('JiraService|If different from Web URL') },
+ { type: 'text', name: 'api_url', title: s_('JiraService|Jira API URL'), placeholder: s_('JiraService|If different from Web URL') },
{ type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true },
{ type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true },
{ type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') }
@@ -130,7 +121,7 @@ class JiraService < IssueTrackerService
commit_url = build_entity_url(:commit, commit_id)
- # Depending on the JIRA project's workflow, a comment during transition
+ # Depending on the Jira project's workflow, a comment during transition
# may or may not be allowed. Refresh the issue after transition and check
# if it is closed, so we don't have one comment for every commit.
issue = jira_request { client.Issue.find(issue.key) } if transition_issue(issue)
@@ -177,7 +168,7 @@ class JiraService < IssueTrackerService
{ success: success, result: result }
end
- # JIRA does not need test data.
+ # Jira does not need test data.
# We are requesting the project that belongs to the project key.
def test_data(user = nil, project = nil)
nil
@@ -313,7 +304,7 @@ class JiraService < IssueTrackerService
name == "project_snippet" ? "snippet" : name
end
- # Handle errors when doing JIRA API calls
+ # Handle errors when doing Jira API calls
def jira_request
yield
@@ -339,9 +330,9 @@ class JiraService < IssueTrackerService
def self.event_description(event)
case event
when "merge_request", "merge_request_events"
- s_("JiraService|JIRA comments will be created when an issue gets referenced in a merge request.")
+ s_("JiraService|Jira comments will be created when an issue gets referenced in a merge request.")
when "commit", "commit_events"
- s_("JiraService|JIRA comments will be created when an issue gets referenced in a commit.")
+ s_("JiraService|Jira comments will be created when an issue gets referenced in a commit.")
end
end
end
diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb
index a80be4b06da..5ca057ca833 100644
--- a/app/models/project_services/redmine_service.rb
+++ b/app/models/project_services/redmine_service.rb
@@ -3,22 +3,14 @@
class RedmineService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
- prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
+ prop_accessor :project_url, :issues_url, :new_issue_url
- def title
- if self.properties && self.properties['title'].present?
- self.properties['title']
- else
- 'Redmine'
- end
+ def default_title
+ 'Redmine'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'Redmine issue tracker'
- end
+ def default_description
+ s_('IssueTracker|Redmine issue tracker')
end
def self.to_param
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 175c2ebf197..f9de1f7dc49 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -3,7 +3,7 @@
class YoutrackService < IssueTrackerService
validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
- prop_accessor :description, :project_url, :issues_url
+ prop_accessor :project_url, :issues_url
# {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030
def self.reference_pattern(only_long: false)
@@ -14,16 +14,12 @@ class YoutrackService < IssueTrackerService
end
end
- def title
+ def default_title
'YouTrack'
end
- def description
- if self.properties && self.properties['description'].present?
- self.properties['description']
- else
- 'YouTrack issue tracker'
- end
+ def default_description
+ s_('IssueTracker|YouTrack issue tracker')
end
def self.to_param
diff --git a/app/models/release.rb b/app/models/release.rb
index 7bbeb3c9976..459a7c29ad0 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -12,12 +12,16 @@ class Release < ApplicationRecord
has_many :links, class_name: 'Releases::Link'
+ default_value_for :released_at, allows_nil: false do
+ Time.zone.now
+ end
+
accepts_nested_attributes_for :links, allow_destroy: true
validates :description, :project, :tag, presence: true
validates :name, presence: true, on: :create
- scope :sorted, -> { order(created_at: :desc) }
+ scope :sorted, -> { order(released_at: :desc) }
delegate :repository, to: :project
@@ -44,6 +48,10 @@ class Release < ApplicationRecord
end
end
+ def upcoming_release?
+ released_at.present? && released_at > Time.zone.now
+ end
+
private
def actual_sha
diff --git a/app/models/repository.rb b/app/models/repository.rb
index e05d3dd58ac..d087a5a7bbd 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -6,7 +6,6 @@ class Repository
REF_MERGE_REQUEST = 'merge-requests'.freeze
REF_KEEP_AROUND = 'keep-around'.freeze
REF_ENVIRONMENTS = 'environments'.freeze
- MAX_DIVERGING_COUNT = 1000
RESERVED_REFS_NAMES = %W[
heads
@@ -282,46 +281,6 @@ class Repository
ref_exists?(keep_around_ref_name(sha))
end
- def diverging_commit_counts(branch)
- return diverging_commit_counts_without_max(branch) if Feature.enabled?('gitaly_count_diverging_commits_no_max')
-
- ## TODO: deprecate the below code after 12.0
- @root_ref_hash ||= raw_repository.commit(root_ref).id
- cache.fetch(:"diverging_commit_counts_#{branch.name}") do
- # Rugged seems to throw a `ReferenceError` when given branch_names rather
- # than SHA-1 hashes
- branch_sha = branch.dereferenced_target.sha
-
- number_commits_behind, number_commits_ahead =
- raw_repository.diverging_commit_count(
- @root_ref_hash,
- branch_sha,
- max_count: MAX_DIVERGING_COUNT)
-
- if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT
- { distance: MAX_DIVERGING_COUNT }
- else
- { behind: number_commits_behind, ahead: number_commits_ahead }
- end
- end
- end
-
- def diverging_commit_counts_without_max(branch)
- @root_ref_hash ||= raw_repository.commit(root_ref).id
- cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do
- # Rugged seems to throw a `ReferenceError` when given branch_names rather
- # than SHA-1 hashes
- branch_sha = branch.dereferenced_target.sha
-
- number_commits_behind, number_commits_ahead =
- raw_repository.diverging_commit_count(
- @root_ref_hash,
- branch_sha)
-
- { behind: number_commits_behind, ahead: number_commits_ahead }
- end
- end
-
def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil)
raw_repository.archive_metadata(
ref,
diff --git a/app/models/service.rb b/app/models/service.rb
index 40033003f3b..752467622f2 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -129,7 +129,7 @@ class Service < ApplicationRecord
def api_field_names
fields.map { |field| field[:name] }
- .reject { |field_name| field_name =~ /(password|token|key)/ }
+ .reject { |field_name| field_name =~ /(password|token|key|title|description)/ }
end
def global_fields
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index f4fdac2558c..00931457344 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -194,6 +194,10 @@ class Snippet < ApplicationRecord
'snippet'
end
+ def to_ability_name
+ model_name.singular
+ end
+
class << self
# Searches for snippets with a matching title or file name.
#
diff --git a/app/models/todo.rb b/app/models/todo.rb
index f1fc5e599eb..240c91da5b6 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -60,7 +60,7 @@ class Todo < ApplicationRecord
scope :for_type, -> (type) { where(target_type: type) }
scope :for_target, -> (id) { where(target_id: id) }
scope :for_commit, -> (id) { where(commit_id: id) }
- scope :with_api_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) }
+ scope :with_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) }
scope :joins_issue_and_assignees, -> { left_joins(issue: :assignees) }
state_machine :state, initial: :pending do
diff --git a/app/models/user.rb b/app/models/user.rb
index 38cb4d1a6e8..26be197209a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1460,7 +1460,7 @@ class User < ApplicationRecord
end
def requires_usage_stats_consent?
- !consented_usage_stats? && 7.days.ago > self.created_at && !has_current_license? && User.single_user?
+ self.admin? && 7.days.ago > self.created_at && !has_current_license? && User.single_user? && !consented_usage_stats?
end
# Avoid migrations only building user preference object when needed.
@@ -1495,7 +1495,14 @@ class User < ApplicationRecord
end
def consented_usage_stats?
- Gitlab::CurrentSettings.usage_stats_set_by_user_id == self.id
+ # Bypass the cache here because it's possible the admin enabled the
+ # usage ping, and we don't want to annoy the user again if they
+ # already set the value. This is a bit of hack, but the alternative
+ # would be to put in a more complex cache invalidation step. Since
+ # this call only gets called in the uncommon situation where the
+ # user is an admin and the only user in the instance, this shouldn't
+ # cause too much load on the system.
+ ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id
end
# Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
diff --git a/app/policies/award_emoji_policy.rb b/app/policies/award_emoji_policy.rb
new file mode 100644
index 00000000000..21e382e24b3
--- /dev/null
+++ b/app/policies/award_emoji_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AwardEmojiPolicy < BasePolicy
+ delegate { @subject.awardable if DeclarativePolicy.has_policy?(@subject.awardable) }
+
+ condition(:can_read_awardable) do
+ can?(:"read_#{@subject.awardable.to_ability_name}")
+ end
+
+ rule { can_read_awardable }.enable :read_emoji
+end
diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb
index e1045c85e6d..f72096e8fc6 100644
--- a/app/policies/clusters/instance_policy.rb
+++ b/app/policies/clusters/instance_policy.rb
@@ -6,9 +6,8 @@ module Clusters
condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
condition(:can_have_multiple_clusters) { multiple_clusters_available? }
- condition(:instance_clusters_enabled) { Instance.enabled? }
- rule { admin & instance_clusters_enabled }.policy do
+ rule { admin }.policy do
enable :read_cluster
enable :add_cluster
enable :create_cluster
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 08bfe5d14ee..3c9ffbb2065 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -196,6 +196,7 @@ class ProjectPolicy < BasePolicy
rule { guest & can?(:read_container_image) }.enable :build_read_container_image
rule { can?(:reporter_access) }.policy do
+ enable :admin_board
enable :download_code
enable :read_statistics
enable :download_wiki_code
@@ -240,6 +241,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
rule { can?(:developer_access) }.policy do
+ enable :admin_board
enable :admin_merge_request
enable :admin_milestone
enable :update_merge_request
@@ -266,6 +268,7 @@ class ProjectPolicy < BasePolicy
end
rule { can?(:maintainer_access) }.policy do
+ enable :admin_board
enable :push_to_delete_protected_branch
enable :update_project_snippet
enable :update_environment
diff --git a/app/policies/repository_policy.rb b/app/policies/repository_policy.rb
new file mode 100644
index 00000000000..32340749858
--- /dev/null
+++ b/app/policies/repository_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class RepositoryPolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/presenters/award_emoji_presenter.rb b/app/presenters/award_emoji_presenter.rb
new file mode 100644
index 00000000000..98713855d35
--- /dev/null
+++ b/app/presenters/award_emoji_presenter.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class AwardEmojiPresenter < Gitlab::View::Presenter::Delegated
+ presents :award_emoji
+
+ def description
+ as_emoji['description']
+ end
+
+ def unicode
+ as_emoji['unicode']
+ end
+
+ def emoji
+ as_emoji['moji']
+ end
+
+ def unicode_version
+ Gitlab::Emoji.emoji_unicode_version(award_emoji.name)
+ end
+
+ private
+
+ def as_emoji
+ @emoji ||= Gitlab::Emoji.emojis[award_emoji.name] || {}
+ end
+end
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
index 05adbe1d4f5..fc9853733c1 100644
--- a/app/presenters/commit_presenter.rb
+++ b/app/presenters/commit_presenter.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
-class CommitPresenter < Gitlab::View::Presenter::Simple
+class CommitPresenter < Gitlab::View::Presenter::Delegated
+ include GlobalID::Identification
+
presents :commit
def status_for(ref)
@@ -10,4 +12,8 @@ class CommitPresenter < Gitlab::View::Presenter::Simple
def any_pipelines?
can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any?
end
+
+ def web_url
+ Gitlab::UrlBuilder.new(commit).url
+ end
end
diff --git a/app/serializers/board_simple_entity.rb b/app/serializers/board_simple_entity.rb
index f297d993e27..029d3808e75 100644
--- a/app/serializers/board_simple_entity.rb
+++ b/app/serializers/board_simple_entity.rb
@@ -2,4 +2,5 @@
class BoardSimpleEntity < Grape::Entity
expose :id
+ expose :name
end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 43aced598a9..fd2673fa0cc 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -217,8 +217,12 @@ class MergeRequestWidgetEntity < IssuableEntity
project_merge_request_path(merge_request.project, merge_request, format: :diff)
end
- expose :status_path do |merge_request|
- project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+ expose :merge_request_basic_path do |merge_request|
+ project_merge_request_path(merge_request.target_project, merge_request, serializer: :basic, format: :json)
+ end
+
+ expose :merge_request_widget_path do |merge_request|
+ widget_project_json_merge_request_path(merge_request.target_project, merge_request, format: :json)
end
expose :ci_environments_status_path do |merge_request|
diff --git a/app/services/boards/create_service.rb b/app/services/boards/create_service.rb
index 1b796cef3e2..dd9358913fd 100644
--- a/app/services/boards/create_service.rb
+++ b/app/services/boards/create_service.rb
@@ -9,7 +9,7 @@ module Boards
private
def can_create_board?
- parent.boards.empty?
+ parent.boards.empty? || parent.multiple_issue_boards_available?
end
def create_board!
diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb
new file mode 100644
index 00000000000..ea0c1394aa3
--- /dev/null
+++ b/app/services/boards/destroy_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Boards
+ class DestroyService < Boards::BaseService
+ def execute(board)
+ return false if parent.boards.size == 1
+
+ board.destroy
+ end
+ end
+end
diff --git a/app/services/boards/update_service.rb b/app/services/boards/update_service.rb
new file mode 100644
index 00000000000..88aced01ccd
--- /dev/null
+++ b/app/services/boards/update_service.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Boards
+ class UpdateService < Boards::BaseService
+ def execute(board)
+ board.update(params)
+ end
+ end
+end
diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb
deleted file mode 100644
index d13e25b4f12..00000000000
--- a/app/services/boards/visits/latest_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Boards
- module Visits
- class LatestService < Boards::BaseService
- def execute
- return unless current_user
-
- recent_visit_model.latest(current_user, parent, count: params[:count])
- end
-
- private
-
- def recent_visit_model
- parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit
- end
- end
- end
-end
diff --git a/app/services/branches/diverging_commit_counts_service.rb b/app/services/branches/diverging_commit_counts_service.rb
new file mode 100644
index 00000000000..a3404caf2d7
--- /dev/null
+++ b/app/services/branches/diverging_commit_counts_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Branches
+ class DivergingCommitCountsService
+ def initialize(repository)
+ @repository = repository
+ @cache = Gitlab::RepositoryCache.new(repository)
+ end
+
+ def call(branch)
+ diverging_commit_counts(branch)
+ end
+
+ private
+
+ attr_reader :repository, :cache
+
+ delegate :raw_repository, to: :repository
+
+ def diverging_commit_counts(branch)
+ @root_ref_hash ||= raw_repository.commit(repository.root_ref).id
+ cache.fetch(:"diverging_commit_counts_#{branch.name}") do
+ number_commits_behind, number_commits_ahead =
+ raw_repository.diverging_commit_count(
+ @root_ref_hash,
+ branch.dereferenced_target.sha)
+
+ { behind: number_commits_behind, ahead: number_commits_ahead }
+ end
+ end
+ end
+end
diff --git a/app/services/deploy_tokens/create_service.rb b/app/services/deploy_tokens/create_service.rb
index dc0122002e9..327a1dbf408 100644
--- a/app/services/deploy_tokens/create_service.rb
+++ b/app/services/deploy_tokens/create_service.rb
@@ -3,7 +3,9 @@
module DeployTokens
class CreateService < BaseService
def execute
- @project.deploy_tokens.create(params)
+ @project.deploy_tokens.create(params) do |deploy_token|
+ deploy_token.username = params[:username].presence
+ end
end
end
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 26132f1824a..02de080e0ba 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -205,7 +205,7 @@ class IssuableBaseService < BaseService
end
if issuable.changed? || params.present?
- issuable.assign_attributes(params.merge(updated_by: current_user))
+ issuable.assign_attributes(params)
if has_title_or_description_changed?(issuable)
issuable.assign_attributes(last_edited_at: Time.now, last_edited_by: current_user)
@@ -213,11 +213,16 @@ class IssuableBaseService < BaseService
before_update(issuable)
+ # Do not touch when saving the issuable if only changes position within a list. We should call
+ # this method at this point to capture all possible changes.
+ should_touch = update_timestamp?(issuable)
+
+ issuable.updated_by = current_user if should_touch
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
update_project_counters = issuable.project && update_project_counter_caches?(issuable)
- if issuable.with_transaction_returning_status { issuable.save }
+ if issuable.with_transaction_returning_status { issuable.save(touch: should_touch) }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
@@ -402,4 +407,8 @@ class IssuableBaseService < BaseService
def ensure_milestone_available(issuable)
issuable.milestone_id = nil unless issuable.milestone_available?
end
+
+ def update_timestamp?(issuable)
+ issuable.changes.keys != ["relative_position"]
+ end
end
diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb
index e69791872cc..2a217a6f689 100644
--- a/app/services/merge_requests/create_from_issue_service.rb
+++ b/app/services/merge_requests/create_from_issue_service.rb
@@ -6,17 +6,20 @@ module MergeRequests
# branch - the name of new branch
# ref - the source of new branch.
- @branch_name = params[:branch_name]
- @issue_iid = params[:issue_iid]
- @ref = params[:ref]
+ @branch_name = params[:branch_name]
+ @issue_iid = params[:issue_iid]
+ @ref = params[:ref]
+ @target_project_id = params[:target_project_id]
super(project, user)
end
def execute
+ return error('Project not found') if target_project.blank?
+ return error('Not allowed to create merge request') unless can_create_merge_request?
return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
- result = CreateBranchService.new(project, current_user).execute(branch_name, ref)
+ result = CreateBranchService.new(target_project, current_user).execute(branch_name, ref)
return result if result[:status] == :error
new_merge_request = create(merge_request)
@@ -26,7 +29,7 @@ module MergeRequests
success(new_merge_request)
else
- SystemNoteService.new_issue_branch(issue, project, current_user, branch_name)
+ SystemNoteService.new_issue_branch(issue, project, current_user, branch_name, branch_project: target_project)
error(new_merge_request.errors)
end
@@ -34,6 +37,10 @@ module MergeRequests
private
+ def can_create_merge_request?
+ can?(current_user, :create_merge_request_from, target_project)
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def issue
@issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid)
@@ -45,21 +52,21 @@ module MergeRequests
end
def ref
- return @ref if project.repository.branch_exists?(@ref)
+ return @ref if target_project.repository.branch_exists?(@ref)
- project.default_branch || 'master'
+ target_project.default_branch || 'master'
end
def merge_request
- MergeRequests::BuildService.new(project, current_user, merge_request_params).execute
+ MergeRequests::BuildService.new(target_project, current_user, merge_request_params).execute
end
def merge_request_params
{
issue_iid: @issue_iid,
- source_project_id: project.id,
+ source_project_id: target_project.id,
source_branch: branch_name,
- target_project_id: project.id,
+ target_project_id: target_project.id,
target_branch: ref
}
end
@@ -67,5 +74,14 @@ module MergeRequests
def success(merge_request)
super().merge(merge_request: merge_request)
end
+
+ def target_project
+ @target_project ||=
+ if @target_project_id.present?
+ project.forks.find_by_id(@target_project_id)
+ else
+ project
+ end
+ end
end
end
diff --git a/app/services/namespaces/statistics_refresher_service.rb b/app/services/namespaces/statistics_refresher_service.rb
new file mode 100644
index 00000000000..c07b302839b
--- /dev/null
+++ b/app/services/namespaces/statistics_refresher_service.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class StatisticsRefresherService
+ RefresherError = Class.new(StandardError)
+
+ def execute(root_namespace)
+ root_storage_statistics = find_or_create_root_storage_statistics(root_namespace.id)
+
+ root_storage_statistics.recalculate!
+ rescue ActiveRecord::ActiveRecordError => e
+ raise RefresherError.new(e.message)
+ end
+
+ private
+
+ def find_or_create_root_storage_statistics(root_namespace_id)
+ Namespace::RootStorageStatistics
+ .safe_find_or_create_by!(namespace_id: root_namespace_id)
+ end
+ end
+end
diff --git a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
index 3413a9e4612..58f795e639e 100644
--- a/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
+++ b/app/services/pages_domains/obtain_lets_encrypt_certificate_service.rb
@@ -2,6 +2,14 @@
module PagesDomains
class ObtainLetsEncryptCertificateService
+ # time for processing validation requests for acme challenges
+ # 5-15 seconds is usually enough
+ CHALLENGE_PROCESSING_DELAY = 1.minute.freeze
+
+ # time LetsEncrypt ACME server needs to generate the certificate
+ # no particular SLA, usually takes 10-15 seconds
+ CERTIFICATE_PROCESSING_DELAY = 1.minute.freeze
+
attr_reader :pages_domain
def initialize(pages_domain)
@@ -14,6 +22,7 @@ module PagesDomains
unless acme_order
::PagesDomains::CreateAcmeOrderService.new(pages_domain).execute
+ PagesDomainSslRenewalWorker.perform_in(CHALLENGE_PROCESSING_DELAY, pages_domain.id)
return
end
@@ -23,6 +32,7 @@ module PagesDomains
case api_order.status
when 'ready'
api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain)
+ PagesDomainSslRenewalWorker.perform_in(CERTIFICATE_PROCESSING_DELAY, pages_domain.id)
when 'valid'
save_certificate(acme_order.private_key, api_order)
acme_order.destroy!
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index a2f36d2bd1b..a25c985585b 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -24,7 +24,7 @@ module Projects
def propagate_projects_with_template
loop do
- batch = project_ids_batch
+ batch = Project.uncached { project_ids_batch }
bulk_create_from_template(batch) unless batch.empty?
diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb
index ff6b696ca96..618d96717b8 100644
--- a/app/services/releases/concerns.rb
+++ b/app/services/releases/concerns.rb
@@ -22,6 +22,10 @@ module Releases
params[:description]
end
+ def released_at
+ params[:released_at]
+ end
+
def release
strong_memoize(:release) do
project.releases.find_by_tag(tag_name)
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
index a271a7e5e49..5b13ac631ba 100644
--- a/app/services/releases/create_service.rb
+++ b/app/services/releases/create_service.rb
@@ -58,6 +58,7 @@ module Releases
author: current_user,
tag: tag.name,
sha: tag.dereferenced_target.sha,
+ released_at: released_at,
links_attributes: params.dig(:assets, 'links') || []
)
end
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 1390f7cdf46..237ddbcf2c2 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -249,7 +249,7 @@ module SystemNoteService
end
def resolve_all_discussions(merge_request, project, author)
- body = "resolved all discussions"
+ body = "resolved all threads"
create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion'))
end
@@ -404,8 +404,9 @@ module SystemNoteService
# Example note text:
#
# "created branch `201-issue-branch-button`"
- def new_issue_branch(issue, project, author, branch)
- link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch)
+ def new_issue_branch(issue, project, author, branch, branch_project: nil)
+ branch_project ||= project
+ link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch)
body = "created branch [`#{branch}`](#{link}) to address this issue"
@@ -413,7 +414,7 @@ module SystemNoteService
end
def new_merge_request(issue, project, author, merge_request)
- body = "created merge request #{merge_request.to_reference} to address this issue"
+ body = "created merge request #{merge_request.to_reference(project)} to address this issue"
create_note(NoteSummary.new(issue, project, author, body, action: 'merge'))
end
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index 15c13a452ad..8f52e9cb23f 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -63,12 +63,20 @@ module Users
def assign_identity
return unless identity_params.present?
- identity = user.identities.find_or_create_by(provider: identity_params[:provider]) # rubocop: disable CodeReuse/ActiveRecord
+ identity = user.identities.find_or_create_by(provider_params) # rubocop: disable CodeReuse/ActiveRecord
identity.update(identity_params)
end
def identity_attributes
[:provider, :extern_uid]
end
+
+ def provider_attributes
+ [:provider]
+ end
+
+ def provider_params
+ identity_params.slice(*provider_attributes)
+ end
end
end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index 236b7ed2b3d..12be1e2bb22 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -1,22 +1,29 @@
# frozen_string_literal: true
class FileMover
- attr_reader :secret, :file_name, :model, :update_field
+ include Gitlab::Utils::StrongMemoize
- def initialize(file_path, model, update_field = :description)
+ attr_reader :secret, :file_name, :from_model, :to_model, :update_field
+
+ def initialize(file_path, update_field = :description, from_model:, to_model:)
@secret = File.split(File.dirname(file_path)).last
@file_name = File.basename(file_path)
- @model = model
+ @from_model = from_model
+ @to_model = to_model
@update_field = update_field
end
def execute
+ temp_file_uploader.retrieve_from_store!(file_name)
+
return unless valid?
+ uploader.retrieve_from_store!(file_name)
+
move
if update_markdown
- uploader.record_upload
+ update_upload_model
uploader.schedule_background_upload
end
end
@@ -24,52 +31,77 @@ class FileMover
private
def valid?
- Pathname.new(temp_file_path).realpath.to_path.start_with?(
- (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path
- )
+ if temp_file_uploader.file_storage?
+ Pathname.new(temp_file_path).realpath.to_path.start_with?(
+ (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path
+ )
+ else
+ temp_file_uploader.exists?
+ end
end
def move
- FileUtils.mkdir_p(File.dirname(file_path))
- FileUtils.move(temp_file_path, file_path)
+ if temp_file_uploader.file_storage?
+ FileUtils.mkdir_p(File.dirname(file_path))
+ FileUtils.move(temp_file_path, file_path)
+ else
+ uploader.copy_file(temp_file_uploader.file)
+ temp_file_uploader.upload.destroy!
+ end
end
def update_markdown
- updated_text = model.read_attribute(update_field)
- .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
- model.update_attribute(update_field, updated_text)
+ updated_text = to_model.read_attribute(update_field)
+ .gsub(temp_file_uploader.markdown_link, uploader.markdown_link)
+ to_model.update_attribute(update_field, updated_text)
rescue
revert
false
end
- def temp_file_path
- return @temp_file_path if @temp_file_path
+ def update_upload_model
+ return unless upload = temp_file_uploader.upload
+ return if upload.destroyed?
- temp_file_uploader.retrieve_from_store!(file_name)
+ upload.update!(model: to_model)
+ end
- @temp_file_path = temp_file_uploader.file.path
+ def temp_file_path
+ strong_memoize(:temp_file_path) do
+ temp_file_uploader.file.path
+ end
end
def file_path
- return @file_path if @file_path
-
- uploader.retrieve_from_store!(file_name)
-
- @file_path = uploader.file.path
+ strong_memoize(:file_path) do
+ uploader.file.path
+ end
end
def uploader
- @uploader ||= PersonalFileUploader.new(model, secret: secret)
+ @uploader ||=
+ begin
+ uploader = PersonalFileUploader.new(to_model, secret: secret)
+
+ # Enforcing a REMOTE object storage given FileUploader#retrieve_from_store! won't do it
+ # (there's no upload at the target yet).
+ if uploader.class.object_store_enabled?
+ uploader.object_store = ::ObjectStorage::Store::REMOTE
+ end
+
+ uploader
+ end
end
def temp_file_uploader
- @temp_file_uploader ||= PersonalFileUploader.new(nil, secret: secret)
+ @temp_file_uploader ||= PersonalFileUploader.new(from_model, secret: secret)
end
def revert
- Rails.logger.warn("Markdown not updated, file move reverted for #{model}")
+ Rails.logger.warn("Markdown not updated, file move reverted for #{to_model}")
- FileUtils.move(file_path, temp_file_path)
+ if temp_file_uploader.file_storage?
+ FileUtils.move(file_path, temp_file_path)
+ end
end
end
diff --git a/app/validators/color_validator.rb b/app/validators/color_validator.rb
index 1932d042e83..974dfbbf394 100644
--- a/app/validators/color_validator.rb
+++ b/app/validators/color_validator.rb
@@ -12,7 +12,7 @@
# end
#
class ColorValidator < ActiveModel::EachValidator
- PATTERN = /\A\#[0-9A-Fa-f]{3}{1,2}+\Z/.freeze
+ PATTERN = /\A\#(?:[0-9A-Fa-f]{3}){1,2}\Z/.freeze
def validate_each(record, attribute, value)
unless value =~ PATTERN
diff --git a/app/views/admin/application_settings/_grafana.html.haml b/app/views/admin/application_settings/_grafana.html.haml
new file mode 100644
index 00000000000..b6e02bde895
--- /dev/null
+++ b/app/views/admin/application_settings/_grafana.html.haml
@@ -0,0 +1,17 @@
+= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-grafana-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ %p
+ = _("Add a Grafana button in the admin sidebar, monitoring section, to access a variety of statistics on the health and performance of GitLab.")
+ = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/grafana_configuration.md')
+ .form-group
+ .form-check
+ = f.check_box :grafana_enabled, class: 'form-check-input'
+ = f.label :grafana_enabled, class: 'form-check-label' do
+ = _('Enable access to Grafana')
+ .form-group
+ = f.label :grafana_url, _('Grafana URL'), class: 'label-bold'
+ = f.text_field :grafana_url, class: 'form-control', placeholder: '/-/grafana'
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
index bb4d1fa1241..e01c123d1db 100644
--- a/app/views/admin/application_settings/_localization.html.haml
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -8,4 +8,11 @@
.form-text.text-muted
= _('Default first day of the week in calendars and date pickers.')
+ .form-group
+ = f.label :time_tracking, _('Time tracking'), class: 'label-bold'
+ .form-check
+ = f.check_box :time_tracking_limit_to_hours, class: 'form-check-input'
+ = f.label :time_tracking_limit_to_hours, class: 'form-check-label' do
+ = _('Limit display of time tracking units to hours.')
+
= f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml
deleted file mode 100644
index d57066bba01..00000000000
--- a/app/views/admin/application_settings/_logging.html.haml
+++ /dev/null
@@ -1,38 +0,0 @@
-= form_for @application_setting, url: reporting_admin_application_settings_path(anchor: 'js-logging-settings'), html: { class: 'fieldset-form' } do |f|
- = form_errors(@application_setting)
-
- %p
- %strong
- NOTE:
- These settings will be removed from the UI in a GitLab 12.0 release and made available within gitlab.yml.
- In addition, you will be able to define a Sentry Environment to differentiate between multiple deployments. For example, development, staging, and production.
-
- %fieldset
- .form-group
- .form-check
- = f.check_box :sentry_enabled, class: 'form-check-input'
- = f.label :sentry_enabled, class: 'form-check-label' do
- Enable Sentry
- .form-text.text-muted
- %p This setting requires a restart to take effect.
- Sentry is an error reporting and logging tool which is currently not shipped with GitLab, get it here:
- %a{ href: 'https://getsentry.com', target: '_blank', rel: 'noopener noreferrer' } https://getsentry.com
-
- .form-group
- = f.label :sentry_dsn, 'Sentry DSN', class: 'label-bold'
- = f.text_field :sentry_dsn, class: 'form-control'
-
- .form-group
- .form-check
- = f.check_box :clientside_sentry_enabled, class: 'form-check-input'
- = f.label :clientside_sentry_enabled, class: 'form-check-label' do
- Enable Clientside Sentry
- .form-text.text-muted
- Sentry can also be used for reporting and logging clientside exceptions.
- %a{ href: 'https://sentry.io/for/javascript/', target: '_blank', rel: 'noopener noreferrer' } https://sentry.io/for/javascript/
-
- .form-group
- = f.label :clientside_sentry_dsn, 'Clientside Sentry DSN', class: 'label-bold'
- = f.text_field :clientside_sentry_dsn, class: 'form-control'
-
- = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml
index a34fc15acb1..d24e46b2815 100644
--- a/app/views/admin/application_settings/_spam.html.haml
+++ b/app/views/admin/application_settings/_spam.html.haml
@@ -7,7 +7,10 @@
= f.check_box :recaptcha_enabled, class: 'form-check-input'
= f.label :recaptcha_enabled, class: 'form-check-label' do
Enable reCAPTCHA
- %span.form-text.text-muted#recaptcha_help_block Helps prevent bots from creating accounts
+ - recaptcha_v2_link_url = 'https://developers.google.com/recaptcha/docs/versions'
+ - recaptcha_v2_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: recaptcha_v2_link_url }
+ %span.form-text.text-muted#recaptcha_help_block
+ = _('Helps prevent bots from creating accounts. We currently only support %{recaptcha_v2_link_start}reCAPTCHA v2%{recaptcha_v2_link_end}').html_safe % { recaptcha_v2_link_start: recaptcha_v2_link_start, recaptcha_v2_link_end: '</a>'.html_safe }
.form-group
= f.label :recaptcha_site_key, 'reCAPTCHA Site Key', class: 'label-bold'
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 01d61beaf53..55a48da8342 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -24,6 +24,17 @@
.settings-content
= render 'prometheus'
+%section.settings.as-grafana.no-animate#js-grafana-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Metrics - Grafana')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Enable and configure Grafana.')
+ .settings-content
+ = render 'grafana'
+
%section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml
index 1c2d9ccdb2d..46e3d1c4570 100644
--- a/app/views/admin/application_settings/reporting.html.haml
+++ b/app/views/admin/application_settings/reporting.html.haml
@@ -23,14 +23,3 @@
= _('Set notification email for abuse reports.')
.settings-content
= render 'abuse'
-
-%section.settings.as-logging.no-animate#js-logging-settings{ class: ('expanded' if expanded_by_default?) }
- .settings-header
- %h4
- = _('Error Reporting and Logging')
- %button.btn.btn-default.js-settings-toggle{ type: 'button' }
- = expanded_by_default? ? _('Collapse') : _('Expand')
- %p
- = _('Enable Sentry for error reporting and logging.')
- .settings-content
- = render 'logging'
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 2e23b748edb..5129f5d193b 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -58,7 +58,7 @@
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
- %input.form-control.filtered-search{ { id: 'filtered-search-runners', placeholder: _('Search or filter results...') } }
+ %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' } }
diff --git a/app/views/admin/services/_form.html.haml b/app/views/admin/services/_form.html.haml
index 97373a3c350..ab08d5c4906 100644
--- a/app/views/admin/services/_form.html.haml
+++ b/app/views/admin/services/_form.html.haml
@@ -1,7 +1,7 @@
%h3.page-title
= @service.title
-%p #{@service.description} template
+%p #{@service.description} template.
= form_for :service, url: admin_application_settings_service_path, method: :put, html: { class: 'fieldset-form' } do |form|
= render 'shared/service_settings', form: form, subject: @service
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 455322b2089..3d0266a2d5b 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
+= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_integration_form' } do |field|
= form_errors(@cluster)
.form-group
%h5= s_('ClusterIntegration|Integration status')
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index eae3ee6339f..034273558bb 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -33,7 +33,7 @@
= accept_terms_label.html_safe
= render_if_exists 'devise/shared/email_opted_in', f: f
%div
- - if Gitlab::Recaptcha.enabled?
+ - if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
= f.submit _("Register"), class: "btn-register btn qa-new-user-register-button"
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 10187129a33..9659d416a38 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -13,12 +13,12 @@
= icon("chevron-up")
- else
= icon("chevron-down")
- = _('Toggle discussion')
+ = _('Toggle thread')
= link_to_member(@project, discussion.author, avatar: false)
.inline.discussion-headline-light
= discussion.author.to_reference
- started a discussion
+ started a thread
- url = discussion_path(discussion)
- if discussion.for_commit? && @noteable != discussion.noteable
diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml
index 2bfe118c608..49d5378d62e 100644
--- a/app/views/discussions/_new_issue_for_discussion.html.haml
+++ b/app/views/discussions/_new_issue_for_discussion.html.haml
@@ -4,7 +4,7 @@
.btn-group{ role: "group", "v-if" => "showButton" }
= link_to custom_icon('icon_mr_issue'),
new_project_issue_path(@project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id),
- title: 'Resolve this discussion in a new issue',
- aria: { label: 'Resolve this discussion in a new issue' },
+ title: 'Resolve this thread in a new issue',
+ aria: { label: 'Resolve this thread in a new issue' },
data: { container: 'body' },
class: 'new-issue-for-discussion btn btn-default discussion-create-issue-btn has-tooltip'
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index e12748666c8..db1849ebb45 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -13,7 +13,7 @@
= f.text_field :id, class: 'form-control w-auto', readonly: true
.row.prepend-top-8
- .form-group.col-md-9.append-bottom-0
+ .form-group.col-md-9
= f.label :description, _('Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 7535aee83a3..20b844f9fd8 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -46,7 +46,7 @@
= yield :library_javascripts
= javascript_include_tag locale_path unless I18n.locale == :en
- = webpack_bundle_tag "raven" if Gitlab::CurrentSettings.clientside_sentry_enabled
+ = webpack_bundle_tag "raven" if Gitlab.config.sentry.enabled
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml
index e29f646ed4f..fa04b5be9f2 100644
--- a/app/views/layouts/fullscreen.html.haml
+++ b/app/views/layouts/fullscreen.html.haml
@@ -10,5 +10,5 @@
= render "layouts/broadcast"
= yield :flash_message
= render "layouts/flash"
- .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch" }
+ .content-wrapper{ id: "content-body", class: "d-flex flex-column align-items-stretch mt-0" }
= yield
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 83fe871285a..87133c7ba22 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -81,6 +81,11 @@
= link_to admin_requests_profiles_path, title: _('Requests Profiles') do
%span
= _('Requests Profiles')
+ - if Gitlab::CurrentSettings.current_application_settings.grafana_enabled?
+ = nav_link do
+ = link_to Gitlab::CurrentSettings.current_application_settings.grafana_url, target: '_blank', title: _('Metrics Dashboard') do
+ %span
+ = _('Metrics Dashboard')
= render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar'
= nav_link(controller: :broadcast_messages) do
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index e401488ecff..a9af5ba5008 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -347,7 +347,7 @@
= _('Settings')
%li.divider.fly-out-top-item
= nav_link(path: %w[projects#edit]) do
- = link_to edit_project_path(@project), title: _('General') do
+ = link_to edit_project_path(@project), title: _('General'), class: 'qa-general-settings-link' do
%span
= _('General')
= nav_link(controller: :project_members) do
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 5f986c81ff4..841b2a5e79c 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,9 +1,10 @@
- header_title _("Snippets"), snippets_path
+- snippets_upload_path = snippets_upload_path(@snippet, current_user)
- content_for :page_specific_javascripts do
- - if @snippet && current_user
+ - if snippets_upload_path
-# haml-lint:disable InlineJavaScript
:javascript
- window.uploads_path = "#{upload_path('personal_snippet', id: @snippet.id)}";
+ window.uploads_path = "#{snippets_upload_path}";
= render template: "layouts/application"
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index c3f2902c78a..6c0d7b1e60b 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -1,7 +1,7 @@
<%= @merge_request.author_name %> <%= 'created a merge request:' %> <%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
-<%= 'Author' %>: <%= @merge_request.author_name %>
+<%= 'Author:' %> <%= @merge_request.author_name %>
<%= assignees_label(@merge_request) %>
<%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %>
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 4c18398e3dc..65ef9690062 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title "Access Tokens"
-- page_title "Personal Access Tokens"
+- breadcrumb_title s_('AccessTokens|Access Tokens')
+- page_title s_('AccessTokens|Personal Access Tokens')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -7,10 +7,10 @@
%h4.prepend-top-0
= page_title
%p
- You can generate a personal access token for each application you use that needs access to the GitLab API.
+ = s_('AccessTokens|You can generate a personal access token for each application you use that needs access to the GitLab API.')
%p
- You can also use personal access tokens to authenticate against Git over HTTP.
- They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.
+ = s_('AccessTokens|You can also use personal access tokens to authenticate against Git over HTTP.')
+ = s_('AccessTokens|They are the only accepted password when you have Two-Factor Authentication (2FA) enabled.')
.col-lg-8
- if @new_personal_access_token
@@ -24,35 +24,33 @@
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Feed token
+ = s_('AccessTokens|Feed token')
%p
- Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.
+ = s_('AccessTokens|Your feed token is used to authenticate you when your RSS reader loads a personalized RSS feed or when your calendar application loads a personalized calendar, and is included in those feed URLs.')
%p
- It cannot be used to access any other data.
+ = s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.feed-token-reset
- = label_tag :feed_token, 'Feed token', class: "label-bold"
+ = label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold"
= text_field_tag :feed_token, current_user.feed_token, class: 'form-control', readonly: true, onclick: 'this.select()'
%p.form-text.text-muted
- Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you.
- You should
- = link_to 'reset it', [:reset, :feed_token, :profile], method: :put, data: { confirm: 'Are you sure? Any RSS or calendar URLs currently in use will stop working.' }
- if that ever happens.
+ - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') }
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link }
+ = reset_message.html_safe
- if incoming_email_token_enabled?
%hr
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Incoming email token
+ = s_('AccessTokens|Incoming email token')
%p
- Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.
+ = s_('AccessTokens|Your incoming email token is used to authenticate you when you create a new issue by email, and is included in your personal project-specific email addresses.')
%p
- It cannot be used to access any other data.
+ = s_('AccessTokens|It cannot be used to access any other data.')
.col-lg-8.incoming-email-token-reset
- = label_tag :incoming_email_token, 'Incoming email token', class: "label-bold"
+ = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold"
= text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control', readonly: true, onclick: 'this.select()'
%p.form-text.text-muted
- Keep this token secret. Anyone who gets ahold of it can create issues as if they were you.
- You should
- = link_to 'reset it', [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: 'Are you sure? Any issue email addresses currently in use will stop working.' }
- if that ever happens.
+ - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') }
+ - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link }
+ = reset_message.html_safe
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 1e27c71d20d..b6689f4b57a 100644
--- a/app/views/projects/_commit_button.html.haml
+++ b/app/views/projects/_commit_button.html.haml
@@ -1,5 +1,5 @@
.form-actions
- = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-success'
+ = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-success qa-commit-button'
= link_to 'Cancel', cancel_path,
class: 'btn btn-cancel', data: {confirm: leave_edit_message}
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 2b0c3985755..6763513f9ae 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -9,7 +9,9 @@
.nav-block
= render 'projects/tree/tree_header', tree: @tree
- - if commit
+ - if vue_file_list_enabled?
+ #js-last-commit
+ - elsif commit
= render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview
diff --git a/app/views/projects/_merge_request_settings_description_text.html.haml b/app/views/projects/_merge_request_settings_description_text.html.haml
new file mode 100644
index 00000000000..42964c900b3
--- /dev/null
+++ b/app/views/projects/_merge_request_settings_description_text.html.haml
@@ -0,0 +1 @@
+%p= s_('ProjectSettings|Choose your merge method, merge options, and merge checks.')
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index a54460f1196..283b845e40d 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -32,7 +32,7 @@
= select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1'
.file-editor.code
- %pre.js-edit-mode-pane#editor= params[:content] || local_assigns[:blob_data]
+ %pre.js-edit-mode-pane.qa-editor#editor= params[:content] || local_assigns[:blob_data]
- if local_assigns[:path]
.js-edit-mode-pane#preview.hide
.center
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 3638334d61c..dbff2115f50 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -1,11 +1,7 @@
- merged = local_assigns.fetch(:merged, false)
- commit = @repository.commit(branch.dereferenced_target)
-- diverging_commit_counts = @repository.diverging_commit_counts(branch)
-- number_commits_distance = diverging_commit_counts[:distance]
-- number_commits_behind = diverging_commit_counts[:behind]
-- number_commits_ahead = diverging_commit_counts[:ahead]
- merge_project = merge_request_source_project_for_project(@project)
-%li{ class: "branch-item js-branch-#{branch.name}" }
+%li{ class: "branch-item js-branch-item js-branch-#{branch.name}", data: { name: branch.name } }
.branch-info
.branch-title
= sprite_icon('fork', size: 12)
@@ -30,7 +26,7 @@
= s_('Branches|Cant find HEAD commit for this branch')
- if branch.name != @repository.root_ref
- .js-branch-divergence-graph{ data: { distance: number_commits_distance, ahead_count: number_commits_ahead, behind_count: number_commits_behind, default_branch: @repository.root_ref, max_commits: @max_commits } }
+ .js-branch-divergence-graph
.controls.d-none.d-md-block<
- if merge_project && create_mr_button?(@repository.root_ref, branch.name)
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index d270e461ac8..11340d12423 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -47,6 +47,7 @@
= render_if_exists 'projects/commits/mirror_status'
+ .js-branch-list{ data: { diverging_counts_endpoint: diverging_commit_counts_namespace_project_branches_path(@project.namespace, @project, format: :json) } }
- if can?(current_user, :admin_project, @project)
- project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
.row-content-block
diff --git a/app/views/projects/deploy_tokens/_form.html.haml b/app/views/projects/deploy_tokens/_form.html.haml
index 5412fcbc9d8..f846dbd3763 100644
--- a/app/views/projects/deploy_tokens/_form.html.haml
+++ b/app/views/projects/deploy_tokens/_form.html.haml
@@ -13,6 +13,11 @@
= f.text_field :expires_at, class: 'datepicker form-control qa-deploy-token-expires-at', value: f.object.expires_at
.form-group
+ = f.label :username, class: 'label-bold'
+ = f.text_field :username, class: 'form-control qa-deploy-token-username'
+ .text-secondary= s_('DeployTokens|Default format is "gitlab+deploy-token-{n}". Enter custom username if you want to change it.')
+
+ .form-group
= f.label :scopes, class: 'label-bold'
%fieldset.form-group.form-check
= f.check_box :read_repository, class: 'form-check-input qa-deploy-token-read-repository'
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index c15b84d0aac..3403564992e 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -27,7 +27,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
%button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
- %p= _('Choose your merge method, options, checks, and set up a default merge request description template.')
+ = render_if_exists 'projects/merge_request_settings_description_text'
.settings-content
= render_if_exists 'shared/promotions/promote_mr_features'
@@ -121,7 +121,7 @@
%li= _('You can only transfer the project to namespaces you manage.')
%li= _('You will need to update your local repositories to point to the new location.')
%li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) }
- if @project.forked? && can?(current_user, :remove_fork_project, @project)
.sub-section
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index d59b2d4fb01..c13a47b0b09 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -31,21 +31,19 @@
= button_to stop_project_environment_path(@project, @environment), class: 'btn btn-danger has-tooltip', method: :post do
= s_('Environments|Stop environment')
- .row.top-area.adjust
- .col-md-7
- %h3.page-title= @environment.name
- .col-md-5
- .nav-controls
- = render 'projects/environments/terminal_button', environment: @environment
- = render 'projects/environments/external_url', environment: @environment
- = render 'projects/environments/metrics_button', environment: @environment
- - if can?(current_user, :update_environment, @environment)
- = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
- - if can?(current_user, :stop_environment, @environment)
- = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
- target: '#stop-environment-modal' } do
- = sprite_icon('stop')
- = s_('Environments|Stop')
+ .top-area
+ %h3.page-title= @environment.name
+ .nav-controls.ml-auto.my-2
+ = render 'projects/environments/terminal_button', environment: @environment
+ = render 'projects/environments/external_url', environment: @environment
+ = render 'projects/environments/metrics_button', environment: @environment
+ - if can?(current_user, :update_environment, @environment)
+ = link_to _('Edit'), edit_project_environment_path(@project, @environment), class: 'btn'
+ - if can?(current_user, :stop_environment, @environment)
+ = button_tag class: 'btn btn-danger', type: 'button', data: { toggle: 'modal',
+ target: '#stop-environment-modal' } do
+ = sprite_icon('stop')
+ = s_('Environments|Stop')
.environments-container
- if @deployments.blank?
diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml
index 6f7713124ac..7d539c9d749 100644
--- a/app/views/projects/issues/_issues.html.haml
+++ b/app/views/projects/issues/_issues.html.haml
@@ -1,6 +1,6 @@
- empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues')
-%ul.content-list.issues-list.issuable-list
+%ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') }
= render partial: "projects/issues/issue", collection: @issues
- if @issues.blank?
= render empty_state_path
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index d7e16dbd40c..1cfe302fdc7 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -46,11 +46,11 @@
= render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab
.tab-content.gitlab-tab-content
- .tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
+ .tab-pane.js-toggle-container{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
= render 'new_project_fields', f: f, project_name_id: "blank-project-name"
- #create-from-template-pane.tab-pane.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ #create-from-template-pane.tab-pane.js-toggle-container.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' }
.card-slim.m-4.p-4
%div
- contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing'
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index 044adb75bea..407de590efb 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -39,7 +39,7 @@
- if can?(current_user, :award_emoji, note)
- if note.emoji_awardable?
.note-actions-item
- = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
+ = button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
= icon('spinner spin')
%span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
%span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index e7edb93f05b..5b657966909 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -11,7 +11,7 @@
- if Gitlab.config.pages.external_https
- - auto_ssl_available = ::Gitlab::LetsEncrypt::Client.new.enabled?
+ - auto_ssl_available = ::Gitlab::LetsEncrypt.enabled?(@domain)
- auto_ssl_enabled = @domain.auto_ssl_enabled?
- auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index db1f15f96b8..e34973f1f43 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -1,49 +1,9 @@
-- page_title "Container Registry"
-
%section
- .settings-header
- %h4
- = page_title
- %p
- = s_('ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images.')
- %p.append-bottom-0
- = succeed '.' do
- = s_('ContainerRegistry|Learn more about')
- = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank'
.row.registry-placeholder.prepend-bottom-10
- .col-lg-12
- #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } }
-
- .row.prepend-top-10
- .col-lg-12
- .card
- .card-header
- = s_('ContainerRegistry|How to use the Container Registry')
- .card-body
- %p
- - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank')
- - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank')
- = s_('ContainerRegistry|First log in to GitLab&rsquo;s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token }
- %pre
- docker login #{Gitlab.config.registry.host_port}
- %br
- %p
- - deploy_token = link_to(_('deploy token'), help_page_path('user/project/deploy_tokens/index', anchor: 'read-container-registry-images'), target: '_blank')
- = s_('ContainerRegistry|You can also use a %{deploy_token} for read-only access to the registry images.').html_safe % { deploy_token: deploy_token }
- %br
- %p
- = s_('ContainerRegistry|Once you log in, you&rsquo;re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe }
- %pre
- :plain
- docker build -t #{escape_once(@project.container_registry_url)} .
- docker push #{escape_once(@project.container_registry_url)}
- %hr
- %h5.prepend-top-default
- = s_('ContainerRegistry|Use different image names')
- %p.light
- = s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:')
- %pre
- :plain
- #{escape_once(@project.container_registry_url)}:tag
- #{escape_once(@project.container_registry_url)}/optional-image-name:tag
- #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag
+ .col-12
+ #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json),
+ "help_page_path" => help_page_path('user/project/container_registry'),
+ "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
+ "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
+ "repository_url" => escape_once(@project.container_registry_url),
+ character_error: @character_error.to_s } }
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index ea6349f2f57..1d0bc588c9c 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -76,6 +76,7 @@
#{ _('New tag') }
.tree-controls
+ = render_if_exists 'projects/tree/lock_link'
= link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn'
= render 'projects/find_file_link'
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index df408e5fb60..ee7d89a9bd8 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -87,4 +87,5 @@
= _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
+ = render_if_exists 'search/category_elasticsearch'
= users
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index 3967c8148d2..8e3b482e27d 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -1,4 +1,4 @@
-#modal-confirm-danger.modal{ tabindex: -1 }
+#modal-confirm-danger.modal.qa-confirm-modal{ tabindex: -1 }
.modal-dialog
.modal-content
.modal-header
@@ -17,6 +17,6 @@
to proceed or close this modal to cancel.
.form-group
- = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input'
+ = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input'
.form-actions
- = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit"
+ = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button"
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 31a5370a5f8..71b13a5d741 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -2,7 +2,7 @@
- issue_votes = @issuable_meta_data[issuable.id]
- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes
- issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes')
-- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count
+- issuable_mr = @issuable_meta_data[issuable.id].merge_requests_count(current_user)
- if issuable_mr > 0
%li.issuable-mr.d-none.d-sm-block.has-tooltip{ title: _('Related merge requests') }
diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml
index 987a5d4f13f..a21dcabb485 100644
--- a/app/views/shared/_issues.html.haml
+++ b/app/views/shared/_issues.html.haml
@@ -1,6 +1,6 @@
- if @issues.to_a.any?
.card.card-small.card-without-border
- %ul.content-list.issues-list.issuable-list
+ %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } }
= render partial: 'projects/issues/issue', collection: @issues
= paginate @issues, theme: "gitlab"
- else
diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml
index a8d3de66418..42989b145a2 100644
--- a/app/views/shared/_personal_access_tokens_created_container.html.haml
+++ b/app/views/shared/_personal_access_tokens_created_container.html.haml
@@ -1,5 +1,5 @@
-- container_title = local_assigns.fetch(:container_title, 'Your New Personal Access Token')
-- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, 'Copy personal access token to clipboard')
+- container_title = local_assigns.fetch(:container_title, _('Your New Personal Access Token'))
+- clipboard_button_title = local_assigns.fetch(:clipboard_button_title, _('Copy personal access token to clipboard'))
.created-personal-access-token-container
%h5.prepend-top-0
@@ -9,6 +9,7 @@
= text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block"
%span.input-group-append
= clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard")
- %span#created-token-help-block.form-text.text-muted.text-danger Make sure you save it - you won't be able to access it again.
+ %span#created-token-help-block.form-text.text-muted.text-danger
+ = _("Make sure you save it - you won't be able to access it again.")
%hr
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index 0891b3459ec..1d96feda3b0 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -1,9 +1,9 @@
-- type = impersonation ? "impersonation" : "personal access"
+- type = impersonation ? s_('Profiles|impersonation') : s_('Profiles|personal access')
%h5.prepend-top-0
- Add a #{type} token
+ = _('Add a %{type} token') % { type: type }
%p.profile-settings-content
- Pick a name for the application, and we'll give you a unique #{type} token.
+ = _("Pick a name for the application, and we'll give you a unique %{type} token.") % { type: type }
= form_for token, url: path, method: :post, html: { class: 'js-requires-input' } do |f|
@@ -11,19 +11,19 @@
.row
.form-group.col-md-6
- = f.label :name, class: 'label-bold'
+ = f.label :name, _('Name'), class: 'label-bold'
= f.text_field :name, class: "form-control qa-personal-access-token-name-field", required: true
.row
.form-group.col-md-6
- = f.label :expires_at, class: 'label-bold'
+ = f.label :expires_at, _('Expires at'), class: 'label-bold'
.input-icon-wrapper
= f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD'
= icon('calendar', { class: 'input-icon-right' })
.form-group
- = f.label :scopes, class: 'label-bold'
+ = f.label :scopes, _('Scopes'), class: 'label-bold'
= render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes
.prepend-top-default
- = f.submit "Create #{type} token", class: "btn btn-success qa-create-token-button"
+ = f.submit _('Create %{type} token') % { type: type }, class: "btn btn-success qa-create-token-button"
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml
index 49f3aae0f98..823117f37ca 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/_personal_access_tokens_table.html.haml
@@ -1,20 +1,21 @@
-- type = impersonation ? "Impersonation" : "Personal Access"
+- type = impersonation ? s_('Profiles|Impersonation') : s_('Profiles|Personal Access')
%hr
-%h5 Active #{type} Tokens (#{active_tokens.length})
+%h5
+ = _('Active %{type} Tokens (%{token_length})') % { type: type, token_length: active_tokens.length }
- if impersonation
%p.profile-settings-content
- To see all the user's personal access tokens you must impersonate them first.
+ = _("To see all the user's personal access tokens you must impersonate them first.")
- if active_tokens.present?
.table-responsive
%table.table.active-tokens
%thead
%tr
- %th Name
- %th Created
- %th Expires
- %th Scopes
+ %th= _('Name')
+ %th= s_('AccessTokens|Created')
+ %th= _('Expires')
+ %th= _('Scopes')
%th
%tbody
- active_tokens.each do |token|
@@ -26,10 +27,10 @@
%span{ class: ('text-warning' if token.expires_soon?) }
In #{distance_of_time_in_words_to_now(token.expires_at)}
- else
- %span.token-never-expires-label Never
- %td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>"
+ %span.token-never-expires-label= _('Never')
+ %td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>')
- path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token)
- %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." }
+ %td= link_to _('Revoke'), path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: _('Are you sure you want to revoke this %{type} Token? This action cannot be undone.') % { type: type } }
- else
.settings-message.text-center
- This user has no active #{type} Tokens.
+ = _('This user has no active %{type} Tokens.') % { type: type }
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index f9cfcabc015..6c0613605eb 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -1,52 +1,62 @@
.board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
":data-id" => "list.id" }
.board-inner.d-flex.flex-column.position-relative.h-100.rounded
- %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
- %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' }
- %i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
- ":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
- "aria-hidden": "true" }
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }" }
+ %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' }
+
+ .board-title-caret.no-drag{ "v-if": "list.isExpandable",
+ "aria-hidden": "true",
+ ":aria-label": "caretTooltip",
+ ":title": "caretTooltip",
+ "v-tooltip": "",
+ data: { placement: "bottom" },
+ "@click": "toggleExpanded" }
+ %i.fa.fa-fw{ ":class": '{ "fa-caret-right": list.isExpanded, "fa-caret-down": !list.isExpanded }' }
= render_if_exists "shared/boards/components/list_milestone"
%a.user-avatar-link.js-no-trigger{ "v-if": "list.type === \"assignee\"", ":href": "list.assignee.path" }
-# haml-lint:disable AltText
%img.avatar.s20.has-tooltip{ height: "20", width: "20", ":src": "list.assignee.avatar", ":alt": "list.assignee.name" }
- %span.board-title-text.has-tooltip.block-truncated{ "v-if": "list.type !== \"label\"",
- ":title" => '((list.label && list.label.description) || list.title || "")', data: { container: "body" } }
- {{ list.title }}
+ .board-title-text
+ %span.board-title-main-text.block-truncated{ "v-if": "list.type !== \"label\"",
+ ":title" => '((list.label && list.label.description) || list.title || "")',
+ data: { container: "body" },
+ ":class": "{ 'has-tooltip': !['backlog', 'closed'].includes(list.type) }" }
+ {{ list.title }}
- %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
- ":title" => '(list.assignee && list.assignee.username || "")' }
- @{{ list.assignee.username }}
+ %span.board-title-sub-text.prepend-left-5.has-tooltip{ "v-if": "list.type === \"assignee\"",
+ ":title" => '(list.assignee && list.assignee.username || "")' }
+ @{{ list.assignee.username }}
- %span.has-tooltip{ "v-if": "list.type === \"label\"",
- ":title" => '(list.label ? list.label.description : "")',
- data: { container: "body", placement: "bottom" },
- class: "badge color-label title board-title-text",
- ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" }
- {{ list.title }}
+ %span.has-tooltip.badge.color-label.title{ "v-if": "list.type === \"label\"",
+ ":title" => '(list.label ? list.label.description : "")',
+ data: { container: "body", placement: "bottom" },
+ ":style" => "{ backgroundColor: (list.label && list.label.color ? list.label.color : null), color: (list.label && list.label.textColor ? list.label.textColor : \"#2e2e2e\") }" }
+ {{ list.title }}
- if can?(current_user, :admin_list, current_board_parent)
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
- %button.board-delete.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" }
+ %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.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } }
- %span.issue-count-badge-count
- %icon.mr-1{ name: "issues" }
- {{ list.issuesSize }}
- = render_if_exists "shared/boards/components/list_weight"
- %button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button",
+ .issue-count-badge.no-drag.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
+ %span.d-inline-flex
+ %span.issue-count-badge-count
+ %icon.mr-1{ name: "issues" }
+ {{ list.issuesSize }}
+ = render_if_exists "shared/boards/components/list_weight"
+
+ %button.issue-count-badge-add-button.no-drag.btn.btn-sm.btn-default.ml-1.has-tooltip{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => "isNewIssueShown",
":class": "{ 'd-none': !list.isExpanded }",
"aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
- = icon("plus", class: "js-no-trigger-collapse")
+ = icon("plus")
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list",
diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
index b76d44c5907..43081499920 100644
--- a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
+++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
@@ -3,4 +3,5 @@
":time-spent" => "issue.timeSpent || 0",
":human-time-estimate" => "issue.humanTimeEstimate",
":human-time-spent" => "issue.humanTimeSpent",
+ ":limit-to-hours" => "timeTrackingLimitToHours",
"root-path" => "#{root_url}" }
diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml
index c6a391ae563..1bd56e064d5 100644
--- a/app/views/shared/issuable/_form.html.haml
+++ b/app/views/shared/issuable/_form.html.haml
@@ -48,13 +48,13 @@
= hidden_field_tag 'merge_request_to_resolve_discussions_of', @merge_request_to_resolve_discussions_of.iid
- if @discussion_to_resolve
= hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id
- Creating this issue will resolve the discussion in
+ Creating this issue will resolve the thread in
- else
- Creating this issue will resolve all discussions in
+ Creating this issue will resolve all threads in
= link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve)
- else
The
- = @discussion_to_resolve ? 'discussion' : 'discussions'
+ = @discussion_to_resolve ? 'thread' : 'threads'
at
= link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve)
will stay unresolved. Ask someone with permission to resolve
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index 1dd97bc4ed1..403e001bfe8 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -1,6 +1,7 @@
- sort_value = @sort
- sort_title = issuable_sort_option_title(sort_value)
- viewing_issues = controller.controller_name == 'issues' || controller.action_name == 'issues'
+- manual_sorting = viewing_issues && controller.controller_name != 'dashboard' && Feature.enabled?(:manual_sorting)
.dropdown.inline.prepend-left-10.issue-sort-dropdown
.btn-group{ role: 'group' }
@@ -17,6 +18,6 @@
= sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues
= sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title)
= sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title)
- = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if viewing_issues && Feature.enabled?(:manual_sorting)
+ = sortable_item(sort_title_relative_position, page_filter_path(sort: sort_value_relative_position), sort_title) if manual_sorting
= render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index b24075c7849..ced6af50501 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -93,7 +93,11 @@
= milestone.issues_visible_to_user(current_user).closed.count
.block
- #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate, time_spent: @milestone.total_issue_time_spent, human_time_estimate: @milestone.human_total_issue_time_estimate, human_time_spent: @milestone.human_total_issue_time_spent } }
+ #issuable-time-tracker{ data: { time_estimate: @milestone.total_issue_time_estimate,
+ time_spent: @milestone.total_issue_time_spent,
+ human_time_estimate: @milestone.human_total_issue_time_estimate,
+ human_time_spent: @milestone.human_total_issue_time_spent,
+ limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s } }
// Fallback while content is loading
.title.hide-collapsed
= _('Time tracking')
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index c3f5eeb0da6..8d74eacc7dc 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -18,11 +18,11 @@
%li.divider.droplab-item-ignore
- %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start discussion'), 'close-text' => _("Start discussion & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start discussion & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
+ %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start thread'), 'close-text' => _("Start thread & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start thread & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
- %strong= _("Start discussion")
+ %strong= _("Start thread")
%p
= succeed '.' do
- if @note.noteable.supports_resolvable_notes?
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 13847cd9be1..576ec3e1782 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -28,7 +28,7 @@
.js-projects-list-holder
- if any_projects?(projects)
- - load_pipeline_status(projects)
+ - load_pipeline_status(projects) if pipeline_status
%ul.projects-list{ class: css_classes }
- projects.each_with_index do |project, i|
- css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml
index f99e905e95c..428861485b4 100644
--- a/app/views/shared/tokens/_scopes_list.html.haml
+++ b/app/views/shared/tokens/_scopes_list.html.haml
@@ -4,7 +4,7 @@
%tr
%td
- Scopes
+ = _('Scopes')
%td
%ul.scopes-list.append-bottom-0
- token.scopes.each do |scope|
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index fd0cc5fb24e..3d34bfc05c7 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -9,6 +9,7 @@
- cronjob:import_export_project_cleanup
- cronjob:pages_domain_verification_cron
- cronjob:pages_domain_removal_cron
+- cronjob:pages_domain_ssl_renewal_cron
- cronjob:pipeline_schedule
- cronjob:prune_old_events
- cronjob:remove_expired_group_links
@@ -25,6 +26,7 @@
- cronjob:issue_due_scheduler
- cronjob:prune_web_hook_logs
- cronjob:schedule_migrate_external_diffs
+- cronjob:namespaces_prune_aggregation_schedules
- gcp_cluster:cluster_install_app
- gcp_cluster:cluster_patch_app
@@ -100,6 +102,9 @@
- todos_destroyer:todos_destroyer_project_private
- todos_destroyer:todos_destroyer_private_features
+- update_namespace_statistics:namespaces_schedule_aggregation
+- update_namespace_statistics:namespaces_root_statistics
+
- object_pool:object_pool_create
- object_pool:object_pool_schedule_join
- object_pool:object_pool_join
@@ -133,6 +138,7 @@
- new_note
- pages
- pages_domain_verification
+- pages_domain_ssl_renewal
- plugin
- post_receive
- process_commit
diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
new file mode 100644
index 00000000000..4e40feee702
--- /dev/null
+++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class PruneAggregationSchedulesWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ # Worker to prune pending rows on Namespace::AggregationSchedule
+ # It's scheduled to run once a day at 1:05am.
+ def perform
+ aggregation_schedules.find_each do |aggregation_schedule|
+ aggregation_schedule.schedule_root_storage_statistics
+ end
+ end
+
+ private
+
+ def aggregation_schedules
+ Namespace::AggregationSchedule.all
+ end
+ end
+end
diff --git a/app/workers/namespaces/root_statistics_worker.rb b/app/workers/namespaces/root_statistics_worker.rb
new file mode 100644
index 00000000000..48876825564
--- /dev/null
+++ b/app/workers/namespaces/root_statistics_worker.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class RootStatisticsWorker
+ include ApplicationWorker
+
+ queue_namespace :update_namespace_statistics
+
+ def perform(namespace_id)
+ namespace = Namespace.find(namespace_id)
+
+ return unless update_statistics_enabled_for?(namespace) && namespace.aggregation_scheduled?
+
+ Namespaces::StatisticsRefresherService.new.execute(namespace)
+
+ namespace.aggregation_schedule.destroy
+ rescue ::Namespaces::StatisticsRefresherService::RefresherError, ActiveRecord::RecordNotFound => ex
+ log_error(namespace.full_path, ex.message) if namespace
+ end
+
+ private
+
+ def log_error(namespace_path, error_message)
+ Gitlab::SidekiqLogger.error("Namespace statistics can't be updated for #{namespace_path}: #{error_message}")
+ end
+
+ def update_statistics_enabled_for?(namespace)
+ Feature.enabled?(:update_statistics_namespace, namespace)
+ end
+ end
+end
diff --git a/app/workers/namespaces/schedule_aggregation_worker.rb b/app/workers/namespaces/schedule_aggregation_worker.rb
new file mode 100644
index 00000000000..a4594b84b13
--- /dev/null
+++ b/app/workers/namespaces/schedule_aggregation_worker.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Namespaces
+ class ScheduleAggregationWorker
+ include ApplicationWorker
+
+ queue_namespace :update_namespace_statistics
+
+ def perform(namespace_id)
+ return unless aggregation_schedules_table_exists?
+
+ namespace = Namespace.find(namespace_id)
+ root_ancestor = namespace.root_ancestor
+
+ return unless update_statistics_enabled_for?(root_ancestor) && !root_ancestor.aggregation_scheduled?
+
+ Namespace::AggregationSchedule.safe_find_or_create_by!(namespace_id: root_ancestor.id)
+ rescue ActiveRecord::RecordNotFound
+ log_error(namespace_id)
+ end
+
+ private
+
+ # On db/post_migrate/20180529152628_schedule_to_archive_legacy_traces.rb
+ # traces are archived through build.trace.archive, which in consequence
+ # calls UpdateProjectStatistics#schedule_namespace_statistics_worker.
+ #
+ # The migration and specs fails since NamespaceAggregationSchedule table
+ # does not exist at that point.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/50712
+ def aggregation_schedules_table_exists?
+ return true unless Rails.env.test?
+
+ Namespace::AggregationSchedule.table_exists?
+ end
+
+ def log_error(root_ancestor_id)
+ Gitlab::SidekiqLogger.error("Namespace can't be scheduled for aggregation: #{root_ancestor_id} does not exist")
+ end
+
+ def update_statistics_enabled_for?(root_ancestor)
+ Feature.enabled?(:update_statistics_namespace, root_ancestor)
+ end
+ end
+end
diff --git a/app/workers/pages_domain_ssl_renewal_cron_worker.rb b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
new file mode 100644
index 00000000000..40c34d29970
--- /dev/null
+++ b/app/workers/pages_domain_ssl_renewal_cron_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class PagesDomainSslRenewalCronWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ PagesDomain.need_auto_ssl_renewal.find_each do |domain|
+ next unless ::Gitlab::LetsEncrypt.enabled?(domain)
+
+ PagesDomainSslRenewalWorker.perform_async(domain.id)
+ end
+ end
+end
diff --git a/app/workers/pages_domain_ssl_renewal_worker.rb b/app/workers/pages_domain_ssl_renewal_worker.rb
new file mode 100644
index 00000000000..b32458ca777
--- /dev/null
+++ b/app/workers/pages_domain_ssl_renewal_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class PagesDomainSslRenewalWorker
+ include ApplicationWorker
+
+ def perform(domain_id)
+ domain = PagesDomain.find_by_id(domain_id)
+ return unless domain&.enabled?
+ return unless ::Gitlab::LetsEncrypt.enabled?(domain)
+
+ ::PagesDomains::ObtainLetsEncryptCertificateService.new(domain).execute
+ end
+end