summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/awards_handler.js2
-rw-r--r--app/assets/javascripts/behaviors/autosize.js45
-rw-r--r--app/assets/javascripts/behaviors/details_behavior.js45
-rw-r--r--app/assets/javascripts/behaviors/index.js9
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js109
-rw-r--r--app/assets/javascripts/behaviors/requires_input.js90
-rw-r--r--app/assets/javascripts/behaviors/toggler_behavior.js83
-rw-r--r--app/assets/javascripts/blob/blob_fork_suggestion.js15
-rw-r--r--app/assets/javascripts/boards/boards_bundle.js5
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.js67
-rw-r--r--app/assets/javascripts/build.js268
-rw-r--r--app/assets/javascripts/comment_type_toggle.js60
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_bundle.js15
-rw-r--r--app/assets/javascripts/commit/pipelines/pipelines_table.js76
-rw-r--r--app/assets/javascripts/copy_to_clipboard.js34
-rw-r--r--app/assets/javascripts/diff.js4
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js12
-rw-r--r--app/assets/javascripts/dispatcher.js20
-rw-r--r--app/assets/javascripts/droplab/constants.js11
-rw-r--r--app/assets/javascripts/droplab/drop_down.js139
-rw-r--r--app/assets/javascripts/droplab/drop_lab.js152
-rw-r--r--app/assets/javascripts/droplab/droplab.js741
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax.js103
-rw-r--r--app/assets/javascripts/droplab/droplab_ajax_filter.js164
-rw-r--r--app/assets/javascripts/droplab/droplab_filter.js76
-rw-r--r--app/assets/javascripts/droplab/hook.js22
-rw-r--r--app/assets/javascripts/droplab/hook_button.js65
-rw-r--r--app/assets/javascripts/droplab/hook_input.js119
-rw-r--r--app/assets/javascripts/droplab/keyboard.js113
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax.js65
-rw-r--r--app/assets/javascripts/droplab/plugins/ajax_filter.js133
-rw-r--r--app/assets/javascripts/droplab/plugins/filter.js95
-rw-r--r--app/assets/javascripts/droplab/plugins/input_setter.js50
-rw-r--r--app/assets/javascripts/droplab/utils.js38
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.js9
-rw-r--r--app/assets/javascripts/files_comment_button.js26
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js87
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js12
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js22
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js17
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js4
-rw-r--r--app/assets/javascripts/filtered_search/event_hub.js3
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js2
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js91
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js59
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js26
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js23
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/gl_form.js3
-rw-r--r--app/assets/javascripts/group.js21
-rw-r--r--app/assets/javascripts/issue.js108
-rw-r--r--app/assets/javascripts/lib/utils/poll.js11
-rw-r--r--app/assets/javascripts/main.js17
-rw-r--r--app/assets/javascripts/merge_request_tabs.js31
-rw-r--r--app/assets/javascripts/milestone_select.js6
-rw-r--r--app/assets/javascripts/notes.js196
-rw-r--r--app/assets/javascripts/protected_tags/index.js2
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js26
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js41
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_dropdown.js86
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js52
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js18
-rw-r--r--app/assets/javascripts/render_gfm.js1
-rw-r--r--app/assets/javascripts/shortcuts.js33
-rw-r--r--app/assets/javascripts/shortcuts_dashboard_navigation.js55
-rw-r--r--app/assets/javascripts/shortcuts_navigation.js65
-rw-r--r--app/assets/javascripts/subscription.js5
-rw-r--r--app/assets/javascripts/users_select.js8
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/async_button.vue (renamed from app/assets/javascripts/vue_pipelines_index/components/async_button.js)49
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/empty_state.js33
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/empty_state.vue34
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/error_state.js19
-rw-r--r--app/assets/javascripts/vue_pipelines_index/components/error_state.vue21
-rw-r--r--app/assets/javascripts/vue_pipelines_index/pipelines.js90
-rw-r--r--app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/pipelines_table_row.js2
-rw-r--r--app/assets/stylesheets/framework/calendar.scss2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss18
-rw-r--r--app/assets/stylesheets/framework/files.scss13
-rw-r--r--app/assets/stylesheets/framework/filters.scss141
-rw-r--r--app/assets/stylesheets/framework/timeline.scss7
-rw-r--r--app/assets/stylesheets/pages/boards.scss37
-rw-r--r--app/assets/stylesheets/pages/builds.scss38
-rw-r--r--app/assets/stylesheets/pages/container_registry.scss16
-rw-r--r--app/assets/stylesheets/pages/events.scss44
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss3
-rw-r--r--app/assets/stylesheets/pages/note_form.scss91
-rw-r--r--app/assets/stylesheets/pages/notes.scss240
-rw-r--r--app/assets/stylesheets/pages/projects.scss14
-rw-r--r--app/controllers/admin/application_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb4
-rw-r--r--app/controllers/admin/impersonations_controller.rb2
-rw-r--r--app/controllers/application_controller.rb25
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb58
-rw-r--r--app/controllers/concerns/renders_notes.rb20
-rw-r--r--app/controllers/concerns/requires_health_token.rb25
-rw-r--r--app/controllers/groups_controller.rb4
-rw-r--r--app/controllers/health_check_controller.rb21
-rw-r--r--app/controllers/health_controller.rb60
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb25
-rw-r--r--app/controllers/projects/blob_controller.rb10
-rw-r--r--app/controllers/projects/builds_controller.rb47
-rw-r--r--app/controllers/projects/commit_controller.rb22
-rw-r--r--app/controllers/projects/compare_controller.rb1
-rw-r--r--app/controllers/projects/container_registry_controller.rb34
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/hooks_controller.rb4
-rw-r--r--app/controllers/projects/issues_controller.rb12
-rwxr-xr-xapp/controllers/projects/merge_requests_controller.rb112
-rw-r--r--app/controllers/projects/notes_controller.rb94
-rw-r--r--app/controllers/projects/pipelines_controller.rb4
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb2
-rw-r--r--app/controllers/projects/protected_branches_controller.rb57
-rw-r--r--app/controllers/projects/protected_refs_controller.rb47
-rw-r--r--app/controllers/projects/protected_tags_controller.rb23
-rw-r--r--app/controllers/projects/registry/application_controller.rb16
-rw-r--r--app/controllers/projects/registry/repositories_controller.rb43
-rw-r--r--app/controllers/projects/registry/tags_controller.rb28
-rw-r--r--app/controllers/projects/settings/repository_controller.rb48
-rw-r--r--app/controllers/projects/snippets_controller.rb5
-rw-r--r--app/controllers/projects/triggers_controller.rb15
-rw-r--r--app/controllers/projects_controller.rb10
-rw-r--r--app/controllers/sessions_controller.rb2
-rw-r--r--app/finders/notes_finder.rb65
-rw-r--r--app/helpers/auth_helper.rb12
-rw-r--r--app/helpers/blob_helper.rb49
-rw-r--r--app/helpers/branches_helper.rb6
-rw-r--r--app/helpers/button_helper.rb25
-rw-r--r--app/helpers/diff_helper.rb10
-rw-r--r--app/helpers/dropdowns_helper.rb4
-rw-r--r--app/helpers/events_helper.rb28
-rw-r--r--app/helpers/javascript_helper.rb5
-rw-r--r--app/helpers/notes_helper.rb77
-rw-r--r--app/helpers/preferences_helper.rb6
-rw-r--r--app/helpers/system_note_helper.rb26
-rw-r--r--app/helpers/tags_helper.rb4
-rw-r--r--app/helpers/visibility_level_helper.rb2
-rw-r--r--app/helpers/webpack_helper.rb30
-rw-r--r--app/mailers/emails/notes.rb17
-rw-r--r--app/mailers/notify.rb4
-rw-r--r--app/models/award_emoji.rb3
-rw-r--r--app/models/blob.rb30
-rw-r--r--app/models/ci/build.rb166
-rw-r--r--app/models/ci/pipeline.rb57
-rw-r--r--app/models/ci/pipeline_status.rb86
-rw-r--r--app/models/ci/trigger.rb9
-rw-r--r--app/models/ci/trigger_schedule.rb41
-rw-r--r--app/models/commit.rb20
-rw-r--r--app/models/commit_status.rb5
-rw-r--r--app/models/concerns/discussion_on_diff.rb49
-rw-r--r--app/models/concerns/ghost_user.rb7
-rw-r--r--app/models/concerns/has_status.rb3
-rw-r--r--app/models/concerns/ignorable_column.rb28
-rw-r--r--app/models/concerns/issuable.rb11
-rw-r--r--app/models/concerns/note_on_diff.rb15
-rw-r--r--app/models/concerns/noteable.rb68
-rw-r--r--app/models/concerns/protected_branch_access.rb16
-rw-r--r--app/models/concerns/protected_ref.rb42
-rw-r--r--app/models/concerns/protected_ref_access.rb18
-rw-r--r--app/models/concerns/protected_tag_access.rb11
-rw-r--r--app/models/concerns/resolvable_discussion.rb103
-rw-r--r--app/models/concerns/resolvable_note.rb72
-rw-r--r--app/models/concerns/routable.rb68
-rw-r--r--app/models/container_repository.rb81
-rw-r--r--app/models/diff_discussion.rb27
-rw-r--r--app/models/diff_note.rb128
-rw-r--r--app/models/discussion.rb200
-rw-r--r--app/models/discussion_note.rb13
-rw-r--r--app/models/group.rb11
-rw-r--r--app/models/individual_note_discussion.rb13
-rw-r--r--app/models/issue.rb3
-rw-r--r--app/models/label.rb6
-rw-r--r--app/models/legacy_diff_discussion.rb33
-rw-r--r--app/models/legacy_diff_note.rb27
-rw-r--r--app/models/members/group_member.rb5
-rw-r--r--app/models/merge_request.rb54
-rw-r--r--app/models/merge_request_diff.rb10
-rw-r--r--app/models/milestone.rb4
-rw-r--r--app/models/note.rb136
-rw-r--r--app/models/out_of_context_discussion.rb22
-rw-r--r--app/models/project.rb92
-rw-r--r--app/models/project_services/chat_message/base_message.rb18
-rw-r--r--app/models/project_services/chat_message/issue_message.rb22
-rw-r--r--app/models/project_services/chat_message/merge_message.rb32
-rw-r--r--app/models/project_services/chat_message/note_message.rb80
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb36
-rw-r--r--app/models/project_services/chat_message/push_message.rb32
-rw-r--r--app/models/project_services/chat_message/wiki_page_message.rb18
-rw-r--r--app/models/project_services/chat_notification_service.rb20
-rw-r--r--app/models/project_services/jira_service.rb2
-rw-r--r--app/models/project_services/microsoft_teams_service.rb56
-rw-r--r--app/models/protectable_dropdown.rb33
-rw-r--r--app/models/protected_branch.rb58
-rw-r--r--app/models/protected_ref_matcher.rb54
-rw-r--r--app/models/protected_tag.rb14
-rw-r--r--app/models/protected_tag/create_access_level.rb21
-rw-r--r--app/models/repository.rb30
-rw-r--r--app/models/sent_notification.rb84
-rw-r--r--app/models/service.rb1
-rw-r--r--app/models/snippet.rb1
-rw-r--r--app/models/user.rb28
-rw-r--r--app/policies/ci/runner_policy.rb2
-rw-r--r--app/policies/global_policy.rb1
-rw-r--r--app/policies/group_policy.rb1
-rw-r--r--app/presenters/ci/build_presenter.rb6
-rw-r--r--app/presenters/ci/pipeline_presenter.rb11
-rw-r--r--app/serializers/pipeline_entity.rb8
-rw-r--r--app/serializers/pipeline_serializer.rb10
-rw-r--r--app/services/auth/container_registry_authentication_service.rb58
-rw-r--r--app/services/ci/create_pipeline_service.rb18
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb51
-rw-r--r--app/services/ci/retry_pipeline_service.rb4
-rw-r--r--app/services/concerns/issues/resolve_discussions.rb4
-rw-r--r--app/services/delete_branch_service.rb2
-rw-r--r--app/services/git_push_service.rb2
-rw-r--r--app/services/issues/build_service.rb13
-rw-r--r--app/services/notes/build_service.rb25
-rw-r--r--app/services/notes/create_service.rb8
-rw-r--r--app/services/projects/destroy_service.rb18
-rw-r--r--app/services/protected_branches/update_service.rb7
-rw-r--r--app/services/protected_tags/create_service.rb11
-rw-r--r--app/services/protected_tags/update_service.rb10
-rw-r--r--app/services/slash_commands/interpret_service.rb2
-rw-r--r--app/services/system_note_service.rb8
-rw-r--r--app/services/users/create_service.rb6
-rw-r--r--app/services/users/destroy_service.rb19
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb59
-rw-r--r--app/uploaders/file_uploader.rb4
-rw-r--r--app/validators/cron_timezone_validator.rb9
-rw-r--r--app/validators/cron_validator.rb9
-rw-r--r--app/views/admin/abuse_reports/_abuse_report.html.haml2
-rw-r--r--app/views/admin/application_settings/_form.html.haml3
-rw-r--r--app/views/admin/applications/index.html.haml2
-rw-r--r--app/views/admin/dashboard/index.html.haml6
-rw-r--r--app/views/admin/deploy_keys/index.html.haml2
-rw-r--r--app/views/admin/groups/_form.html.haml2
-rw-r--r--app/views/admin/groups/index.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml2
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/hooks/index.html.haml4
-rw-r--r--app/views/admin/identities/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml4
-rw-r--r--app/views/admin/runners/index.html.haml2
-rw-r--r--app/views/admin/spam_logs/_spam_log.html.haml2
-rw-r--r--app/views/admin/users/_user.html.haml2
-rw-r--r--app/views/admin/users/index.html.haml2
-rw-r--r--app/views/ci/status/_badge.html.haml9
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml2
-rw-r--r--app/views/dashboard/issues.html.haml2
-rw-r--r--app/views/dashboard/merge_requests.html.haml2
-rw-r--r--app/views/dashboard/milestones/index.html.haml2
-rw-r--r--app/views/discussions/_diff_discussion.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml2
-rw-r--r--app/views/discussions/_discussion.html.haml21
-rw-r--r--app/views/discussions/_notes.html.haml28
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml14
-rw-r--r--app/views/discussions/_resolve_all.html.haml17
-rw-r--r--app/views/events/_event.html.haml2
-rw-r--r--app/views/events/_event_last_push.html.haml4
-rw-r--r--app/views/events/event/_common.html.haml2
-rw-r--r--app/views/events/event/_created_project.html.haml2
-rw-r--r--app/views/events/event/_note.html.haml2
-rw-r--r--app/views/events/event/_push.html.haml3
-rw-r--r--app/views/groups/_group_admin_settings.html.haml28
-rw-r--r--app/views/groups/_group_lfs_settings.html.haml11
-rw-r--r--app/views/groups/edit.html.haml4
-rw-r--r--app/views/groups/issues.html.haml2
-rw-r--r--app/views/groups/milestones/index.html.haml2
-rw-r--r--app/views/groups/milestones/new.html.haml2
-rw-r--r--app/views/groups/projects.html.haml2
-rw-r--r--app/views/groups/subgroups.html.haml2
-rw-r--r--app/views/help/_shortcuts.html.haml130
-rw-r--r--app/views/help/ui.html.haml42
-rw-r--r--app/views/import/github/new.html.haml4
-rw-r--r--app/views/layouts/_head.html.haml6
-rw-r--r--app/views/layouts/header/_default.html.haml18
-rw-r--r--app/views/layouts/mailer.text.erb4
-rw-r--r--app/views/layouts/mailer.text.haml5
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml34
-rw-r--r--app/views/layouts/nav/_explore.html.haml19
-rw-r--r--app/views/layouts/notify.html.haml4
-rw-r--r--app/views/layouts/notify.text.erb12
-rw-r--r--app/views/notify/_note_email.html.haml37
-rw-r--r--app/views/notify/_note_email.text.erb26
-rw-r--r--app/views/notify/_note_message.html.haml5
-rw-r--r--app/views/notify/_note_message.text.erb5
-rw-r--r--app/views/notify/_note_mr_or_commit_email.html.haml18
-rw-r--r--app/views/notify/_note_mr_or_commit_email.text.erb8
-rw-r--r--app/views/notify/_simple_diff.text.erb3
-rw-r--r--app/views/notify/new_issue_email.html.haml10
-rw-r--r--app/views/notify/new_mention_in_issue_email.html.haml10
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.html.haml13
-rw-r--r--app/views/notify/new_merge_request_email.html.haml10
-rw-r--r--app/views/notify/note_commit_email.html.haml3
-rw-r--r--app/views/notify/note_commit_email.text.erb3
-rw-r--r--app/views/notify/note_issue_email.html.haml2
-rw-r--r--app/views/notify/note_issue_email.text.erb10
-rw-r--r--app/views/notify/note_merge_request_email.html.haml3
-rw-r--r--app/views/notify/note_merge_request_email.text.erb3
-rw-r--r--app/views/notify/note_personal_snippet_email.html.haml2
-rw-r--r--app/views/notify/note_personal_snippet_email.text.erb9
-rw-r--r--app/views/notify/note_snippet_email.html.haml2
-rw-r--r--app/views/notify/note_snippet_email.text.erb9
-rw-r--r--app/views/notify/pipeline_failed_email.html.haml2
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb2
-rw-r--r--app/views/notify/project_was_exported_email.html.haml2
-rw-r--r--app/views/profiles/accounts/show.html.haml4
-rw-r--r--app/views/profiles/emails/index.html.haml10
-rw-r--r--app/views/profiles/personal_access_tokens/index.html.haml2
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml2
-rw-r--r--app/views/projects/_commit_button.html.haml2
-rw-r--r--app/views/projects/_find_file_link.html.haml2
-rw-r--r--app/views/projects/_last_push.html.haml6
-rw-r--r--app/views/projects/blame/show.html.haml2
-rw-r--r--app/views/projects/blob/_blob.html.haml8
-rw-r--r--app/views/projects/blob/_header.html.haml20
-rw-r--r--app/views/projects/blob/_image.html.haml15
-rw-r--r--app/views/projects/blob/_markup.html.haml4
-rw-r--r--app/views/projects/blob/_svg.html.haml9
-rw-r--r--app/views/projects/blob/_template_selectors.html.haml2
-rw-r--r--app/views/projects/blob/_text.html.haml21
-rw-r--r--app/views/projects/blob/_too_large.html.haml5
-rw-r--r--app/views/projects/branches/_branch.html.haml4
-rw-r--r--app/views/projects/builds/_header.html.haml12
-rw-r--r--app/views/projects/builds/_sidebar.html.haml4
-rw-r--r--app/views/projects/builds/_table.html.haml2
-rw-r--r--app/views/projects/builds/index.html.haml2
-rw-r--r--app/views/projects/builds/show.html.haml5
-rw-r--r--app/views/projects/ci/builds/_build.html.haml85
-rw-r--r--app/views/projects/commit/_commit_box.html.haml4
-rw-r--r--app/views/projects/commit/_pipeline.html.haml3
-rw-r--r--app/views/projects/commit/show.html.haml1
-rw-r--r--app/views/projects/commits/_commit.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml6
-rw-r--r--app/views/projects/compare/_form.html.haml4
-rw-r--r--app/views/projects/diffs/_line.html.haml9
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml9
-rw-r--r--app/views/projects/diffs/_text_file.html.haml3
-rw-r--r--app/views/projects/environments/folder.html.haml5
-rw-r--r--app/views/projects/environments/show.html.haml2
-rw-r--r--app/views/projects/forks/error.html.haml2
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml3
-rw-r--r--app/views/projects/issues/_issue_by_email.html.haml2
-rw-r--r--app/views/projects/issues/index.html.haml4
-rw-r--r--app/views/projects/merge_requests/_discussion.html.haml4
-rw-r--r--app/views/projects/merge_requests/_new_submit.html.haml3
-rw-r--r--app/views/projects/merge_requests/show/_how_to_merge.html.haml6
-rw-r--r--app/views/projects/merge_requests/show/_versions.html.haml11
-rw-r--r--app/views/projects/merge_requests/widget/_merged_buttons.haml2
-rw-r--r--app/views/projects/merge_requests/widget/open/_accept.html.haml12
-rw-r--r--app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml4
-rw-r--r--app/views/projects/milestones/index.html.haml4
-rw-r--r--app/views/projects/milestones/show.html.haml4
-rw-r--r--app/views/projects/notes/_comment_button.html.haml30
-rw-r--r--app/views/projects/notes/_edit_form.html.haml2
-rw-r--r--app/views/projects/notes/_form.html.haml16
-rw-r--r--app/views/projects/notes/_note.html.haml9
-rw-r--r--app/views/projects/notes/_notes.html.haml6
-rw-r--r--app/views/projects/pipelines/_info.html.haml4
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml3
-rw-r--r--app/views/projects/pipelines_settings/_show.html.haml17
-rw-r--r--app/views/projects/protected_branches/show.html.haml8
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml32
-rw-r--r--app/views/projects/protected_tags/_dropdown.html.haml15
-rw-r--r--app/views/projects/protected_tags/_index.html.haml18
-rw-r--r--app/views/projects/protected_tags/_matching_tag.html.haml9
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml21
-rw-r--r--app/views/projects/protected_tags/_tags_list.html.haml28
-rw-r--r--app/views/projects/protected_tags/_update_protected_tag.haml5
-rw-r--r--app/views/projects/protected_tags/show.html.haml25
-rw-r--r--app/views/projects/registry/repositories/_image.html.haml32
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml (renamed from app/views/projects/container_registry/_tag.html.haml)10
-rw-r--r--app/views/projects/registry/repositories/index.html.haml (renamed from app/views/projects/container_registry/index.html.haml)23
-rw-r--r--app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml14
-rw-r--r--app/views/projects/services/slack_slash_commands/_help.html.haml10
-rw-r--r--app/views/projects/settings/repository/show.html.haml1
-rw-r--r--app/views/projects/show.html.haml6
-rw-r--r--app/views/projects/stage/_stage.html.haml4
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/tags/show.html.haml5
-rw-r--r--app/views/projects/tree/_tree_content.html.haml2
-rw-r--r--app/views/projects/triggers/_form.html.haml22
-rw-r--r--app/views/projects/triggers/_index.html.haml2
-rw-r--r--app/views/projects/triggers/_trigger.html.haml8
-rw-r--r--app/views/projects/wikis/_main_links.html.haml4
-rw-r--r--app/views/projects/wikis/_new.html.haml2
-rw-r--r--app/views/projects/wikis/edit.html.haml4
-rw-r--r--app/views/shared/_clone_panel.html.haml2
-rw-r--r--app/views/shared/_group_form.html.haml18
-rw-r--r--app/views/shared/_personal_access_tokens_form.html.haml4
-rw-r--r--app/views/shared/_personal_access_tokens_table.html.haml2
-rw-r--r--app/views/shared/icons/_icon_arrow_circle_o_right.svg1
-rw-r--r--app/views/shared/icons/_icon_check_square_o.svg1
-rw-r--r--app/views/shared/icons/_icon_clock_o.svg1
-rw-r--r--app/views/shared/icons/_icon_code_fork.svg1
-rw-r--r--app/views/shared/icons/_icon_comment_o.svg1
-rw-r--r--app/views/shared/icons/_icon_commit.svg4
-rw-r--r--app/views/shared/icons/_icon_edit.svg1
-rw-r--r--app/views/shared/icons/_icon_eye.svg1
-rw-r--r--app/views/shared/icons/_icon_eye_slash.svg1
-rw-r--r--app/views/shared/icons/_icon_merge.svg1
-rw-r--r--app/views/shared/icons/_icon_merged.svg1
-rw-r--r--app/views/shared/icons/_icon_pencil.svg1
-rw-r--r--app/views/shared/icons/_icon_random.svg1
-rw-r--r--app/views/shared/icons/_icon_status_closed.svg1
-rw-r--r--app/views/shared/icons/_icon_status_open.svg1
-rw-r--r--app/views/shared/icons/_icon_tags.svg1
-rw-r--r--app/views/shared/icons/_icon_trash_o.svg1
-rw-r--r--app/views/shared/icons/_icon_user.svg1
-rw-r--r--app/views/shared/issuable/_filter.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml162
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml4
-rw-r--r--app/views/shared/labels/_form.html.haml2
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml4
-rw-r--r--app/views/shared/web_hooks/_form.html.haml2
-rw-r--r--app/views/u2f/_register.html.haml6
-rw-r--r--app/workers/build_coverage_worker.rb3
-rw-r--r--app/workers/trigger_schedule_worker.rb18
421 files changed, 6738 insertions, 4150 deletions
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 67106e85a37..ce426741637 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -51,7 +51,7 @@ function renderCategory(name, emojiList, opts = {}) {
<h5 class="emoji-menu-title">
${name}
</h5>
- <ul class="clearfix emoji-menu-list ${opts.menuListClass}">
+ <ul class="clearfix emoji-menu-list ${opts.menuListClass || ''}">
${emojiList.map(emojiName => `
<li class="emoji-menu-list-item">
<button class="emoji-menu-btn text-center js-emoji-btn" type="button">
diff --git a/app/assets/javascripts/behaviors/autosize.js b/app/assets/javascripts/behaviors/autosize.js
index f7f41d55b52..3bea460dcc6 100644
--- a/app/assets/javascripts/behaviors/autosize.js
+++ b/app/assets/javascripts/behaviors/autosize.js
@@ -1,28 +1,23 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, consistent-return, max-len */
-/* global autosize */
+import autosize from 'vendor/autosize';
-var autosize = require('vendor/autosize');
+$(() => {
+ const $fields = $('.js-autosize');
-(function() {
- $(function() {
- var $fields;
- $fields = $('.js-autosize');
- $fields.on('autosize:resized', function() {
- var $field;
- $field = $(this);
- return $field.data('height', $field.outerHeight());
- });
- $fields.on('resize.autosize', function() {
- var $field;
- $field = $(this);
- if ($field.data('height') !== $field.outerHeight()) {
- $field.data('height', $field.outerHeight());
- autosize.destroy($field);
- return $field.css('max-height', window.outerHeight);
- }
- });
- autosize($fields);
- autosize.update($fields);
- return $fields.css('resize', 'vertical');
+ $fields.on('autosize:resized', function resized() {
+ const $field = $(this);
+ $field.data('height', $field.outerHeight());
});
-}).call(window);
+
+ $fields.on('resize.autosize', function resize() {
+ const $field = $(this);
+ if ($field.data('height') !== $field.outerHeight()) {
+ $field.data('height', $field.outerHeight());
+ autosize.destroy($field);
+ $field.css('max-height', window.outerHeight);
+ }
+ });
+
+ autosize($fields);
+ autosize.update($fields);
+ $fields.css('resize', 'vertical');
+});
diff --git a/app/assets/javascripts/behaviors/details_behavior.js b/app/assets/javascripts/behaviors/details_behavior.js
index fd0840fa117..7c9dbcc8d6e 100644
--- a/app/assets/javascripts/behaviors/details_behavior.js
+++ b/app/assets/javascripts/behaviors/details_behavior.js
@@ -1,26 +1,23 @@
-/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, max-len */
-(function() {
- $(function() {
- $("body").on("click", ".js-details-target", function() {
- var container;
- container = $(this).closest(".js-details-container");
- return container.toggleClass("open");
- });
- // Show details content. Hides link after click.
- //
- // %div
- // %a.js-details-expand
- // %div.js-details-content
- //
- return $("body").on("click", ".js-details-expand", function(e) {
- $(this).next('.js-details-content').removeClass("hide");
- $(this).hide();
- var truncatedItem = $(this).siblings('.js-details-short');
- if (truncatedItem.length) {
- truncatedItem.addClass("hide");
- }
- return e.preventDefault();
- });
+$(() => {
+ $('body').on('click', '.js-details-target', function target() {
+ $(this).closest('.js-details-container').toggleClass('open');
});
-}).call(window);
+
+ // Show details content. Hides link after click.
+ //
+ // %div
+ // %a.js-details-expand
+ // %div.js-details-content
+ //
+ $('body').on('click', '.js-details-expand', function expand(e) {
+ e.preventDefault();
+ $(this).next('.js-details-content').removeClass('hide');
+ $(this).hide();
+
+ const truncatedItem = $(this).siblings('.js-details-short');
+ if (truncatedItem.length) {
+ truncatedItem.addClass('hide');
+ }
+ });
+});
diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js
new file mode 100644
index 00000000000..5b931e6cfa6
--- /dev/null
+++ b/app/assets/javascripts/behaviors/index.js
@@ -0,0 +1,9 @@
+import './autosize';
+import './bind_in_out';
+import './details_behavior';
+import { installGlEmojiElement } from './gl_emoji';
+import './quick_submit';
+import './requires_input';
+import './toggler_behavior';
+
+installGlEmojiElement();
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index 626f3503c91..3d162b24413 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,4 +1,4 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-arrow-callback, camelcase, consistent-return, quotes, object-shorthand, comma-dangle, max-len */
+import '../commons/bootstrap';
// Quick Submit behavior
//
@@ -6,9 +6,6 @@
// "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form
// is submitted.
//
-import '../commons/bootstrap';
-
-//
// ### Example Markup
//
// <form action="/foo" class="js-quick-submit">
@@ -17,61 +14,59 @@ import '../commons/bootstrap';
// <input type="submit" value="Submit" />
// </form>
//
-(function() {
- var isMac, keyCodeIs;
- isMac = function() {
- return navigator.userAgent.match(/Macintosh/);
- };
+function isMac() {
+ return navigator.userAgent.match(/Macintosh/);
+}
- keyCodeIs = function(e, keyCode) {
- if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) {
- return false;
- }
- return e.keyCode === keyCode;
- };
+function keyCodeIs(e, keyCode) {
+ if ((e.originalEvent && e.originalEvent.repeat) || e.repeat) {
+ return false;
+ }
+ return e.keyCode === keyCode;
+}
- $(document).on('keydown.quick_submit', '.js-quick-submit', function(e) {
- var $form, $submit_button;
- // Enter
- if (!keyCodeIs(e, 13)) {
- return;
- }
- if (!((e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey) || (e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey))) {
- return;
- }
- e.preventDefault();
- $form = $(e.target).closest('form');
- $submit_button = $form.find('input[type=submit], button[type=submit]');
- if ($submit_button.attr('disabled')) {
- return;
- }
- $submit_button.disable();
- return $form.submit();
- });
+$(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
+ // Enter
+ if (!keyCodeIs(e, 13)) {
+ return;
+ }
+
+ const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey;
+ const onlyCtrl = e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
+ if (!onlyMeta && !onlyCtrl) {
+ return;
+ }
+
+ e.preventDefault();
+ const $form = $(e.target).closest('form');
+ const $submitButton = $form.find('input[type=submit], button[type=submit]');
+
+ if (!$submitButton.attr('disabled')) {
+ $submitButton.disable();
+ $form.submit();
+ }
+});
+
+// If the user tabs to a submit button on a `js-quick-submit` form, display a
+// tooltip to let them know they could've used the hotkey
+$(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function displayTooltip(e) {
+ // Tab
+ if (!keyCodeIs(e, 9)) {
+ return;
+ }
+
+ const $this = $(this);
+ const title = isMac() ?
+ 'You can also press &#8984;-Enter' :
+ 'You can also press Ctrl-Enter';
- // If the user tabs to a submit button on a `js-quick-submit` form, display a
- // tooltip to let them know they could've used the hotkey
- $(document).on('keyup.quick_submit', '.js-quick-submit input[type=submit], .js-quick-submit button[type=submit]', function(e) {
- var $this, title;
- // Tab
- if (!keyCodeIs(e, 9)) {
- return;
- }
- if (isMac()) {
- title = "You can also press &#8984;-Enter";
- } else {
- title = "You can also press Ctrl-Enter";
- }
- $this = $(this);
- return $this.tooltip({
- container: 'body',
- html: 'true',
- placement: 'auto top',
- title: title,
- trigger: 'manual'
- }).tooltip('show').one('blur', function() {
- return $this.tooltip('hide');
- });
+ $this.tooltip({
+ container: 'body',
+ html: 'true',
+ placement: 'auto top',
+ title,
+ trigger: 'manual',
});
-}).call(window);
+ $this.tooltip('show').one('blur', () => $this.tooltip('hide'));
+});
diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js
index eb7143f5b1a..b20d108aa25 100644
--- a/app/assets/javascripts/behaviors/requires_input.js
+++ b/app/assets/javascripts/behaviors/requires_input.js
@@ -1,12 +1,10 @@
-/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, no-else-return, consistent-return, max-len */
+import '../commons/bootstrap';
+
// Requires Input behavior
//
// When called on a form with input fields with the `required` attribute, the
// form's submit button will be disabled until all required fields have values.
//
-import '../commons/bootstrap';
-
-//
// ### Example Markup
//
// <form class="js-requires-input">
@@ -14,49 +12,43 @@ import '../commons/bootstrap';
// <input type="submit" value="Submit">
// </form>
//
-(function() {
- $.fn.requiresInput = function() {
- var $button, $form, fieldSelector, requireInput, required;
- $form = $(this);
- $button = $('button[type=submit], input[type=submit]', $form);
- required = '[required=required]';
- fieldSelector = "input" + required + ", select" + required + ", textarea" + required;
- requireInput = function() {
- var values;
- values = _.map($(fieldSelector, $form), function(field) {
- // Collect the input values of *all* required fields
- return field.value;
- });
- // Disable the button if any required fields are empty
- if (values.length && _.any(values, _.isEmpty)) {
- return $button.disable();
- } else {
- return $button.enable();
- }
- };
- // Set initial button state
- requireInput();
- return $form.on('change input', fieldSelector, requireInput);
- };
- $(function() {
- var $form, hideOrShowHelpBlock;
- $form = $('form.js-requires-input');
- $form.requiresInput();
- // Hide or Show the help block when creating a new project
- // based on the option selected
- hideOrShowHelpBlock = function(form) {
- var selected;
- selected = $('.js-select-namespace option:selected');
- if (selected.length && selected.data('options-parent') === 'groups') {
- return form.find('.help-block').hide();
- } else if (selected.length) {
- return form.find('.help-block').show();
- }
- };
- hideOrShowHelpBlock($form);
- return $('.select2.js-select-namespace').change(function() {
- return hideOrShowHelpBlock($form);
- });
- });
-}).call(window);
+$.fn.requiresInput = function requiresInput() {
+ const $form = $(this);
+ const $button = $('button[type=submit], input[type=submit]', $form);
+ const fieldSelector = 'input[required=required], select[required=required], textarea[required=required]';
+
+ function requireInput() {
+ // Collect the input values of *all* required fields
+ const values = _.map($(fieldSelector, $form), field => field.value);
+
+ // Disable the button if any required fields are empty
+ if (values.length && _.any(values, _.isEmpty)) {
+ $button.disable();
+ } else {
+ $button.enable();
+ }
+ }
+
+ // Set initial button state
+ requireInput();
+ $form.on('change input', fieldSelector, requireInput);
+};
+
+// Hide or Show the help block when creating a new project
+// based on the option selected
+function hideOrShowHelpBlock(form) {
+ const selected = $('.js-select-namespace option:selected');
+ if (selected.length && selected.data('options-parent') === 'groups') {
+ form.find('.help-block').hide();
+ } else if (selected.length) {
+ form.find('.help-block').show();
+ }
+}
+
+$(() => {
+ const $form = $('form.js-requires-input');
+ $form.requiresInput();
+ hideOrShowHelpBlock($form);
+ $('.select2.js-select-namespace').change(() => hideOrShowHelpBlock($form));
+});
diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js
index 576b8a0425f..4c9ad128e6c 100644
--- a/app/assets/javascripts/behaviors/toggler_behavior.js
+++ b/app/assets/javascripts/behaviors/toggler_behavior.js
@@ -1,44 +1,43 @@
-/* eslint-disable wrap-iife, func-names, space-before-function-paren, prefer-arrow-callback, vars-on-top, no-var, max-len */
-(function(w) {
- $(function() {
- var toggleContainer = function(container, /* optional */toggleState) {
- var $container = $(container);
-
- $container
- .find('.js-toggle-button .fa')
- .toggleClass('fa-chevron-up', toggleState)
- .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
-
- $container
- .find('.js-toggle-content')
- .toggle(toggleState);
- };
-
- // Toggle button. Show/hide content inside parent container.
- // Button does not change visibility. If button has icon - it changes chevron style.
- //
- // %div.js-toggle-container
- // %button.js-toggle-button
- // %div.js-toggle-content
- //
- $('body').on('click', '.js-toggle-button', function(e) {
- toggleContainer($(this).closest('.js-toggle-container'));
-
- const targetTag = e.currentTarget.tagName.toLowerCase();
- if (targetTag === 'a' || targetTag === 'button') {
- e.preventDefault();
- }
- });
-
- // If we're accessing a permalink, ensure it is not inside a
- // closed js-toggle-container!
- var hash = w.gl.utils.getLocationHash();
- var anchor = hash && document.getElementById(hash);
- var container = anchor && $(anchor).closest('.js-toggle-container');
-
- if (container) {
- toggleContainer(container, true);
- anchor.scrollIntoView();
+
+// Toggle button. Show/hide content inside parent container.
+// Button does not change visibility. If button has icon - it changes chevron style.
+//
+// %div.js-toggle-container
+// %button.js-toggle-button
+// %div.js-toggle-content
+//
+
+$(() => {
+ function toggleContainer(container, toggleState) {
+ const $container = $(container);
+
+ $container
+ .find('.js-toggle-button .fa')
+ .toggleClass('fa-chevron-up', toggleState)
+ .toggleClass('fa-chevron-down', toggleState !== undefined ? !toggleState : undefined);
+
+ $container
+ .find('.js-toggle-content')
+ .toggle(toggleState);
+ }
+
+ $('body').on('click', '.js-toggle-button', function toggleButton(e) {
+ toggleContainer($(this).closest('.js-toggle-container'));
+
+ const targetTag = e.currentTarget.tagName.toLowerCase();
+ if (targetTag === 'a' || targetTag === 'button') {
+ e.preventDefault();
}
});
-})(window);
+
+ // If we're accessing a permalink, ensure it is not inside a
+ // closed js-toggle-container!
+ const hash = window.gl.utils.getLocationHash();
+ const anchor = hash && document.getElementById(hash);
+ const container = anchor && $(anchor).closest('.js-toggle-container');
+
+ if (container) {
+ toggleContainer(container, true);
+ anchor.scrollIntoView();
+ }
+});
diff --git a/app/assets/javascripts/blob/blob_fork_suggestion.js b/app/assets/javascripts/blob/blob_fork_suggestion.js
new file mode 100644
index 00000000000..aa9a4e1c99a
--- /dev/null
+++ b/app/assets/javascripts/blob/blob_fork_suggestion.js
@@ -0,0 +1,15 @@
+function BlobForkSuggestion(openButton, cancelButton, suggestionSection) {
+ if (openButton) {
+ openButton.addEventListener('click', () => {
+ suggestionSection.classList.remove('hidden');
+ });
+ }
+
+ if (cancelButton) {
+ cancelButton.addEventListener('click', () => {
+ suggestionSection.classList.add('hidden');
+ });
+ }
+}
+
+export default BlobForkSuggestion;
diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js
index e057ac8df02..b749ef43cd3 100644
--- a/app/assets/javascripts/boards/boards_bundle.js
+++ b/app/assets/javascripts/boards/boards_bundle.js
@@ -38,6 +38,10 @@ $(() => {
Store.create();
+ // hack to allow sidebar scripts like milestone_select manipulate the BoardsStore
+ gl.issueBoards.boardStoreIssueSet = (...args) => Vue.set(Store.detail.issue, ...args);
+ gl.issueBoards.boardStoreIssueDelete = (...args) => Vue.delete(Store.detail.issue, ...args);
+
gl.IssueBoardsApp = new Vue({
el: $boardApp,
components: {
@@ -81,6 +85,7 @@ $(() => {
if (list.type === 'closed') {
list.position = Infinity;
+ list.label = { description: 'Shows all closed issues. Moving an issue to this list closes it' };
}
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js
index a4629b092bf..e48d3344a2b 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.js
+++ b/app/assets/javascripts/boards/components/issue_card_inner.js
@@ -20,6 +20,7 @@ import eventHub from '../eventhub';
list: {
type: Object,
required: false,
+ default: () => ({}),
},
rootPath: {
type: String,
@@ -31,6 +32,26 @@ import eventHub from '../eventhub';
default: false,
},
},
+ computed: {
+ cardUrl() {
+ return `${this.issueLinkBase}/${this.issue.id}`;
+ },
+ assigneeUrl() {
+ return `${this.rootPath}${this.issue.assignee.username}`;
+ },
+ assigneeUrlTitle() {
+ return `Assigned to ${this.issue.assignee.name}`;
+ },
+ avatarUrlTitle() {
+ return `Avatar for ${this.issue.assignee.name}`;
+ },
+ issueId() {
+ return `#${this.issue.id}`;
+ },
+ showLabelFooter() {
+ return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
+ },
+ },
methods: {
showLabel(label) {
if (!this.list) return true;
@@ -67,35 +88,41 @@ import eventHub from '../eventhub';
},
template: `
<div>
- <h4 class="card-title">
- <i
- class="fa fa-eye-slash confidential-icon"
- v-if="issue.confidential"></i>
- <a
- :href="issueLinkBase + '/' + issue.id"
- :title="issue.title">
- {{ issue.title }}
- </a>
- </h4>
- <div class="card-footer">
- <span
- class="card-number"
- v-if="issue.id">
- #{{ issue.id }}
- </span>
+ <div class="card-header">
+ <h4 class="card-title">
+ <i
+ class="fa fa-eye-slash confidential-icon"
+ v-if="issue.confidential"
+ aria-hidden="true"
+ />
+ <a
+ class="js-no-trigger"
+ :href="cardUrl"
+ :title="issue.title">{{ issue.title }}</a>
+ <span
+ class="card-number"
+ v-if="issue.id"
+ >
+ {{ issueId }}
+ </span>
+ </h4>
<a
class="card-assignee has-tooltip js-no-trigger"
- :href="rootPath + issue.assignee.username"
- :title="'Assigned to ' + issue.assignee.name"
+ :href="assigneeUrl"
+ :title="assigneeUrlTitle"
v-if="issue.assignee"
- data-container="body">
+ data-container="body"
+ >
<img
class="avatar avatar-inline s20 js-no-trigger"
:src="issue.assignee.avatar"
width="20"
height="20"
- :alt="'Avatar for ' + issue.assignee.name" />
+ :alt="avatarUrlTitle"
+ />
</a>
+ </div>
+ <div class="card-footer" v-if="showLabelFooter">
<button
class="label color-label has-tooltip js-no-trigger"
v-for="label in issue.labels"
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js
index fe54ecffdfe..0aad95c2fe3 100644
--- a/app/assets/javascripts/build.js
+++ b/app/assets/javascripts/build.js
@@ -1,24 +1,28 @@
-/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-param-reassign, quotes, yoda, no-else-return, consistent-return, comma-dangle, object-shorthand, prefer-template, one-var, one-var-declaration-per-line, no-unused-vars, max-len, vars-on-top */
+/* eslint-disable func-names, wrap-iife, no-use-before-define,
+consistent-return, prefer-rest-params */
/* global Breakpoints */
-var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
-var AUTO_SCROLL_OFFSET = 75;
-var DOWN_BUILD_TRACE = '#down-build-trace';
+const bind = function (fn, me) { return function () { return fn.apply(me, arguments); }; };
+const AUTO_SCROLL_OFFSET = 75;
+const DOWN_BUILD_TRACE = '#down-build-trace';
-window.Build = (function() {
+window.Build = (function () {
Build.timeout = null;
Build.state = null;
function Build(options) {
- options = options || $('.js-build-options').data();
- this.pageUrl = options.pageUrl;
- this.buildUrl = options.buildUrl;
- this.buildStatus = options.buildStatus;
- this.state = options.logState;
- this.buildStage = options.buildStage;
- this.updateDropdown = bind(this.updateDropdown, this);
+ this.options = options || $('.js-build-options').data();
+
+ this.pageUrl = this.options.pageUrl;
+ this.buildUrl = this.options.buildUrl;
+ this.buildStatus = this.options.buildStatus;
+ this.state = this.options.logState;
+ this.buildStage = this.options.buildStage;
this.$document = $(document);
+
+ this.updateDropdown = bind(this.updateDropdown, this);
+
this.$body = $('body');
this.$buildTrace = $('#build-trace');
this.$autoScrollContainer = $('.autoscroll-container');
@@ -29,112 +33,112 @@ window.Build = (function() {
this.$scrollTopBtn = $('#scroll-top');
this.$scrollBottomBtn = $('#scroll-bottom');
this.$buildRefreshAnimation = $('.js-build-refresh');
+ this.$buildScroll = $('#js-build-scroll');
+ this.$truncatedInfo = $('.js-truncated-info');
clearTimeout(Build.timeout);
// Init breakpoint checker
this.bp = Breakpoints.get();
this.initSidebar();
- this.$buildScroll = $('#js-build-scroll');
-
this.populateJobs(this.buildStage);
this.updateStageDropdownText(this.buildStage);
this.sidebarOnResize();
- this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
- this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown);
+ this.$document
+ .off('click', '.js-sidebar-build-toggle')
+ .on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this));
+
+ this.$document
+ .off('click', '.stage-item')
+ .on('click', '.stage-item', this.updateDropdown);
+
this.$document.on('scroll', this.initScrollMonitor.bind(this));
- $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this));
- $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace);
+
+ $(window)
+ .off('resize.build')
+ .on('resize.build', this.sidebarOnResize.bind(this));
+
+ $('a', this.$buildScroll)
+ .off('click.stepTrace')
+ .on('click.stepTrace', this.stepTrace);
+
this.updateArtifactRemoveDate();
- if ($('#build-trace').length) {
- this.getInitialBuildTrace();
- this.initScrollButtonAffix();
- }
+ this.initScrollButtonAffix();
this.invokeBuildTrace();
}
- Build.prototype.initSidebar = function() {
+ Build.prototype.initSidebar = function () {
this.$sidebar = $('.js-build-sidebar');
this.$sidebar.niceScroll();
- this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
- };
-
- Build.prototype.location = function() {
- return window.location.href.split("#")[0];
+ this.$document
+ .off('click', '.js-sidebar-build-toggle')
+ .on('click', '.js-sidebar-build-toggle', this.toggleSidebar);
};
- Build.prototype.invokeBuildTrace = function() {
- var continueRefreshStatuses = ['running', 'pending'];
- // Continue to update build trace when build is running or pending
- if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) {
- // Check for new build output if user still watching build page
- // Only valid for runnig build when output changes during time
- Build.timeout = setTimeout((function(_this) {
- return function() {
- if (_this.location() === _this.pageUrl) {
- return _this.getBuildTrace();
- }
- };
- })(this), 4000);
- }
+ Build.prototype.invokeBuildTrace = function () {
+ return this.getBuildTrace();
};
- Build.prototype.getInitialBuildTrace = function() {
- var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
-
+ Build.prototype.getBuildTrace = function () {
return $.ajax({
- url: this.buildUrl,
+ url: `${this.pageUrl}/trace.json`,
dataType: 'json',
- success: function(buildData) {
- $('.js-build-output').html(buildData.trace_html);
+ data: {
+ state: this.state,
+ },
+ success: ((log) => {
+ const $buildContainer = $('.js-build-output');
+
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
- if (window.location.hash === DOWN_BUILD_TRACE) {
- $("html,body").scrollTop(this.$buildTrace.height());
+
+ if (log.state) {
+ this.state = log.state;
}
- if (removeRefreshStatuses.indexOf(buildData.status) !== -1) {
+
+ if (log.append) {
+ $buildContainer.append(log.html);
+ } else {
+ $buildContainer.html(log.html);
+ if (log.truncated) {
+ $('.js-truncated-info-size').html(` ${log.size} `);
+ this.$truncatedInfo.removeClass('hidden');
+ this.initAffixTruncatedInfo();
+ } else {
+ this.$truncatedInfo.addClass('hidden');
+ }
+ }
+
+ this.checkAutoscroll();
+
+ if (!log.complete) {
+ Build.timeout = setTimeout(() => {
+ this.invokeBuildTrace();
+ }, 4000);
+ } else {
this.$buildRefreshAnimation.remove();
- return this.initScrollMonitor();
}
- }.bind(this)
- });
- };
- Build.prototype.getBuildTrace = function() {
- return $.ajax({
- url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)),
- dataType: "json",
- success: (function(_this) {
- return function(log) {
- var pageUrl;
-
- if (log.state) {
- _this.state = log.state;
- }
- _this.invokeBuildTrace();
- if (log.status === "running") {
- if (log.append) {
- $('.js-build-output').append(log.html);
- } else {
- $('.js-build-output').html(log.html);
- }
- return _this.checkAutoscroll();
- } else if (log.status !== _this.buildStatus) {
- pageUrl = _this.pageUrl;
- if (_this.$autoScrollStatus.data('state') === 'enabled') {
- pageUrl += DOWN_BUILD_TRACE;
- }
-
- return gl.utils.visitUrl(pageUrl);
+ if (log.status !== this.buildStatus) {
+ let pageUrl = this.pageUrl;
+
+ if (this.$autoScrollStatus.data('state') === 'enabled') {
+ pageUrl += DOWN_BUILD_TRACE;
}
- };
- })(this)
+
+ gl.utils.visitUrl(pageUrl);
+ }
+ }),
+ error: () => {
+ this.$buildRefreshAnimation.remove();
+ return this.initScrollMonitor();
+ },
});
};
- Build.prototype.checkAutoscroll = function() {
- if (this.$autoScrollStatus.data("state") === "enabled") {
- return $("html,body").scrollTop(this.$buildTrace.height());
+ Build.prototype.checkAutoscroll = function () {
+ if (this.$autoScrollStatus.data('state') === 'enabled') {
+ return $('html,body').scrollTop(this.$buildTrace.height());
}
// Handle a situation where user started new build
@@ -146,7 +150,7 @@ window.Build = (function() {
}
};
- Build.prototype.initScrollButtonAffix = function() {
+ Build.prototype.initScrollButtonAffix = function () {
// Hide everything initially
this.$scrollTopBtn.hide();
this.$scrollBottomBtn.hide();
@@ -167,15 +171,17 @@ window.Build = (function() {
// - Show Top Arrow button
// - Show Bottom Arrow button
// - Disable Autoscroll and hide indicator (when build is running)
- Build.prototype.initScrollMonitor = function() {
- if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ Build.prototype.initScrollMonitor = function () {
+ if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// User is somewhere in middle of Build Log
this.$scrollTopBtn.show();
if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed
this.$scrollBottomBtn.show();
- } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
+ } else if (this.$buildRefreshAnimation.is(':visible') &&
+ !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) {
this.$scrollBottomBtn.show();
} else {
this.$scrollBottomBtn.hide();
@@ -186,10 +192,13 @@ window.Build = (function() {
this.$autoScrollContainer.hide();
this.$autoScrollStatusText.removeClass('animate');
} else {
- this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show();
+ this.$autoScrollContainer.css({
+ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
+ }).show();
this.$autoScrollStatusText.addClass('animate');
}
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ !gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// User is at Top of Build Log
this.$scrollTopBtn.hide();
@@ -197,17 +206,22 @@ window.Build = (function() {
this.$autoScrollContainer.hide();
this.$autoScrollStatusText.removeClass('animate');
- } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
- (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
+ } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ gl.utils.isInViewport(this.$downBuildTrace.get(0))) ||
+ (this.$buildRefreshAnimation.is(':visible') &&
+ gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) {
// User is at Bottom of Build Log
this.$scrollTopBtn.show();
this.$scrollBottomBtn.hide();
// Show and Reposition Autoscroll Status Indicator
- this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show();
+ this.$autoScrollContainer.css({
+ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET,
+ }).show();
this.$autoScrollStatusText.addClass('animate');
- } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
+ } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) &&
+ gl.utils.isInViewport(this.$downBuildTrace.get(0))) {
// Build Log height is small
this.$scrollTopBtn.hide();
@@ -218,65 +232,81 @@ window.Build = (function() {
this.$autoScrollStatusText.removeClass('animate');
}
- if (this.buildStatus === "running" || this.buildStatus === "pending") {
+ if (this.buildStatus === 'running' || this.buildStatus === 'pending') {
// Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise.
- this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled');
+ this.$autoScrollStatus.data(
+ 'state',
+ gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled',
+ );
}
};
- Build.prototype.shouldHideSidebarForViewport = function() {
- var bootstrapBreakpoint;
- bootstrapBreakpoint = this.bp.getBreakpointSize();
+ Build.prototype.shouldHideSidebarForViewport = function () {
+ const bootstrapBreakpoint = this.bp.getBreakpointSize();
return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm';
};
- Build.prototype.toggleSidebar = function(shouldHide) {
- var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+ Build.prototype.toggleSidebar = function (shouldHide) {
+ const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined;
+
this.$buildScroll.toggleClass('sidebar-expanded', shouldShow)
.toggleClass('sidebar-collapsed', shouldHide);
+ this.$truncatedInfo.toggleClass('sidebar-expanded', shouldShow)
+ .toggleClass('sidebar-collapsed', shouldHide);
this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow)
.toggleClass('right-sidebar-collapsed', shouldHide);
};
- Build.prototype.sidebarOnResize = function() {
+ Build.prototype.sidebarOnResize = function () {
this.toggleSidebar(this.shouldHideSidebarForViewport());
};
- Build.prototype.sidebarOnClick = function() {
+ Build.prototype.sidebarOnClick = function () {
if (this.shouldHideSidebarForViewport()) this.toggleSidebar();
};
- Build.prototype.updateArtifactRemoveDate = function() {
- var $date, date;
- $date = $('.js-artifacts-remove');
+ Build.prototype.updateArtifactRemoveDate = function () {
+ const $date = $('.js-artifacts-remove');
if ($date.length) {
- date = $date.text();
- return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '));
+ const date = $date.text();
+ return $date.text(
+ gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' '),
+ );
}
};
- Build.prototype.populateJobs = function(stage) {
+ Build.prototype.populateJobs = function (stage) {
$('.build-job').hide();
- $('.build-job[data-stage="' + stage + '"]').show();
+ $(`.build-job[data-stage="${stage}"]`).show();
};
- Build.prototype.updateStageDropdownText = function(stage) {
+ Build.prototype.updateStageDropdownText = function (stage) {
$('.stage-selection').text(stage);
};
- Build.prototype.updateDropdown = function(e) {
+ Build.prototype.updateDropdown = function (e) {
e.preventDefault();
- var stage = e.currentTarget.text;
+ const stage = e.currentTarget.text;
this.updateStageDropdownText(stage);
this.populateJobs(stage);
};
- Build.prototype.stepTrace = function(e) {
- var $currentTarget;
+ Build.prototype.stepTrace = function (e) {
e.preventDefault();
- $currentTarget = $(e.currentTarget);
+
+ const $currentTarget = $(e.currentTarget);
$.scrollTo($currentTarget.attr('href'), {
- offset: 0
+ offset: 0,
+ });
+ };
+
+ Build.prototype.initAffixTruncatedInfo = function () {
+ const offsetTop = this.$buildTrace.offset().top;
+
+ this.$truncatedInfo.affix({
+ offset: {
+ top: offsetTop,
+ },
});
};
diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js
new file mode 100644
index 00000000000..df0ba86198c
--- /dev/null
+++ b/app/assets/javascripts/comment_type_toggle.js
@@ -0,0 +1,60 @@
+import DropLab from './droplab/drop_lab';
+import InputSetter from './droplab/plugins/input_setter';
+
+class CommentTypeToggle {
+ constructor(opts = {}) {
+ this.dropdownTrigger = opts.dropdownTrigger;
+ this.dropdownList = opts.dropdownList;
+ this.noteTypeInput = opts.noteTypeInput;
+ this.submitButton = opts.submitButton;
+ this.closeButton = opts.closeButton;
+ this.reopenButton = opts.reopenButton;
+ }
+
+ initDroplab() {
+ this.droplab = new DropLab();
+
+ const config = this.setConfig();
+
+ this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
+ }
+
+ setConfig() {
+ const config = {
+ InputSetter: [{
+ input: this.noteTypeInput,
+ valueAttribute: 'data-value',
+ },
+ {
+ input: this.submitButton,
+ valueAttribute: 'data-submit-text',
+ }],
+ };
+
+ if (this.closeButton) {
+ config.InputSetter.push({
+ input: this.closeButton,
+ valueAttribute: 'data-close-text',
+ }, {
+ input: this.closeButton,
+ valueAttribute: 'data-close-text',
+ inputAttribute: 'data-alternative-text',
+ });
+ }
+
+ if (this.reopenButton) {
+ config.InputSetter.push({
+ input: this.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ }, {
+ input: this.reopenButton,
+ valueAttribute: 'data-reopen-text',
+ inputAttribute: 'data-alternative-text',
+ });
+ }
+
+ return config;
+ }
+}
+
+export default CommentTypeToggle;
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
index a92e068ca5a..86d99dd87da 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js
@@ -8,25 +8,22 @@ Vue.use(VueResource);
/**
* Commits View > Pipelines Tab > Pipelines Table.
- * Merge Request View > Pipelines Tab > Pipelines Table.
*
* Renders Pipelines table in pipelines tab in the commits show view.
- * Renders Pipelines table in pipelines tab in the merge request show view.
*/
+// export for use in merge_request_tabs.js (TODO: remove this hack)
+window.gl = window.gl || {};
+window.gl.CommitPipelinesTable = CommitPipelinesTable;
+
$(() => {
- window.gl = window.gl || {};
gl.commits = gl.commits || {};
gl.commits.pipelines = gl.commits.pipelines || {};
- if (gl.commits.PipelinesTableBundle) {
- gl.commits.PipelinesTableBundle.$destroy(true);
- }
-
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable();
if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) {
- gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl);
+ gl.commits.pipelines.PipelinesTableBundle = new CommitPipelinesTable().$mount();
+ pipelineTableViewEl.appendChild(gl.commits.pipelines.PipelinesTableBundle.$el);
}
});
diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js
index 4d5a857d705..1d16c64e07e 100644
--- a/app/assets/javascripts/commit/pipelines/pipelines_table.js
+++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js
@@ -1,12 +1,14 @@
import Vue from 'vue';
+import Visibility from 'visibilityjs';
import PipelinesTableComponent from '../../vue_shared/components/pipelines_table';
import PipelinesService from '../../vue_pipelines_index/services/pipelines_service';
import PipelineStore from '../../vue_pipelines_index/stores/pipelines_store';
import eventHub from '../../vue_pipelines_index/event_hub';
-import EmptyState from '../../vue_pipelines_index/components/empty_state';
-import ErrorState from '../../vue_pipelines_index/components/error_state';
+import EmptyState from '../../vue_pipelines_index/components/empty_state.vue';
+import ErrorState from '../../vue_pipelines_index/components/error_state.vue';
import '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor';
+import Poll from '../../lib/utils/poll';
/**
*
@@ -20,6 +22,7 @@ import '../../vue_shared/vue_resource_interceptor';
*/
export default Vue.component('pipelines-table', {
+
components: {
'pipelines-table-component': PipelinesTableComponent,
'error-state': ErrorState,
@@ -42,6 +45,7 @@ export default Vue.component('pipelines-table', {
state: store.state,
isLoading: false,
hasError: false,
+ isMakingRequest: false,
};
},
@@ -64,17 +68,41 @@ export default Vue.component('pipelines-table', {
*
*/
beforeMount() {
- this.endpoint = this.$el.dataset.endpoint;
- this.helpPagePath = this.$el.dataset.helpPagePath;
+ const element = document.querySelector('#commit-pipeline-table-view');
+
+ this.endpoint = element.dataset.endpoint;
+ this.helpPagePath = element.dataset.helpPagePath;
this.service = new PipelinesService(this.endpoint);
- this.fetchPipelines();
+ this.poll = new Poll({
+ resource: this.service,
+ method: 'getPipelines',
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: this.setIsMakingRequest,
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ this.poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ this.poll.restart();
+ } else {
+ this.poll.stop();
+ }
+ });
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeUpdate() {
- if (this.state.pipelines.length && this.$children) {
+ if (this.state.pipelines.length &&
+ this.$children &&
+ !this.isMakingRequest &&
+ !this.isLoading) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
@@ -83,21 +111,35 @@ export default Vue.component('pipelines-table', {
eventHub.$off('refreshPipelines');
},
+ destroyed() {
+ this.poll.stop();
+ },
+
methods: {
fetchPipelines() {
this.isLoading = true;
+
return this.service.getPipelines()
- .then(response => response.json())
- .then((json) => {
- // depending of the endpoint the response can either bring a `pipelines` key or not.
- const pipelines = json.pipelines || json;
- this.store.storePipelines(pipelines);
- this.isLoading = false;
- })
- .catch(() => {
- this.hasError = true;
- this.isLoading = false;
- });
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ },
+
+ successCallback(resp) {
+ const response = resp.json();
+
+ // depending of the endpoint the response can either bring a `pipelines` key or not.
+ const pipelines = response.pipelines || response;
+ this.store.storePipelines(pipelines);
+ this.isLoading = false;
+ },
+
+ errorCallback() {
+ this.hasError = true;
+ this.isLoading = false;
+ },
+
+ setIsMakingRequest(isMakingRequest) {
+ this.isMakingRequest = isMakingRequest;
},
},
diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js
index 6dbec50b890..ab9a8e43dd1 100644
--- a/app/assets/javascripts/copy_to_clipboard.js
+++ b/app/assets/javascripts/copy_to_clipboard.js
@@ -38,9 +38,35 @@ showTooltip = function(target, title) {
};
$(function() {
- var clipboard;
-
- clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
+ const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
- return clipboard.on('error', genericError);
+ clipboard.on('error', genericError);
+
+ // This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
+ // The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text`
+ // attribute that ClipboardJS reads from.
+ // When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
+ // to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
+ // this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the
+ // `text/plain` and `text/x-gfm` copy data types to the intended values.
+ $(document).on('copy', 'body > textarea[readonly]', function(e) {
+ const clipboardData = e.originalEvent.clipboardData;
+ if (!clipboardData) return;
+
+ const text = e.target.value;
+
+ let json;
+ try {
+ json = JSON.parse(text);
+ } catch (ex) {
+ return;
+ }
+
+ if (!json.text || !json.gfm) return;
+
+ e.preventDefault();
+
+ clipboardData.setData('text/plain', json.text);
+ clipboardData.setData('text/x-gfm', json.gfm);
+ });
});
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js
index 88180149715..5aa3eb46a69 100644
--- a/app/assets/javascripts/diff.js
+++ b/app/assets/javascripts/diff.js
@@ -13,10 +13,6 @@ class Diff {
$diffFile.each((index, file) => new gl.ImageFile(file));
- if (this.diffViewType() === 'parallel') {
- $('.content-wrapper .container-fluid').removeClass('container-limited');
- }
-
if (!isBound) {
$(document)
.on('click', '.js-unfold', this.handleClickUnfold.bind(this))
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 fc2f20e3bcb..eb76b7d15fd 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -42,10 +42,14 @@ import Vue from 'vue';
}
},
created() {
- this.discussion = CommentsStore.state[this.discussionId];
+ if (this.discussionId) {
+ this.discussion = CommentsStore.state[this.discussionId];
+ }
},
mounted: function () {
- const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`);
+ if (!this.discussionId) return;
+
+ const $textarea = $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`);
this.textareaIsEmpty = $textarea.val() === '';
$textarea.on('input.comment-and-resolve-btn', () => {
@@ -53,7 +57,9 @@ import Vue from 'vue';
});
},
destroyed: function () {
- $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn');
+ if (!this.discussionId) return;
+
+ $(`.js-discussion-note-form[data-discussion-id=${this.discussionId}] .note-textarea`).off('input.comment-and-resolve-btn');
}
});
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 9c7acc903d1..f277e1dddc7 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -24,7 +24,6 @@
/* global Search */
/* global Admin */
/* global NamespaceSelects */
-/* global ShortcutsDashboardNavigation */
/* global Project */
/* global ProjectAvatar */
/* global CompareAutocomplete */
@@ -38,12 +37,15 @@
import Issue from './issue';
import BindInOut from './behaviors/bind_in_out';
+import Group from './group';
import GroupName from './group_name';
import GroupsList from './groups_list';
import ProjectsList from './projects_list';
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
+import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
+import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
const ShortcutsBlob = require('./shortcuts_blob');
@@ -86,6 +88,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
skipResetBindings: true,
fileBlobPermalinkUrl,
});
+
+ new BlobForkSuggestion(
+ document.querySelector('.js-edit-blob-link-fork-toggler'),
+ document.querySelector('.js-cancel-fork-suggestion'),
+ document.querySelector('.js-file-fork-suggestion-section'),
+ );
}
switch (page) {
@@ -264,8 +272,9 @@ const ShortcutsBlob = require('./shortcuts_blob');
case 'groups:create':
case 'admin:groups:create':
BindInOut.initAll();
- case 'groups:new':
- case 'admin:groups:new':
+ new Group();
+ new GroupAvatar();
+ break;
case 'groups:edit':
case 'admin:groups:edit':
new GroupAvatar();
@@ -323,8 +332,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
new Search();
break;
case 'projects:repository:show':
+ // Initialize Protected Branch Settings
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
+ // Initialize Protected Tag Settings
+ new ProtectedTagCreate();
+ new ProtectedTagEditList();
break;
case 'projects:ci_cd:show':
new gl.ProjectVariables();
@@ -371,7 +384,6 @@ const ShortcutsBlob = require('./shortcuts_blob');
break;
case 'dashboard':
case 'root':
- shortcut_handler = new ShortcutsDashboardNavigation();
new UserCallout();
break;
case 'groups':
diff --git a/app/assets/javascripts/droplab/constants.js b/app/assets/javascripts/droplab/constants.js
new file mode 100644
index 00000000000..a23d914772a
--- /dev/null
+++ b/app/assets/javascripts/droplab/constants.js
@@ -0,0 +1,11 @@
+const DATA_TRIGGER = 'data-dropdown-trigger';
+const DATA_DROPDOWN = 'data-dropdown';
+const SELECTED_CLASS = 'droplab-item-selected';
+const ACTIVE_CLASS = 'droplab-item-active';
+
+export {
+ DATA_TRIGGER,
+ DATA_DROPDOWN,
+ SELECTED_CLASS,
+ ACTIVE_CLASS,
+};
diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js
new file mode 100644
index 00000000000..9588921ebcd
--- /dev/null
+++ b/app/assets/javascripts/droplab/drop_down.js
@@ -0,0 +1,139 @@
+/* eslint-disable */
+
+import utils from './utils';
+import { SELECTED_CLASS } from './constants';
+
+var DropDown = function(list) {
+ this.currentIndex = 0;
+ this.hidden = true;
+ this.list = typeof list === 'string' ? document.querySelector(list) : list;
+ this.items = [];
+
+ this.eventWrapper = {};
+
+ this.getItems();
+ this.initTemplateString();
+ this.addEvents();
+
+ this.initialState = list.innerHTML;
+};
+
+Object.assign(DropDown.prototype, {
+ getItems: function() {
+ this.items = [].slice.call(this.list.querySelectorAll('li'));
+ return this.items;
+ },
+
+ initTemplateString: function() {
+ var items = this.items || this.getItems();
+
+ var templateString = '';
+ if (items.length > 0) templateString = items[items.length - 1].outerHTML;
+ this.templateString = templateString;
+
+ return this.templateString;
+ },
+
+ clickEvent: function(e) {
+ if (e.target.tagName === 'UL') return;
+
+ var selected = utils.closest(e.target, 'LI');
+ if (!selected) return;
+
+ this.addSelectedClass(selected);
+
+ e.preventDefault();
+ this.hide();
+
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: this,
+ selected: selected,
+ data: e.target.dataset,
+ },
+ });
+ this.list.dispatchEvent(listEvent);
+ },
+
+ addSelectedClass: function (selected) {
+ this.removeSelectedClasses();
+ selected.classList.add(SELECTED_CLASS);
+ },
+
+ removeSelectedClasses: function () {
+ const items = this.items || this.getItems();
+
+ items.forEach(item => item.classList.remove(SELECTED_CLASS));
+ },
+
+ addEvents: function() {
+ this.eventWrapper.clickEvent = this.clickEvent.bind(this)
+ this.list.addEventListener('click', this.eventWrapper.clickEvent);
+ },
+
+ toggle: function() {
+ this.hidden ? this.show() : this.hide();
+ },
+
+ setData: function(data) {
+ this.data = data;
+ this.render(data);
+ },
+
+ addData: function(data) {
+ this.data = (this.data || []).concat(data);
+ this.render(this.data);
+ },
+
+ render: function(data) {
+ const children = data ? data.map(this.renderChildren.bind(this)) : [];
+ const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list;
+
+ renderableList.innerHTML = children.join('');
+ },
+
+ renderChildren: function(data) {
+ var html = utils.t(this.templateString, data);
+ var template = document.createElement('div');
+
+ template.innerHTML = html;
+ this.setImagesSrc(template);
+ template.firstChild.style.display = data.droplab_hidden ? 'none' : 'block';
+
+ return template.firstChild.outerHTML;
+ },
+
+ setImagesSrc: function(template) {
+ const images = [].slice.call(template.querySelectorAll('img[data-src]'));
+
+ images.forEach((image) => {
+ image.src = image.getAttribute('data-src');
+ image.removeAttribute('data-src');
+ });
+ },
+
+ show: function() {
+ if (!this.hidden) return;
+ this.list.style.display = 'block';
+ this.currentIndex = 0;
+ this.hidden = false;
+ },
+
+ hide: function() {
+ if (this.hidden) return;
+ this.list.style.display = 'none';
+ this.currentIndex = 0;
+ this.hidden = true;
+ },
+
+ toggle: function () {
+ this.hidden ? this.show() : this.hide();
+ },
+
+ destroy: function() {
+ this.hide();
+ this.list.removeEventListener('click', this.eventWrapper.clickEvent);
+ }
+});
+
+export default DropDown;
diff --git a/app/assets/javascripts/droplab/drop_lab.js b/app/assets/javascripts/droplab/drop_lab.js
new file mode 100644
index 00000000000..6eb9f314af7
--- /dev/null
+++ b/app/assets/javascripts/droplab/drop_lab.js
@@ -0,0 +1,152 @@
+/* eslint-disable */
+
+import HookButton from './hook_button';
+import HookInput from './hook_input';
+import utils from './utils';
+import Keyboard from './keyboard';
+import { DATA_TRIGGER } from './constants';
+
+var DropLab = function() {
+ this.ready = false;
+ this.hooks = [];
+ this.queuedData = [];
+ this.config = {};
+
+ this.eventWrapper = {};
+};
+
+Object.assign(DropLab.prototype, {
+ loadStatic: function(){
+ var dropdownTriggers = [].slice.apply(document.querySelectorAll(`[${DATA_TRIGGER}]`));
+ this.addHooks(dropdownTriggers);
+ },
+
+ addData: function () {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_addData');
+ },
+
+ setData: function() {
+ var args = [].slice.apply(arguments);
+ this.applyArgs(args, '_setData');
+ },
+
+ destroy: function() {
+ this.hooks.forEach(hook => hook.destroy());
+ this.hooks = [];
+ this.removeEvents();
+ },
+
+ applyArgs: function(args, methodName) {
+ if (this.ready) return this[methodName].apply(this, args);
+
+ this.queuedData = this.queuedData || [];
+ this.queuedData.push(args);
+ },
+
+ _addData: function(trigger, data) {
+ this._processData(trigger, data, 'addData');
+ },
+
+ _setData: function(trigger, data) {
+ this._processData(trigger, data, 'setData');
+ },
+
+ _processData: function(trigger, data, methodName) {
+ this.hooks.forEach((hook) => {
+ if (Array.isArray(trigger)) hook.list[methodName](trigger);
+
+ if (hook.trigger.id === trigger) hook.list[methodName](data);
+ });
+ },
+
+ addEvents: function() {
+ this.eventWrapper.documentClicked = this.documentClicked.bind(this)
+ document.addEventListener('click', this.eventWrapper.documentClicked);
+ },
+
+ documentClicked: function(e) {
+ let thisTag = e.target;
+
+ if (thisTag.tagName !== 'UL') thisTag = utils.closest(thisTag, 'UL');
+ if (utils.isDropDownParts(thisTag, this.hooks) || utils.isDropDownParts(e.target, this.hooks)) return;
+
+ this.hooks.forEach(hook => hook.list.hide());
+ },
+
+ removeEvents: function(){
+ document.removeEventListener('click', this.eventWrapper.documentClicked);
+ },
+
+ changeHookList: function(trigger, list, plugins, config) {
+ const availableTrigger = typeof trigger === 'string' ? document.getElementById(trigger) : trigger;
+
+
+ this.hooks.forEach((hook, i) => {
+ hook.list.list.dataset.dropdownActive = false;
+
+ if (hook.trigger !== availableTrigger) return;
+
+ hook.destroy();
+ this.hooks.splice(i, 1);
+ this.addHook(availableTrigger, list, plugins, config);
+ });
+ },
+
+ addHook: function(hook, list, plugins, config) {
+ const availableHook = typeof hook === 'string' ? document.querySelector(hook) : hook;
+ let availableList;
+
+ if (typeof list === 'string') {
+ availableList = document.querySelector(list);
+ } else if (list instanceof Element) {
+ availableList = list;
+ } else {
+ availableList = document.querySelector(hook.dataset[utils.toCamelCase(DATA_TRIGGER)]);
+ }
+
+ availableList.dataset.dropdownActive = true;
+
+ const HookObject = availableHook.tagName === 'INPUT' ? HookInput : HookButton;
+ this.hooks.push(new HookObject(availableHook, availableList, plugins, config));
+
+ return this;
+ },
+
+ addHooks: function(hooks, plugins, config) {
+ hooks.forEach(hook => this.addHook(hook, null, plugins, config));
+ return this;
+ },
+
+ setConfig: function(obj){
+ this.config = obj;
+ },
+
+ fireReady: function() {
+ const readyEvent = new CustomEvent('ready.dl', {
+ detail: {
+ dropdown: this,
+ },
+ });
+ document.dispatchEvent(readyEvent);
+
+ this.ready = true;
+ },
+
+ init: function (hook, list, plugins, config) {
+ hook ? this.addHook(hook, list, plugins, config) : this.loadStatic();
+
+ this.addEvents();
+
+ Keyboard();
+
+ this.fireReady();
+
+ this.queuedData.forEach(data => this.addData(data));
+ this.queuedData = [];
+
+ return this;
+ },
+});
+
+export default DropLab;
diff --git a/app/assets/javascripts/droplab/droplab.js b/app/assets/javascripts/droplab/droplab.js
deleted file mode 100644
index 8b14191395b..00000000000
--- a/app/assets/javascripts/droplab/droplab.js
+++ /dev/null
@@ -1,741 +0,0 @@
-/* eslint-disable */
-// Determine where to place this
-if (typeof Object.assign != 'function') {
- Object.assign = function (target, varArgs) { // .length of function is 2
- 'use strict';
- if (target == null) { // TypeError if undefined or null
- throw new TypeError('Cannot convert undefined or null to object');
- }
-
- var to = Object(target);
-
- for (var index = 1; index < arguments.length; index++) {
- var nextSource = arguments[index];
-
- if (nextSource != null) { // Skip over if undefined or null
- for (var nextKey in nextSource) {
- // Avoid bugs when hasOwnProperty is shadowed
- if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
- to[nextKey] = nextSource[nextKey];
- }
- }
- }
- }
- return to;
- };
-}
-
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.droplab = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-var DATA_TRIGGER = 'data-dropdown-trigger';
-var DATA_DROPDOWN = 'data-dropdown';
-
-module.exports = {
- DATA_TRIGGER: DATA_TRIGGER,
- DATA_DROPDOWN: DATA_DROPDOWN,
-}
-
-},{}],2:[function(require,module,exports){
-// Custom event support for IE
-if ( typeof CustomEvent === "function" ) {
- module.exports = CustomEvent;
-} else {
- require('./window')(function(w){
- var CustomEvent = function ( event, params ) {
- params = params || { bubbles: false, cancelable: false, detail: undefined };
- var evt = document.createEvent( 'CustomEvent' );
- evt.initCustomEvent( event, params.bubbles, params.cancelable, params.detail );
- return evt;
- }
- CustomEvent.prototype = w.Event.prototype;
-
- w.CustomEvent = CustomEvent;
- });
- module.exports = CustomEvent;
-}
-
-},{"./window":11}],3:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var utils = require('./utils');
-
-var DropDown = function(list) {
- this.currentIndex = 0;
- this.hidden = true;
- this.list = list;
- this.items = [];
- this.getItems();
- this.initTemplateString();
- this.addEvents();
- this.initialState = list.innerHTML;
-};
-
-Object.assign(DropDown.prototype, {
- getItems: function() {
- this.items = [].slice.call(this.list.querySelectorAll('li'));
- return this.items;
- },
-
- initTemplateString: function() {
- var items = this.items || this.getItems();
-
- var templateString = '';
- if(items.length > 0) {
- templateString = items[items.length - 1].outerHTML;
- }
- this.templateString = templateString;
- return this.templateString;
- },
-
- clickEvent: function(e) {
- // climb up the tree to find the LI
- var selected = utils.closest(e.target, 'LI');
-
- if(selected) {
- e.preventDefault();
- this.hide();
- var listEvent = new CustomEvent('click.dl', {
- detail: {
- list: this,
- selected: selected,
- data: e.target.dataset,
- },
- });
- this.list.dispatchEvent(listEvent);
- }
- },
-
- addEvents: function() {
- this.clickWrapper = this.clickEvent.bind(this);
- // event delegation.
- this.list.addEventListener('click', this.clickWrapper);
- },
-
- toggle: function() {
- if(this.hidden) {
- this.show();
- } else {
- this.hide();
- }
- },
-
- setData: function(data) {
- this.data = data;
- this.render(data);
- },
-
- addData: function(data) {
- this.data = (this.data || []).concat(data);
- this.render(this.data);
- },
-
- // call render manually on data;
- render: function(data){
- // debugger
- // empty the list first
- var templateString = this.templateString;
- var newChildren = [];
- var toAppend;
-
- newChildren = (data ||[]).map(function(dat){
- var html = utils.t(templateString, dat);
- var template = document.createElement('div');
- template.innerHTML = html;
-
- // Help set the image src template
- var imageTags = template.querySelectorAll('img[data-src]');
- // debugger
- for(var i = 0; i < imageTags.length; i++) {
- var imageTag = imageTags[i];
- imageTag.src = imageTag.getAttribute('data-src');
- imageTag.removeAttribute('data-src');
- }
-
- if(dat.hasOwnProperty('droplab_hidden') && dat.droplab_hidden){
- template.firstChild.style.display = 'none'
- }else{
- template.firstChild.style.display = 'block';
- }
- return template.firstChild.outerHTML;
- });
- toAppend = this.list.querySelector('ul[data-dynamic]');
- if(toAppend) {
- toAppend.innerHTML = newChildren.join('');
- } else {
- this.list.innerHTML = newChildren.join('');
- }
- },
-
- show: function() {
- if (this.hidden) {
- // debugger
- this.list.style.display = 'block';
- this.currentIndex = 0;
- this.hidden = false;
- }
- },
-
- hide: function() {
- if (!this.hidden) {
- // debugger
- this.list.style.display = 'none';
- this.currentIndex = 0;
- this.hidden = true;
- }
- },
-
- destroy: function() {
- this.hide();
- this.list.removeEventListener('click', this.clickWrapper);
- }
-});
-
-module.exports = DropDown;
-
-},{"./custom_event_polyfill":2,"./utils":10}],4:[function(require,module,exports){
-require('./window')(function(w){
- module.exports = function(deps) {
- deps = deps || {};
- var window = deps.window || w;
- var document = deps.document || window.document;
- var CustomEvent = deps.CustomEvent || require('./custom_event_polyfill');
- var HookButton = deps.HookButton || require('./hook_button');
- var HookInput = deps.HookInput || require('./hook_input');
- var utils = deps.utils || require('./utils');
- var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-
- var DropLab = function(hook){
- if (!(this instanceof DropLab)) return new DropLab(hook);
- this.ready = false;
- this.hooks = [];
- this.queuedData = [];
- this.config = {};
- this.loadWrapper;
- if(typeof hook !== 'undefined'){
- this.addHook(hook);
- }
- };
-
-
- Object.assign(DropLab.prototype, {
- load: function() {
- this.loadWrapper();
- },
-
- loadWrapper: function(){
- var dropdownTriggers = [].slice.apply(document.querySelectorAll('['+DATA_TRIGGER+']'));
- this.addHooks(dropdownTriggers).init();
- },
-
- addData: function () {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_addData');
- },
-
- setData: function() {
- var args = [].slice.apply(arguments);
- this.applyArgs(args, '_setData');
- },
-
- destroy: function() {
- for(var i = 0; i < this.hooks.length; i++) {
- this.hooks[i].destroy();
- }
- this.hooks = [];
- this.removeEvents();
- },
-
- applyArgs: function(args, methodName) {
- if(this.ready) {
- this[methodName].apply(this, args);
- } else {
- this.queuedData = this.queuedData || [];
- this.queuedData.push(args);
- }
- },
-
- _addData: function(trigger, data) {
- this._processData(trigger, data, 'addData');
- },
-
- _setData: function(trigger, data) {
- this._processData(trigger, data, 'setData');
- },
-
- _processData: function(trigger, data, methodName) {
- for(var i = 0; i < this.hooks.length; i++) {
- var hook = this.hooks[i];
- if(hook.trigger.dataset.hasOwnProperty('id')) {
- if(hook.trigger.dataset.id === trigger) {
- hook.list[methodName](data);
- }
- }
- }
- },
-
- addEvents: function() {
- var self = this;
- this.windowClickedWrapper = function(e){
- var thisTag = e.target;
- if(thisTag.tagName !== 'UL'){
- // climb up the tree to find the UL
- thisTag = utils.closest(thisTag, 'UL');
- }
- if(utils.isDropDownParts(thisTag)){ return }
- if(utils.isDropDownParts(e.target)){ return }
- for(var i = 0; i < self.hooks.length; i++) {
- self.hooks[i].list.hide();
- }
- }.bind(this);
- document.addEventListener('click', this.windowClickedWrapper);
- },
-
- removeEvents: function(){
- w.removeEventListener('click', this.windowClickedWrapper);
- w.removeEventListener('load', this.loadWrapper);
- },
-
- changeHookList: function(trigger, list, plugins, config) {
- trigger = document.querySelector('[data-id="'+trigger+'"]');
- // list = document.querySelector(list);
- this.hooks.every(function(hook, i) {
- if(hook.trigger === trigger) {
- hook.destroy();
- this.hooks.splice(i, 1);
- this.addHook(trigger, list, plugins, config);
- return false;
- }
- return true
- }.bind(this));
- },
-
- addHook: function(hook, list, plugins, config) {
- if(!(hook instanceof HTMLElement) && typeof hook === 'string'){
- hook = document.querySelector(hook);
- }
- if(!list){
- list = document.querySelector(hook.dataset[utils.toDataCamelCase(DATA_TRIGGER)]);
- }
-
- if(hook) {
- if(hook.tagName === 'A' || hook.tagName === 'BUTTON') {
- this.hooks.push(new HookButton(hook, list, plugins, config));
- } else if(hook.tagName === 'INPUT') {
- this.hooks.push(new HookInput(hook, list, plugins, config));
- }
- }
- return this;
- },
-
- addHooks: function(hooks, plugins, config) {
- for(var i = 0; i < hooks.length; i++) {
- var hook = hooks[i];
- this.addHook(hook, null, plugins, config);
- }
- return this;
- },
-
- setConfig: function(obj){
- this.config = obj;
- },
-
- init: function () {
- this.addEvents();
- var readyEvent = new CustomEvent('ready.dl', {
- detail: {
- dropdown: this,
- },
- });
- window.dispatchEvent(readyEvent);
- this.ready = true;
- for(var i = 0; i < this.queuedData.length; i++) {
- this.addData.apply(this, this.queuedData[i]);
- }
- this.queuedData = [];
- return this;
- },
- });
-
- return DropLab;
- };
-});
-
-},{"./constants":1,"./custom_event_polyfill":2,"./hook_button":6,"./hook_input":7,"./utils":10,"./window":11}],5:[function(require,module,exports){
-var DropDown = require('./dropdown');
-
-var Hook = function(trigger, list, plugins, config){
- this.trigger = trigger;
- this.list = new DropDown(list);
- this.type = 'Hook';
- this.event = 'click';
- this.plugins = plugins || [];
- this.config = config || {};
- this.id = trigger.dataset.id;
-};
-
-Object.assign(Hook.prototype, {
-
- addEvents: function(){},
-
- constructor: Hook,
-});
-
-module.exports = Hook;
-
-},{"./dropdown":3}],6:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var Hook = require('./hook');
-
-var HookButton = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
- this.type = 'button';
- this.event = 'click';
- this.addEvents();
- this.addPlugins();
-};
-
-HookButton.prototype = Object.create(Hook.prototype);
-
-Object.assign(HookButton.prototype, {
- addPlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].init(this);
- }
- },
-
- clicked: function(e){
- var buttonEvent = new CustomEvent('click.dl', {
- detail: {
- hook: this,
- },
- bubbles: true,
- cancelable: true
- });
- this.list.show();
- e.target.dispatchEvent(buttonEvent);
- },
-
- addEvents: function(){
- this.clickedWrapper = this.clicked.bind(this);
- this.trigger.addEventListener('click', this.clickedWrapper);
- },
-
- removeEvents: function(){
- this.trigger.removeEventListener('click', this.clickedWrapper);
- },
-
- restoreInitialState: function() {
- this.list.list.innerHTML = this.list.initialState;
- },
-
- removePlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].destroy();
- }
- },
-
- destroy: function() {
- this.restoreInitialState();
- this.removeEvents();
- this.removePlugins();
- },
-
-
- constructor: HookButton,
-});
-
-
-module.exports = HookButton;
-
-},{"./custom_event_polyfill":2,"./hook":5}],7:[function(require,module,exports){
-var CustomEvent = require('./custom_event_polyfill');
-var Hook = require('./hook');
-
-var HookInput = function(trigger, list, plugins, config) {
- Hook.call(this, trigger, list, plugins, config);
- this.type = 'input';
- this.event = 'input';
- this.addPlugins();
- this.addEvents();
-};
-
-Object.assign(HookInput.prototype, {
- addPlugins: function() {
- var self = this;
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].init(self);
- }
- },
-
- addEvents: function(){
- var self = this;
-
- this.mousedown = function mousedown(e) {
- if(self.hasRemovedEvents) return;
-
- var mouseEvent = new CustomEvent('mousedown.dl', {
- detail: {
- hook: self,
- text: e.target.value,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(mouseEvent);
- }
-
- this.input = function input(e) {
- if(self.hasRemovedEvents) return;
-
- self.list.show();
-
- var inputEvent = new CustomEvent('input.dl', {
- detail: {
- hook: self,
- text: e.target.value,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(inputEvent);
- }
-
- this.keyup = function keyup(e) {
- if(self.hasRemovedEvents) return;
-
- keyEvent(e, 'keyup.dl');
- }
-
- this.keydown = function keydown(e) {
- if(self.hasRemovedEvents) return;
-
- keyEvent(e, 'keydown.dl');
- }
-
- function keyEvent(e, keyEventName){
- self.list.show();
-
- var keyEvent = new CustomEvent(keyEventName, {
- detail: {
- hook: self,
- text: e.target.value,
- which: e.which,
- key: e.key,
- },
- bubbles: true,
- cancelable: true
- });
- e.target.dispatchEvent(keyEvent);
- }
-
- this.events = this.events || {};
- this.events.mousedown = this.mousedown;
- this.events.input = this.input;
- this.events.keyup = this.keyup;
- this.events.keydown = this.keydown;
- this.trigger.addEventListener('mousedown', this.mousedown);
- this.trigger.addEventListener('input', this.input);
- this.trigger.addEventListener('keyup', this.keyup);
- this.trigger.addEventListener('keydown', this.keydown);
- },
-
- removeEvents: function() {
- this.hasRemovedEvents = true;
- this.trigger.removeEventListener('mousedown', this.mousedown);
- this.trigger.removeEventListener('input', this.input);
- this.trigger.removeEventListener('keyup', this.keyup);
- this.trigger.removeEventListener('keydown', this.keydown);
- },
-
- restoreInitialState: function() {
- this.list.list.innerHTML = this.list.initialState;
- },
-
- removePlugins: function() {
- for(var i = 0; i < this.plugins.length; i++) {
- this.plugins[i].destroy();
- }
- },
-
- destroy: function() {
- this.restoreInitialState();
- this.removeEvents();
- this.removePlugins();
- this.list.destroy();
- }
-});
-
-module.exports = HookInput;
-
-},{"./custom_event_polyfill":2,"./hook":5}],8:[function(require,module,exports){
-var DropLab = require('./droplab')();
-var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-var keyboard = require('./keyboard')();
-var setup = function() {
- window.DropLab = DropLab;
-};
-
-
-module.exports = setup();
-
-},{"./constants":1,"./droplab":4,"./keyboard":9}],9:[function(require,module,exports){
-require('./window')(function(w){
- module.exports = function(){
- var currentKey;
- var currentFocus;
- var isUpArrow = false;
- var isDownArrow = false;
- var removeHighlight = function removeHighlight(list) {
- var listItems = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
- var listItemsTmp = [];
- for(var i = 0; i < listItems.length; i++) {
- var listItem = listItems[i];
- listItem.classList.remove('dropdown-active');
-
- if (listItem.style.display !== 'none') {
- listItemsTmp.push(listItem);
- }
- }
- return listItemsTmp;
- };
-
- var setMenuForArrows = function setMenuForArrows(list) {
- var listItems = removeHighlight(list);
- if(list.currentIndex>0){
- if(!listItems[list.currentIndex-1]){
- list.currentIndex = list.currentIndex-1;
- }
-
- if (listItems[list.currentIndex-1]) {
- var el = listItems[list.currentIndex-1];
- var filterDropdownEl = el.closest('.filter-dropdown');
- el.classList.add('dropdown-active');
-
- if (filterDropdownEl) {
- var filterDropdownBottom = filterDropdownEl.offsetHeight;
- var elOffsetTop = el.offsetTop - 30;
-
- if (elOffsetTop > filterDropdownBottom) {
- filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom;
- }
- }
- }
- }
- };
-
- var mousedown = function mousedown(e) {
- var list = e.detail.hook.list;
- removeHighlight(list);
- list.show();
- list.currentIndex = 0;
- isUpArrow = false;
- isDownArrow = false;
- };
- var selectItem = function selectItem(list) {
- var listItems = removeHighlight(list);
- var currentItem = listItems[list.currentIndex-1];
- var listEvent = new CustomEvent('click.dl', {
- detail: {
- list: list,
- selected: currentItem,
- data: currentItem.dataset,
- },
- });
- list.list.dispatchEvent(listEvent);
- list.hide();
- }
-
- var keydown = function keydown(e){
- var typedOn = e.target;
- var list = e.detail.hook.list;
- var currentIndex = list.currentIndex;
- isUpArrow = false;
- isDownArrow = false;
-
- if(e.detail.which){
- currentKey = e.detail.which;
- if(currentKey === 13){
- selectItem(e.detail.hook.list);
- return;
- }
- if(currentKey === 38) {
- isUpArrow = true;
- }
- if(currentKey === 40) {
- isDownArrow = true;
- }
- } else if(e.detail.key) {
- currentKey = e.detail.key;
- if(currentKey === 'Enter'){
- selectItem(e.detail.hook.list);
- return;
- }
- if(currentKey === 'ArrowUp') {
- isUpArrow = true;
- }
- if(currentKey === 'ArrowDown') {
- isDownArrow = true;
- }
- }
- if(isUpArrow){ currentIndex--; }
- if(isDownArrow){ currentIndex++; }
- if(currentIndex < 0){ currentIndex = 0; }
- list.currentIndex = currentIndex;
- setMenuForArrows(e.detail.hook.list);
- };
-
- w.addEventListener('mousedown.dl', mousedown);
- w.addEventListener('keydown.dl', keydown);
- };
-});
-},{"./window":11}],10:[function(require,module,exports){
-var DATA_TRIGGER = require('./constants').DATA_TRIGGER;
-var DATA_DROPDOWN = require('./constants').DATA_DROPDOWN;
-
-var toDataCamelCase = function(attr){
- return this.camelize(attr.split('-').slice(1).join(' '));
-};
-
-// the tiniest damn templating I can do
-var t = function(s,d){
- for(var p in d)
- s=s.replace(new RegExp('{{'+p+'}}','g'), d[p]);
- return s;
-};
-
-var camelize = function(str) {
- return str.replace(/(?:^\w|[A-Z]|\b\w)/g, function(letter, index) {
- return index == 0 ? letter.toLowerCase() : letter.toUpperCase();
- }).replace(/\s+/g, '');
-};
-
-var closest = function(thisTag, stopTag) {
- while(thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML'){
- thisTag = thisTag.parentNode;
- }
- return thisTag;
-};
-
-var isDropDownParts = function(target) {
- if(!target || target.tagName === 'HTML') { return false; }
- return (
- target.hasAttribute(DATA_TRIGGER) ||
- target.hasAttribute(DATA_DROPDOWN)
- );
-};
-
-module.exports = {
- toDataCamelCase: toDataCamelCase,
- t: t,
- camelize: camelize,
- closest: closest,
- isDropDownParts: isDropDownParts,
-};
-
-},{"./constants":1}],11:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[8])(8)
-});
diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js
deleted file mode 100644
index 020f8b4ac65..00000000000
--- a/app/assets/javascripts/droplab/droplab_ajax.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- function droplabAjaxException(message) {
- this.message = message;
- }
-
- w.droplabAjax = {
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
-
- _loadData: function _loadData(data, config, self) {
- if (config.loadingTemplate) {
- var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
-
- if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
- }
- }
-
- if (!self.destroyed) {
- self.hook.list[config.method].call(self.hook.list, data);
- }
- },
-
- init: function init(hook) {
- var self = this;
- self.destroyed = false;
- self.cache = self.cache || {};
- var config = hook.config.droplabAjax;
- this.hook = hook;
-
- if (!config || !config.endpoint || !config.method) {
- return;
- }
-
- if (config.method !== 'setData' && config.method !== 'addData') {
- return;
- }
-
- if (config.loadingTemplate) {
- var dynamicList = hook.list.list.querySelector('[data-dynamic]');
-
- var loadingTemplate = document.createElement('div');
- loadingTemplate.innerHTML = config.loadingTemplate;
- loadingTemplate.setAttribute('data-loading-template', '');
-
- this.listTemplate = dynamicList.outerHTML;
- dynamicList.outerHTML = loadingTemplate.outerHTML;
- }
-
- if (self.cache[config.endpoint]) {
- self._loadData(self.cache[config.endpoint], config, self);
- } else {
- this._loadUrlData(config.endpoint)
- .then(function(d) {
- self._loadData(d, config, self);
- }, function(xhrError) {
- // TODO: properly handle errors due to XHR cancellation
- return;
- }).catch(function(e) {
- throw new droplabAjaxException(e.message || e);
- });
- }
- },
-
- destroy: function() {
- var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
- this.destroyed = true;
- if (this.listTemplate && dynamicList) {
- dynamicList.outerHTML = this.listTemplate;
- }
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js
deleted file mode 100644
index 05eba7aef56..00000000000
--- a/app/assets/javascripts/droplab/droplab_ajax_filter.js
+++ /dev/null
@@ -1,164 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.ajax||(g.ajax = {}));g=(g.datasource||(g.datasource = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- w.droplabAjaxFilter = {
- init: function(hook) {
- this.destroyed = false;
- this.hook = hook;
- this.notLoading();
-
- this.debounceTriggerWrapper = this.debounceTrigger.bind(this);
- this.hook.trigger.addEventListener('keydown.dl', this.debounceTriggerWrapper);
- this.hook.trigger.addEventListener('focus', this.debounceTriggerWrapper);
- this.trigger(true);
- },
-
- notLoading: function notLoading() {
- this.loading = false;
- },
-
- debounceTrigger: function debounceTrigger(e) {
- var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93];
- var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1;
- var focusEvent = e.type === 'focus';
-
- if (invalidKeyPressed || this.loading) {
- return;
- }
-
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
-
- this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200);
- },
-
- trigger: function trigger(getEntireList) {
- var config = this.hook.config.droplabAjaxFilter;
- var searchValue = this.trigger.value;
-
- if (!config || !config.endpoint || !config.searchKey) {
- return;
- }
-
- if (config.searchValueFunction) {
- searchValue = config.searchValueFunction();
- }
-
- if (config.loadingTemplate && this.hook.list.data === undefined ||
- this.hook.list.data.length === 0) {
- var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
-
- var loadingTemplate = document.createElement('div');
- loadingTemplate.innerHTML = config.loadingTemplate;
- loadingTemplate.setAttribute('data-loading-template', true);
-
- this.listTemplate = dynamicList.outerHTML;
- dynamicList.outerHTML = loadingTemplate.outerHTML;
- }
-
- if (getEntireList) {
- searchValue = '';
- }
-
- if (config.searchKey === searchValue) {
- return this.list.show();
- }
-
- this.loading = true;
-
- var params = config.params || {};
- params[config.searchKey] = searchValue;
- var self = this;
- self.cache = self.cache || {};
- var url = config.endpoint + this.buildParams(params);
- var urlCachedData = self.cache[url];
-
- if (urlCachedData) {
- self._loadData(urlCachedData, config, self);
- } else {
- this._loadUrlData(url)
- .then(function(data) {
- self._loadData(data, config, self);
- }, function(xhrError) {
- // TODO: properly handle errors due to XHR cancellation
- return;
- });
- }
- },
-
- _loadUrlData: function _loadUrlData(url) {
- var self = this;
- return new Promise(function(resolve, reject) {
- var xhr = new XMLHttpRequest;
- xhr.open('GET', url, true);
- xhr.onreadystatechange = function () {
- if(xhr.readyState === XMLHttpRequest.DONE) {
- if (xhr.status === 200) {
- var data = JSON.parse(xhr.responseText);
- self.cache[url] = data;
- return resolve(data);
- } else {
- return reject([xhr.responseText, xhr.status]);
- }
- }
- };
- xhr.send();
- });
- },
-
- _loadData: function _loadData(data, config, self) {
- if (config.loadingTemplate && self.hook.list.data === undefined ||
- self.hook.list.data.length === 0) {
- const dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
-
- if (dataLoadingTemplate) {
- dataLoadingTemplate.outerHTML = self.listTemplate;
- }
- }
-
- if (!self.destroyed) {
- var hookListChildren = self.hook.list.list.children;
- var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
-
- if (onlyDynamicList && data.length === 0) {
- self.hook.list.hide();
- }
-
- self.hook.list.setData.call(self.hook.list, data);
- }
- self.notLoading();
- self.hook.list.currentIndex = 0;
- },
-
- buildParams: function(params) {
- if (!params) return '';
- var paramsArray = Object.keys(params).map(function(param) {
- return param + '=' + (params[param] || '');
- });
- return '?' + paramsArray.join('&');
- },
-
- destroy: function destroy() {
- if (this.timeout) {
- clearTimeout(this.timeout);
- }
-
- this.destroyed = true;
-
- this.hook.trigger.removeEventListener('keydown.dl', this.debounceTriggerWrapper);
- this.hook.trigger.removeEventListener('focus', this.debounceTriggerWrapper);
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/droplab_filter.js b/app/assets/javascripts/droplab/droplab_filter.js
deleted file mode 100644
index 7f7d93f3e27..00000000000
--- a/app/assets/javascripts/droplab/droplab_filter.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/* eslint-disable */
-(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.droplab||(g.droplab = {}));g=(g.filter||(g.filter = {}));g.js = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
-/* global droplab */
-
-require('../window')(function(w){
- w.droplabFilter = {
-
- keydownWrapper: function(e){
- var hiddenCount = 0;
- var dataHiddenCount = 0;
- var list = e.detail.hook.list;
- var data = list.data;
- var value = e.detail.hook.trigger.value.toLowerCase();
- var config = e.detail.hook.config.droplabFilter;
- var matches = [];
- var filterFunction;
- // will only work on dynamically set data
- if(!data){
- return;
- }
-
- if (config && config.filterFunction && typeof config.filterFunction === 'function') {
- filterFunction = config.filterFunction;
- } else {
- filterFunction = function(o){
- // cheap string search
- o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1;
- return o;
- };
- }
-
- dataHiddenCount = data.filter(function(o) {
- return !o.droplab_hidden;
- }).length;
-
- matches = data.map(function(o) {
- return filterFunction(o, value);
- });
-
- hiddenCount = matches.filter(function(o) {
- return !o.droplab_hidden;
- }).length;
-
- if (dataHiddenCount !== hiddenCount) {
- list.render(matches);
- list.currentIndex = 0;
- }
- },
-
- init: function init(hookInput) {
- var config = hookInput.config.droplabFilter;
-
- if (!config || (!config.template && !config.filterFunction)) {
- return;
- }
-
- this.hookInput = hookInput;
- this.hookInput.trigger.addEventListener('keyup.dl', this.keydownWrapper);
- this.hookInput.trigger.addEventListener('mousedown.dl', this.keydownWrapper);
- },
-
- destroy: function destroy(){
- this.hookInput.trigger.removeEventListener('keyup.dl', this.keydownWrapper);
- this.hookInput.trigger.removeEventListener('mousedown.dl', this.keydownWrapper);
- }
- };
-});
-},{"../window":2}],2:[function(require,module,exports){
-module.exports = function(callback) {
- return (function() {
- callback(this);
- }).call(null);
-};
-
-},{}]},{},[1])(1)
-});
diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js
new file mode 100644
index 00000000000..2f840083571
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook.js
@@ -0,0 +1,22 @@
+/* eslint-disable */
+
+import DropDown from './drop_down';
+
+var Hook = function(trigger, list, plugins, config){
+ this.trigger = trigger;
+ this.list = new DropDown(list);
+ this.type = 'Hook';
+ this.event = 'click';
+ this.plugins = plugins || [];
+ this.config = config || {};
+ this.id = trigger.id;
+};
+
+Object.assign(Hook.prototype, {
+
+ addEvents: function(){},
+
+ constructor: Hook,
+});
+
+export default Hook;
diff --git a/app/assets/javascripts/droplab/hook_button.js b/app/assets/javascripts/droplab/hook_button.js
new file mode 100644
index 00000000000..be8aead1303
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook_button.js
@@ -0,0 +1,65 @@
+/* eslint-disable */
+
+import Hook from './hook';
+
+var HookButton = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+
+ this.type = 'button';
+ this.event = 'click';
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ this.addPlugins();
+};
+
+HookButton.prototype = Object.create(Hook.prototype);
+
+Object.assign(HookButton.prototype, {
+ addPlugins: function() {
+ this.plugins.forEach(plugin => plugin.init(this));
+ },
+
+ clicked: function(e){
+ var buttonEvent = new CustomEvent('click.dl', {
+ detail: {
+ hook: this,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(buttonEvent);
+
+ this.list.toggle();
+ },
+
+ addEvents: function(){
+ this.eventWrapper.clicked = this.clicked.bind(this);
+ this.trigger.addEventListener('click', this.eventWrapper.clicked);
+ },
+
+ removeEvents: function(){
+ this.trigger.removeEventListener('click', this.eventWrapper.clicked);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ this.plugins.forEach(plugin => plugin.destroy());
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+
+ this.removeEvents();
+ this.removePlugins();
+ },
+
+ constructor: HookButton,
+});
+
+
+export default HookButton;
diff --git a/app/assets/javascripts/droplab/hook_input.js b/app/assets/javascripts/droplab/hook_input.js
new file mode 100644
index 00000000000..05082334045
--- /dev/null
+++ b/app/assets/javascripts/droplab/hook_input.js
@@ -0,0 +1,119 @@
+/* eslint-disable */
+
+import Hook from './hook';
+
+var HookInput = function(trigger, list, plugins, config) {
+ Hook.call(this, trigger, list, plugins, config);
+
+ this.type = 'input';
+ this.event = 'input';
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ this.addPlugins();
+};
+
+Object.assign(HookInput.prototype, {
+ addPlugins: function() {
+ this.plugins.forEach(plugin => plugin.init(this));
+ },
+
+ addEvents: function(){
+ this.eventWrapper.mousedown = this.mousedown.bind(this);
+ this.eventWrapper.input = this.input.bind(this);
+ this.eventWrapper.keyup = this.keyup.bind(this);
+ this.eventWrapper.keydown = this.keydown.bind(this);
+
+ this.trigger.addEventListener('mousedown', this.eventWrapper.mousedown);
+ this.trigger.addEventListener('input', this.eventWrapper.input);
+ this.trigger.addEventListener('keyup', this.eventWrapper.keyup);
+ this.trigger.addEventListener('keydown', this.eventWrapper.keydown);
+ },
+
+ removeEvents: function() {
+ this.hasRemovedEvents = true;
+
+ this.trigger.removeEventListener('mousedown', this.eventWrapper.mousedown);
+ this.trigger.removeEventListener('input', this.eventWrapper.input);
+ this.trigger.removeEventListener('keyup', this.eventWrapper.keyup);
+ this.trigger.removeEventListener('keydown', this.eventWrapper.keydown);
+ },
+
+ input: function(e) {
+ if(this.hasRemovedEvents) return;
+
+ this.list.show();
+
+ const inputEvent = new CustomEvent('input.dl', {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true
+ });
+ e.target.dispatchEvent(inputEvent);
+ },
+
+ mousedown: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ const mouseEvent = new CustomEvent('mousedown.dl', {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ },
+ bubbles: true,
+ cancelable: true,
+ });
+ e.target.dispatchEvent(mouseEvent);
+ },
+
+ keyup: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ this.keyEvent(e, 'keyup.dl');
+ },
+
+ keydown: function(e) {
+ if (this.hasRemovedEvents) return;
+
+ this.keyEvent(e, 'keydown.dl');
+ },
+
+ keyEvent: function(e, eventName) {
+ this.list.show();
+
+ const keyEvent = new CustomEvent(eventName, {
+ detail: {
+ hook: this,
+ text: e.target.value,
+ which: e.which,
+ key: e.key,
+ },
+ bubbles: true,
+ cancelable: true,
+ });
+ e.target.dispatchEvent(keyEvent);
+ },
+
+ restoreInitialState: function() {
+ this.list.list.innerHTML = this.list.initialState;
+ },
+
+ removePlugins: function() {
+ this.plugins.forEach(plugin => plugin.destroy());
+ },
+
+ destroy: function() {
+ this.restoreInitialState();
+
+ this.removeEvents();
+ this.removePlugins();
+
+ this.list.destroy();
+ }
+});
+
+export default HookInput;
diff --git a/app/assets/javascripts/droplab/keyboard.js b/app/assets/javascripts/droplab/keyboard.js
new file mode 100644
index 00000000000..36740a430e1
--- /dev/null
+++ b/app/assets/javascripts/droplab/keyboard.js
@@ -0,0 +1,113 @@
+/* eslint-disable */
+
+import { ACTIVE_CLASS } from './constants';
+
+const Keyboard = function () {
+ var currentKey;
+ var currentFocus;
+ var isUpArrow = false;
+ var isDownArrow = false;
+ var removeHighlight = function removeHighlight(list) {
+ var itemElements = Array.prototype.slice.call(list.list.querySelectorAll('li:not(.divider)'), 0);
+ var listItems = [];
+ for(var i = 0; i < itemElements.length; i++) {
+ var listItem = itemElements[i];
+ listItem.classList.remove(ACTIVE_CLASS);
+
+ if (listItem.style.display !== 'none') {
+ listItems.push(listItem);
+ }
+ }
+ return listItems;
+ };
+
+ var setMenuForArrows = function setMenuForArrows(list) {
+ var listItems = removeHighlight(list);
+ if(list.currentIndex>0){
+ if(!listItems[list.currentIndex-1]){
+ list.currentIndex = list.currentIndex-1;
+ }
+
+ if (listItems[list.currentIndex-1]) {
+ var el = listItems[list.currentIndex-1];
+ var filterDropdownEl = el.closest('.filter-dropdown');
+ el.classList.add(ACTIVE_CLASS);
+
+ if (filterDropdownEl) {
+ var filterDropdownBottom = filterDropdownEl.offsetHeight;
+ var elOffsetTop = el.offsetTop - 30;
+
+ if (elOffsetTop > filterDropdownBottom) {
+ filterDropdownEl.scrollTop = elOffsetTop - filterDropdownBottom;
+ }
+ }
+ }
+ }
+ };
+
+ var mousedown = function mousedown(e) {
+ var list = e.detail.hook.list;
+ removeHighlight(list);
+ list.show();
+ list.currentIndex = 0;
+ isUpArrow = false;
+ isDownArrow = false;
+ };
+ var selectItem = function selectItem(list) {
+ var listItems = removeHighlight(list);
+ var currentItem = listItems[list.currentIndex-1];
+ var listEvent = new CustomEvent('click.dl', {
+ detail: {
+ list: list,
+ selected: currentItem,
+ data: currentItem.dataset,
+ },
+ });
+ list.list.dispatchEvent(listEvent);
+ list.hide();
+ }
+
+ var keydown = function keydown(e){
+ var typedOn = e.target;
+ var list = e.detail.hook.list;
+ var currentIndex = list.currentIndex;
+ isUpArrow = false;
+ isDownArrow = false;
+
+ if(e.detail.which){
+ currentKey = e.detail.which;
+ if(currentKey === 13){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 38) {
+ isUpArrow = true;
+ }
+ if(currentKey === 40) {
+ isDownArrow = true;
+ }
+ } else if(e.detail.key) {
+ currentKey = e.detail.key;
+ if(currentKey === 'Enter'){
+ selectItem(e.detail.hook.list);
+ return;
+ }
+ if(currentKey === 'ArrowUp') {
+ isUpArrow = true;
+ }
+ if(currentKey === 'ArrowDown') {
+ isDownArrow = true;
+ }
+ }
+ if(isUpArrow){ currentIndex--; }
+ if(isDownArrow){ currentIndex++; }
+ if(currentIndex < 0){ currentIndex = 0; }
+ list.currentIndex = currentIndex;
+ setMenuForArrows(e.detail.hook.list);
+ };
+
+ document.addEventListener('mousedown.dl', mousedown);
+ document.addEventListener('keydown.dl', keydown);
+};
+
+export default Keyboard;
diff --git a/app/assets/javascripts/droplab/plugins/ajax.js b/app/assets/javascripts/droplab/plugins/ajax.js
new file mode 100644
index 00000000000..12afe53ed76
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/ajax.js
@@ -0,0 +1,65 @@
+/* eslint-disable */
+
+const Ajax = {
+ _loadUrlData: function _loadUrlData(url) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ self.cache[url] = data;
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+ _loadData: function _loadData(data, config, self) {
+ if (config.loadingTemplate) {
+ var dataLoadingTemplate = self.hook.list.list.querySelector('[data-loading-template]');
+ if (dataLoadingTemplate) dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+
+ if (!self.destroyed) self.hook.list[config.method].call(self.hook.list, data);
+ },
+ init: function init(hook) {
+ var self = this;
+ self.destroyed = false;
+ self.cache = self.cache || {};
+ var config = hook.config.Ajax;
+ this.hook = hook;
+ if (!config || !config.endpoint || !config.method) {
+ return;
+ }
+ if (config.method !== 'setData' && config.method !== 'addData') {
+ return;
+ }
+ if (config.loadingTemplate) {
+ var dynamicList = hook.list.list.querySelector('[data-dynamic]');
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', '');
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+ if (self.cache[config.endpoint]) {
+ self._loadData(self.cache[config.endpoint], config, self);
+ } else {
+ this._loadUrlData(config.endpoint)
+ .then(function(d) {
+ self._loadData(d, config, self);
+ }, config.onError).catch(config.onError);
+ }
+ },
+ destroy: function() {
+ this.destroyed = true;
+ }
+};
+
+export default Ajax;
diff --git a/app/assets/javascripts/droplab/plugins/ajax_filter.js b/app/assets/javascripts/droplab/plugins/ajax_filter.js
new file mode 100644
index 00000000000..cfd7e2ca189
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/ajax_filter.js
@@ -0,0 +1,133 @@
+/* eslint-disable */
+
+const AjaxFilter = {
+ init: function(hook) {
+ this.destroyed = false;
+ this.hook = hook;
+ this.notLoading();
+
+ this.eventWrapper = {};
+ this.eventWrapper.debounceTrigger = this.debounceTrigger.bind(this);
+ this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceTrigger);
+ this.hook.trigger.addEventListener('focus', this.eventWrapper.debounceTrigger);
+
+ this.trigger(true);
+ },
+
+ notLoading: function notLoading() {
+ this.loading = false;
+ },
+
+ debounceTrigger: function debounceTrigger(e) {
+ var NON_CHARACTER_KEYS = [16, 17, 18, 20, 37, 38, 39, 40, 91, 93];
+ var invalidKeyPressed = NON_CHARACTER_KEYS.indexOf(e.detail.which || e.detail.keyCode) > -1;
+ var focusEvent = e.type === 'focus';
+ if (invalidKeyPressed || this.loading) {
+ return;
+ }
+ if (this.timeout) {
+ clearTimeout(this.timeout);
+ }
+ this.timeout = setTimeout(this.trigger.bind(this, focusEvent), 200);
+ },
+
+ trigger: function trigger(getEntireList) {
+ var config = this.hook.config.AjaxFilter;
+ var searchValue = this.trigger.value;
+ if (!config || !config.endpoint || !config.searchKey) {
+ return;
+ }
+ if (config.searchValueFunction) {
+ searchValue = config.searchValueFunction();
+ }
+ if (config.loadingTemplate && this.hook.list.data === undefined ||
+ this.hook.list.data.length === 0) {
+ var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
+ var loadingTemplate = document.createElement('div');
+ loadingTemplate.innerHTML = config.loadingTemplate;
+ loadingTemplate.setAttribute('data-loading-template', true);
+ this.listTemplate = dynamicList.outerHTML;
+ dynamicList.outerHTML = loadingTemplate.outerHTML;
+ }
+ if (getEntireList) {
+ searchValue = '';
+ }
+ if (config.searchKey === searchValue) {
+ return this.list.show();
+ }
+ this.loading = true;
+ var params = config.params || {};
+ params[config.searchKey] = searchValue;
+ var self = this;
+ self.cache = self.cache || {};
+ var url = config.endpoint + this.buildParams(params);
+ var urlCachedData = self.cache[url];
+ if (urlCachedData) {
+ self._loadData(urlCachedData, config, self);
+ } else {
+ this._loadUrlData(url)
+ .then(function(data) {
+ self._loadData(data, config, self);
+ }, config.onError).catch(config.onError);
+ }
+ },
+
+ _loadUrlData: function _loadUrlData(url) {
+ var self = this;
+ return new Promise(function(resolve, reject) {
+ var xhr = new XMLHttpRequest;
+ xhr.open('GET', url, true);
+ xhr.onreadystatechange = function () {
+ if(xhr.readyState === XMLHttpRequest.DONE) {
+ if (xhr.status === 200) {
+ var data = JSON.parse(xhr.responseText);
+ self.cache[url] = data;
+ return resolve(data);
+ } else {
+ return reject([xhr.responseText, xhr.status]);
+ }
+ }
+ };
+ xhr.send();
+ });
+ },
+
+ _loadData: function _loadData(data, config, self) {
+ const list = self.hook.list;
+ if (config.loadingTemplate && list.data === undefined ||
+ list.data.length === 0) {
+ const dataLoadingTemplate = list.list.querySelector('[data-loading-template]');
+ if (dataLoadingTemplate) {
+ dataLoadingTemplate.outerHTML = self.listTemplate;
+ }
+ }
+ if (!self.destroyed) {
+ var hookListChildren = list.list.children;
+ var onlyDynamicList = hookListChildren.length === 1 && hookListChildren[0].hasAttribute('data-dynamic');
+ if (onlyDynamicList && data.length === 0) {
+ list.hide();
+ }
+ list.setData.call(list, data);
+ }
+ self.notLoading();
+ list.currentIndex = 0;
+ },
+
+ buildParams: function(params) {
+ if (!params) return '';
+ var paramsArray = Object.keys(params).map(function(param) {
+ return param + '=' + (params[param] || '');
+ });
+ return '?' + paramsArray.join('&');
+ },
+
+ destroy: function destroy() {
+ if (this.timeout)clearTimeout(this.timeout);
+ this.destroyed = true;
+
+ this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceTrigger);
+ this.hook.trigger.removeEventListener('focus', this.eventWrapper.debounceTrigger);
+ }
+};
+
+export default AjaxFilter;
diff --git a/app/assets/javascripts/droplab/plugins/filter.js b/app/assets/javascripts/droplab/plugins/filter.js
new file mode 100644
index 00000000000..d6a1aadd49c
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/filter.js
@@ -0,0 +1,95 @@
+/* eslint-disable */
+
+const Filter = {
+ keydown: function(e){
+ if (this.destroyed) return;
+
+ var hiddenCount = 0;
+ var dataHiddenCount = 0;
+
+ var list = e.detail.hook.list;
+ var data = list.data;
+ var value = e.detail.hook.trigger.value.toLowerCase();
+ var config = e.detail.hook.config.Filter;
+ var matches = [];
+ var filterFunction;
+ // will only work on dynamically set data
+ if(!data){
+ return;
+ }
+
+ if (config && config.filterFunction && typeof config.filterFunction === 'function') {
+ filterFunction = config.filterFunction;
+ } else {
+ filterFunction = function(o){
+ // cheap string search
+ o.droplab_hidden = o[config.template].toLowerCase().indexOf(value) === -1;
+ return o;
+ };
+ }
+
+ dataHiddenCount = data.filter(function(o) {
+ return !o.droplab_hidden;
+ }).length;
+
+ matches = data.map(function(o) {
+ return filterFunction(o, value);
+ });
+
+ hiddenCount = matches.filter(function(o) {
+ return !o.droplab_hidden;
+ }).length;
+
+ if (dataHiddenCount !== hiddenCount) {
+ list.setData(matches);
+ list.currentIndex = 0;
+ }
+ },
+
+ debounceKeydown: function debounceKeydown(e) {
+ if ([
+ 13, // enter
+ 16, // shift
+ 17, // ctrl
+ 18, // alt
+ 20, // caps lock
+ 37, // left arrow
+ 38, // up arrow
+ 39, // right arrow
+ 40, // down arrow
+ 91, // left window
+ 92, // right window
+ 93, // select
+ ].indexOf(e.detail.which || e.detail.keyCode) > -1) return;
+
+ if (this.timeout) clearTimeout(this.timeout);
+ this.timeout = setTimeout(this.keydown.bind(this, e), 200);
+ },
+
+ init: function init(hook) {
+ var config = hook.config.Filter;
+
+ if (!config || !config.template) return;
+
+ this.hook = hook;
+ this.destroyed = false;
+
+ this.eventWrapper = {};
+ this.eventWrapper.debounceKeydown = this.debounceKeydown.bind(this);
+
+ this.hook.trigger.addEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
+ this.hook.trigger.addEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
+
+ this.debounceKeydown({ detail: { hook: this.hook } });
+ },
+
+ destroy: function destroy() {
+ if (this.timeout) clearTimeout(this.timeout);
+ this.destroyed = true;
+
+ this.hook.trigger.removeEventListener('keydown.dl', this.eventWrapper.debounceKeydown);
+ this.hook.trigger.removeEventListener('mousedown.dl', this.eventWrapper.debounceKeydown);
+ }
+};
+
+export default Filter;
diff --git a/app/assets/javascripts/droplab/plugins/input_setter.js b/app/assets/javascripts/droplab/plugins/input_setter.js
new file mode 100644
index 00000000000..d01fbc5830d
--- /dev/null
+++ b/app/assets/javascripts/droplab/plugins/input_setter.js
@@ -0,0 +1,50 @@
+/* eslint-disable */
+
+const InputSetter = {
+ init(hook) {
+ this.hook = hook;
+ this.destroyed = false;
+ this.config = hook.config.InputSetter || (this.hook.config.InputSetter = {});
+
+ this.eventWrapper = {};
+
+ this.addEvents();
+ },
+
+ addEvents() {
+ this.eventWrapper.setInputs = this.setInputs.bind(this);
+ this.hook.list.list.addEventListener('click.dl', this.eventWrapper.setInputs);
+ },
+
+ removeEvents() {
+ this.hook.list.list.removeEventListener('click.dl', this.eventWrapper.setInputs);
+ },
+
+ setInputs(e) {
+ if (this.destroyed) return;
+
+ const selectedItem = e.detail.selected;
+
+ if (!Array.isArray(this.config)) this.config = [this.config];
+
+ this.config.forEach(config => this.setInput(config, selectedItem));
+ },
+
+ setInput(config, selectedItem) {
+ const input = config.input || this.hook.trigger;
+ const newValue = selectedItem.getAttribute(config.valueAttribute);
+ const inputAttribute = config.inputAttribute;
+
+ if (input.hasAttribute(inputAttribute)) return input.setAttribute(inputAttribute, newValue);
+ if (input.tagName === 'INPUT') return input.value = newValue;
+ return input.textContent = newValue;
+ },
+
+ destroy() {
+ this.destroyed = true;
+
+ this.removeEvents();
+ },
+};
+
+export default InputSetter;
diff --git a/app/assets/javascripts/droplab/utils.js b/app/assets/javascripts/droplab/utils.js
new file mode 100644
index 00000000000..c149a33a1e9
--- /dev/null
+++ b/app/assets/javascripts/droplab/utils.js
@@ -0,0 +1,38 @@
+/* eslint-disable */
+
+import { DATA_TRIGGER, DATA_DROPDOWN } from './constants';
+
+const utils = {
+ toCamelCase(attr) {
+ return this.camelize(attr.split('-').slice(1).join(' '));
+ },
+
+ t(s, d) {
+ for (const p in d) {
+ if (Object.prototype.hasOwnProperty.call(d, p)) {
+ s = s.replace(new RegExp(`{{${p}}}`, 'g'), d[p]);
+ }
+ }
+ return s;
+ },
+
+ camelize(str) {
+ return str.replace(/(?:^\w|[A-Z]|\b\w)/g, (letter, index) => {
+ return index === 0 ? letter.toLowerCase() : letter.toUpperCase();
+ }).replace(/\s+/g, '');
+ },
+
+ closest(thisTag, stopTag) {
+ while (thisTag && thisTag.tagName !== stopTag && thisTag.tagName !== 'HTML') {
+ thisTag = thisTag.parentNode;
+ }
+ return thisTag;
+ },
+
+ isDropDownParts(target) {
+ if (!target || target.tagName === 'HTML') return false;
+ return target.hasAttribute(DATA_TRIGGER) || target.hasAttribute(DATA_DROPDOWN);
+ },
+};
+
+export default utils;
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js
index 8abbcf0c227..d2514593e3a 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.js
@@ -31,12 +31,6 @@ export default Vue.component('environment-folder-view', {
cssContainerClass: environmentsData.cssClass,
canCreateDeployment: environmentsData.canCreateDeployment,
canReadEnvironment: environmentsData.canReadEnvironment,
-
- // svgs
- commitIconSvg: environmentsData.commitIconSvg,
- playIconSvg: environmentsData.playIconSvg,
- terminalIconSvg: environmentsData.terminalIconSvg,
-
// Pagination Properties,
paginationInformation: {},
pageNumber: 1,
@@ -163,9 +157,6 @@ export default Vue.component('environment-folder-view', {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
- :play-icon-svg="playIconSvg"
- :terminal-icon-svg="terminalIconSvg"
- :commit-icon-svg="commitIconSvg"
:service="service"/>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js
index 3f041172ff3..59d6508fc02 100644
--- a/app/assets/javascripts/files_comment_button.js
+++ b/app/assets/javascripts/files_comment_button.js
@@ -55,14 +55,19 @@ window.FilesCommentButton = (function() {
textFileElement = this.getTextFileElement($currentTarget);
buttonParentElement.append(this.buildButton({
+ discussionID: lineContentElement.attr('data-discussion-id'),
+ lineType: lineContentElement.attr('data-line-type'),
+
noteableType: textFileElement.attr('data-noteable-type'),
noteableID: textFileElement.attr('data-noteable-id'),
commitID: textFileElement.attr('data-commit-id'),
noteType: lineContentElement.attr('data-note-type'),
- position: lineContentElement.attr('data-position'),
- lineType: lineContentElement.attr('data-line-type'),
- discussionID: lineContentElement.attr('data-discussion-id'),
- lineCode: lineContentElement.attr('data-line-code')
+
+ // LegacyDiffNote
+ lineCode: lineContentElement.attr('data-line-code'),
+
+ // DiffNote
+ position: lineContentElement.attr('data-position')
}));
};
@@ -76,14 +81,19 @@ window.FilesCommentButton = (function() {
FilesCommentButton.prototype.buildButton = function(buttonAttributes) {
return $commentButtonTemplate.clone().attr({
+ 'data-discussion-id': buttonAttributes.discussionID,
+ 'data-line-type': buttonAttributes.lineType,
+
'data-noteable-type': buttonAttributes.noteableType,
'data-noteable-id': buttonAttributes.noteableID,
'data-commit-id': buttonAttributes.commitID,
'data-note-type': buttonAttributes.noteType,
+
+ // LegacyDiffNote
'data-line-code': buttonAttributes.lineCode,
- 'data-position': buttonAttributes.position,
- 'data-discussion-id': buttonAttributes.discussionID,
- 'data-line-type': buttonAttributes.lineType
+
+ // DiffNote
+ 'data-position': buttonAttributes.position
});
};
@@ -121,7 +131,7 @@ window.FilesCommentButton = (function() {
};
FilesCommentButton.prototype.validateLineContent = function(lineContentElement) {
- return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== '';
+ return lineContentElement.attr('data-note-type') && lineContentElement.attr('data-note-type') !== '';
};
return FilesCommentButton;
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
new file mode 100644
index 00000000000..9126422b335
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js
@@ -0,0 +1,87 @@
+import eventHub from '../event_hub';
+
+export default {
+ name: 'RecentSearchesDropdownContent',
+
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ },
+
+ computed: {
+ processedItems() {
+ return this.items.map((item) => {
+ const { tokens, searchToken }
+ = gl.FilteredSearchTokenizer.processTokens(item);
+
+ const resultantTokens = tokens.map(token => ({
+ prefix: `${token.key}:`,
+ suffix: `${token.symbol}${token.value}`,
+ }));
+
+ return {
+ text: item,
+ tokens: resultantTokens,
+ searchToken,
+ };
+ });
+ },
+ hasItems() {
+ return this.items.length > 0;
+ },
+ },
+
+ methods: {
+ onItemActivated(text) {
+ eventHub.$emit('recentSearchesItemSelected', text);
+ },
+ onRequestClearRecentSearches(e) {
+ // Stop the dropdown from closing
+ e.stopPropagation();
+
+ eventHub.$emit('requestClearRecentSearches');
+ },
+ },
+
+ template: `
+ <div>
+ <ul v-if="hasItems">
+ <li
+ v-for="(item, index) in processedItems"
+ :key="index">
+ <button
+ type="button"
+ class="filtered-search-history-dropdown-item"
+ @click="onItemActivated(item.text)">
+ <span>
+ <span
+ v-for="(token, tokenIndex) in item.tokens"
+ class="filtered-search-history-dropdown-token">
+ <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
+ </span>
+ </span>
+ <span class="filtered-search-history-dropdown-search-token">
+ {{ item.searchToken }}
+ </span>
+ </button>
+ </li>
+ <li class="divider"></li>
+ <li>
+ <button
+ type="button"
+ class="filtered-search-history-clear-button"
+ @click="onRequestClearRecentSearches($event)">
+ Clear recent searches
+ </button>
+ </li>
+ </ul>
+ <div
+ v-else
+ class="dropdown-info-note">
+ You don't have any recent searches
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js
index 98dcb697af9..381c40c03d8 100644
--- a/app/assets/javascripts/filtered_search/dropdown_hint.js
+++ b/app/assets/javascripts/filtered_search/dropdown_hint.js
@@ -1,13 +1,13 @@
-require('./filtered_search_dropdown');
+import Filter from '~/droplab/plugins/filter';
-/* global droplabFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownHint extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
- droplabFilter: {
+ Filter: {
template: 'hint',
filterFunction: gl.DropdownUtils.filterHint.bind(null, input),
},
@@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() {
const dropdownData = [];
- [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
+ [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push(
@@ -69,12 +69,12 @@ require('./filtered_search_dropdown');
}
});
- this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config);
+ this.droplab.changeHookList(this.hookId, this.dropdown, [Filter], this.config);
this.droplab.setData(this.hookId, dropdownData);
}
init() {
- this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init();
+ this.droplab.addHook(this.input, this.dropdown, [Filter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index b3dc3e502c5..6296965b911 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -1,7 +1,9 @@
-require('./filtered_search_dropdown');
+/* global Flash */
+
+import Ajax from '~/droplab/plugins/ajax';
+import Filter from '~/droplab/plugins/filter';
-/* global droplabAjax */
-/* global droplabFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownNonUser extends gl.FilteredSearchDropdown {
@@ -9,13 +11,19 @@ require('./filtered_search_dropdown');
super(droplab, dropdown, input, filter);
this.symbol = symbol;
this.config = {
- droplabAjax: {
+ Ajax: {
endpoint,
method: 'setData',
loadingTemplate: this.loadingTemplate,
+ onError() {
+ /* eslint-disable no-new */
+ new Flash('An error occured fetching the dropdown data.');
+ /* eslint-enable no-new */
+ },
},
- droplabFilter: {
+ Filter: {
filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input),
+ template: 'title',
},
};
}
@@ -29,13 +37,13 @@ require('./filtered_search_dropdown');
renderContent(forceShowList = false) {
this.droplab
- .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config);
+ .changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config);
super.renderContent(forceShowList);
}
init() {
this.droplab
- .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init();
+ .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index 04e2afad02f..38b5d315bcf 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,13 +1,15 @@
-require('./filtered_search_dropdown');
+/* global Flash */
+
+import AjaxFilter from '~/droplab/plugins/ajax_filter';
-/* global droplabAjaxFilter */
+require('./filtered_search_dropdown');
(() => {
class DropdownUser extends gl.FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
super(droplab, dropdown, input, filter);
this.config = {
- droplabAjaxFilter: {
+ AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
@@ -18,6 +20,11 @@ require('./filtered_search_dropdown');
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
+ onError() {
+ /* eslint-disable no-new */
+ new Flash('An error occured fetching the dropdown data.');
+ /* eslint-enable no-new */
+ },
},
};
}
@@ -28,7 +35,7 @@ require('./filtered_search_dropdown');
}
renderContent(forceShowList = false) {
- this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config);
+ this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
@@ -56,7 +63,7 @@ require('./filtered_search_dropdown');
}
init() {
- this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init();
+ this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js
index 432b0c0dfd2..6c5c20447f7 100644
--- a/app/assets/javascripts/filtered_search/dropdown_utils.js
+++ b/app/assets/javascripts/filtered_search/dropdown_utils.js
@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
}
});
- return values.join(' ');
+ return values
+ .map(value => value.trim())
+ .join(' ');
}
static getSearchInput(filteredSearchInput) {
diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
index e7bf530d343..d58eeeebf81 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js
@@ -4,7 +4,7 @@
class FilteredSearchDropdown {
constructor(droplab, dropdown, input, filter) {
this.droplab = droplab;
- this.hookId = input && input.getAttribute('data-id');
+ this.hookId = input && input.id;
this.input = input;
this.filter = filter;
this.dropdown = dropdown;
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 5fbe0450bb8..ec481b9ef97 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,4 +1,4 @@
-/* global DropLab */
+import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
(() => {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 22352950452..b93a8f1d322 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,18 +1,56 @@
+/* global Flash */
+
import FilteredSearchContainer from './container';
+import RecentSearchesRoot from './recent_searches_root';
+import RecentSearchesStore from './stores/recent_searches_store';
+import RecentSearchesService from './services/recent_searches_service';
+import eventHub from './event_hub';
(() => {
class FilteredSearchManager {
constructor(page) {
this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search');
+ this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
+ this.recentSearchesStore = new RecentSearchesStore();
+ let recentSearchesKey = 'issue-recent-searches';
+ if (page === 'merge_requests') {
+ recentSearchesKey = 'merge-request-recent-searches';
+ }
+ this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
+
+ // Fetch recent searches from localStorage
+ this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
+ .catch(() => {
+ // eslint-disable-next-line no-new
+ new Flash('An error occured while parsing recent searches');
+ // Gracefully fail to empty array
+ return [];
+ })
+ .then((searches) => {
+ // Put any searches that may have come in before
+ // we fetched the saved searches ahead of the already saved ones
+ const resultantSearches = this.recentSearchesStore.setRecentSearches(
+ this.recentSearchesStore.state.recentSearches.concat(searches),
+ );
+ this.recentSearchesService.save(resultantSearches);
+ });
+
if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
+ this.recentSearchesRoot = new RecentSearchesRoot(
+ this.recentSearchesStore,
+ this.recentSearchesService,
+ document.querySelector('.js-filtered-search-history-dropdown'),
+ );
+ this.recentSearchesRoot.init();
+
this.bindEvents();
this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown();
@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() {
this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper);
+
+ if (this.recentSearchesRoot) {
+ this.recentSearchesRoot.destroy();
+ }
}
bindEvents() {
@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.bind(this);
- this.clearSearchWrapper = this.clearSearch.bind(this);
+ this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
+ this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
- this.filteredSearchInputForm = this.filteredSearchInput.form;
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
- this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
+ this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
+ eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
unbindEvents() {
@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
- this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
+ this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
+ eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
}
checkForBackspace(e) {
@@ -110,7 +154,7 @@ import FilteredSearchContainer from './container';
if (e.keyCode === 13) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element;
- const activeElements = dropdownEl.querySelectorAll('.dropdown-active');
+ const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
e.preventDefault();
@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
}
addInputContainerFocus() {
- const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
+ const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) {
inputContainer.classList.add('focus');
@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
}
removeInputContainerFocus(e) {
- const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
+ const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
}
unselectEditTokens(e) {
- const inputContainer = this.container.querySelector('.filtered-search-input-container');
+ const inputContainer = this.container.querySelector('.filtered-search-box');
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container');
@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
}
}
- clearSearch(e) {
+ onClearSearch(e) {
e.preventDefault();
+ this.clearSearch();
+ }
+ clearSearch() {
this.filteredSearchInput.value = '';
const removeElements = [];
@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search();
}
+ saveCurrentSearchQuery() {
+ // Don't save before we have fetched the already saved searches
+ this.fetchingRecentSearchesPromise.then(() => {
+ const searchQuery = gl.DropdownUtils.getSearchQuery();
+ if (searchQuery.length > 0) {
+ const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
+ this.recentSearchesService.save(resultantSearches);
+ }
+ });
+ }
+
loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams();
@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
}
});
+ this.saveCurrentSearchQuery();
+
if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder();
@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() {
const paths = [];
+ const searchQuery = gl.DropdownUtils.getSearchQuery();
+
+ this.saveCurrentSearchQuery();
+
const { tokens, searchToken }
- = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery());
+ = this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`);
@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent();
}
}
+
+ onrecentSearchesItemSelected(text) {
+ this.clearSearch();
+ this.filteredSearchInput.value = text;
+ this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
+ this.search();
+ }
}
window.gl = window.gl || {};
diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js
new file mode 100644
index 00000000000..4e38409e12a
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/recent_searches_root.js
@@ -0,0 +1,59 @@
+import Vue from 'vue';
+import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
+import eventHub from './event_hub';
+
+class RecentSearchesRoot {
+ constructor(
+ recentSearchesStore,
+ recentSearchesService,
+ wrapperElement,
+ ) {
+ this.store = recentSearchesStore;
+ this.service = recentSearchesService;
+ this.wrapperElement = wrapperElement;
+ }
+
+ init() {
+ this.bindEvents();
+ this.render();
+ }
+
+ bindEvents() {
+ this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
+
+ eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
+ }
+
+ unbindEvents() {
+ eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
+ }
+
+ render() {
+ this.vm = new Vue({
+ el: this.wrapperElement,
+ data: this.store.state,
+ template: `
+ <recent-searches-dropdown-content
+ :items="recentSearches" />
+ `,
+ components: {
+ 'recent-searches-dropdown-content': RecentSearchesDropdownContent,
+ },
+ });
+ }
+
+ onRequestClearRecentSearches() {
+ const resultantSearches = this.store.setRecentSearches([]);
+ this.service.save(resultantSearches);
+ }
+
+ destroy() {
+ this.unbindEvents();
+ if (this.vm) {
+ this.vm.$destroy();
+ }
+ }
+
+}
+
+export default RecentSearchesRoot;
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
new file mode 100644
index 00000000000..3e402d5aed0
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js
@@ -0,0 +1,26 @@
+class RecentSearchesService {
+ constructor(localStorageKey = 'issuable-recent-searches') {
+ this.localStorageKey = localStorageKey;
+ }
+
+ fetch() {
+ const input = window.localStorage.getItem(this.localStorageKey);
+
+ let searches = [];
+ if (input && input.length > 0) {
+ try {
+ searches = JSON.parse(input);
+ } catch (err) {
+ return Promise.reject(err);
+ }
+ }
+
+ return Promise.resolve(searches);
+ }
+
+ save(searches = []) {
+ window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
+ }
+}
+
+export default RecentSearchesService;
diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
new file mode 100644
index 00000000000..066be69766a
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js
@@ -0,0 +1,23 @@
+import _ from 'underscore';
+
+class RecentSearchesStore {
+ constructor(initialState = {}) {
+ this.state = Object.assign({
+ recentSearches: [],
+ }, initialState);
+ }
+
+ addRecentSearch(newSearch) {
+ this.setRecentSearches([newSearch].concat(this.state.recentSearches));
+
+ return this.state.recentSearches;
+ }
+
+ setRecentSearches(searches = []) {
+ const trimmedSearches = searches.map(search => search.trim());
+ this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
+ return this.state.recentSearches;
+ }
+}
+
+export default RecentSearchesStore;
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index 9ac4c49d697..b62b2cec4d8 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -50,7 +50,7 @@ window.gl.GfmAutoComplete = {
template: '<li>${title}</li>'
},
Loading: {
- template: '<li style="pointer-events: none;"><i class="fa fa-refresh fa-spin"></i> Loading...</li>'
+ template: '<li style="pointer-events: none;"><i class="fa fa-spinner fa-spin"></i> Loading...</li>'
},
DefaultOptions: {
sorter: function(query, items, searchKey) {
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index e7c98e16581..ff10f19a4fe 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -29,7 +29,8 @@ GLForm.prototype.setupForm = function() {
this.form.find('.div-dropzone').remove();
this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes
- gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button'));
+ gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button, .js-note-new-discussion'));
+
gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form);
autosize(this.textarea);
diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js
new file mode 100644
index 00000000000..7732edde1e7
--- /dev/null
+++ b/app/assets/javascripts/group.js
@@ -0,0 +1,21 @@
+export default class Group {
+ constructor() {
+ this.groupPath = $('#group_path');
+ this.groupName = $('#group_name');
+ this.updateHandler = this.update.bind(this);
+ this.resetHandler = this.reset.bind(this);
+ if (this.groupName.val() === '') {
+ this.groupPath.on('keyup', this.updateHandler);
+ this.groupName.on('keydown', this.resetHandler);
+ }
+ }
+
+ update() {
+ this.groupName.val(this.groupPath.val());
+ }
+
+ reset() {
+ this.groupPath.off('keyup', this.updateHandler);
+ this.groupName.off('keydown', this.resetHandler);
+ }
+}
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 47e675f537e..011043e992f 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -20,57 +20,60 @@ class Issue {
});
Issue.initIssueBtnEventListeners();
}
+
+ Issue.$btnNewBranch = $('#new-branch');
+
Issue.initMergeRequests();
Issue.initRelatedBranches();
Issue.initCanCreateBranch();
}
static initIssueBtnEventListeners() {
- var issueFailMessage;
- issueFailMessage = 'Unable to update this issue at this time.';
- return $('a.btn-close, a.btn-reopen').on('click', function(e) {
- var $this, isClose, shouldSubmit, url;
+ const issueFailMessage = 'Unable to update this issue at this time.';
+
+ const closeButtons = $('a.btn-close');
+ const isClosedBadge = $('div.status-box-closed');
+ const isOpenBadge = $('div.status-box-open');
+ const projectIssuesCounter = $('.issue_counter');
+ const reopenButtons = $('a.btn-reopen');
+
+ return closeButtons.add(reopenButtons).on('click', function(e) {
+ var $this, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
$this = $(this);
- isClose = $this.hasClass('btn-close');
shouldSubmit = $this.hasClass('btn-comment');
if (shouldSubmit) {
Issue.submitNoteForm($this.closest('form'));
}
$this.prop('disabled', true);
+ Issue.setNewBranchButtonState(true, null);
url = $this.attr('href');
return $.ajax({
type: 'PUT',
- url: url,
- error: function(jqXHR, textStatus, errorThrown) {
- var issueStatus;
- issueStatus = isClose ? 'close' : 'open';
- return new Flash(issueFailMessage, 'alert');
- },
- success: function(data, textStatus, jqXHR) {
- if ('id' in data) {
- $(document).trigger('issuable:change');
- let total = Number($('.issue_counter').text().replace(/[^\d]/, ''));
- if (isClose) {
- $('a.btn-close').addClass('hidden');
- $('a.btn-reopen').removeClass('hidden');
- $('div.status-box-closed').removeClass('hidden');
- $('div.status-box-open').addClass('hidden');
- total -= 1;
- } else {
- $('a.btn-reopen').addClass('hidden');
- $('a.btn-close').removeClass('hidden');
- $('div.status-box-closed').addClass('hidden');
- $('div.status-box-open').removeClass('hidden');
- total += 1;
- }
- $('.issue_counter').text(gl.text.addDelimiter(total));
- } else {
- new Flash(issueFailMessage, 'alert');
- }
- return $this.prop('disabled', false);
+ url: url
+ }).fail(function(jqXHR, textStatus, errorThrown) {
+ new Flash(issueFailMessage);
+ Issue.initCanCreateBranch();
+ }).done(function(data, textStatus, jqXHR) {
+ if ('id' in data) {
+ $(document).trigger('issuable:change');
+
+ const isClosed = $this.hasClass('btn-close');
+ closeButtons.toggleClass('hidden', isClosed);
+ reopenButtons.toggleClass('hidden', !isClosed);
+ isClosedBadge.toggleClass('hidden', !isClosed);
+ isOpenBadge.toggleClass('hidden', isClosed);
+
+ let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
+ numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
+ projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
+ } else {
+ new Flash(issueFailMessage);
}
+
+ $this.prop('disabled', false);
+ Issue.initCanCreateBranch();
});
});
}
@@ -86,9 +89,9 @@ class Issue {
static initMergeRequests() {
var $container;
$container = $('#merge-requests');
- return $.getJSON($container.data('url')).error(function() {
- return new Flash('Failed to load referenced merge requests', 'alert');
- }).success(function(data) {
+ return $.getJSON($container.data('url')).fail(function() {
+ return new Flash('Failed to load referenced merge requests');
+ }).done(function(data) {
if ('html' in data) {
return $container.html(data.html);
}
@@ -98,9 +101,9 @@ class Issue {
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
- return $.getJSON($container.data('url')).error(function() {
- return new Flash('Failed to load related branches', 'alert');
- }).success(function(data) {
+ return $.getJSON($container.data('url')).fail(function() {
+ return new Flash('Failed to load related branches');
+ }).done(function(data) {
if ('html' in data) {
return $container.html(data.html);
}
@@ -108,24 +111,27 @@ class Issue {
}
static initCanCreateBranch() {
- var $container;
- $container = $('#new-branch');
// If the user doesn't have the required permissions the container isn't
// rendered at all.
- if ($container.length === 0) {
+ if (Issue.$btnNewBranch.length === 0) {
return;
}
- return $.getJSON($container.data('path')).error(function() {
- $container.find('.unavailable').show();
- return new Flash('Failed to check if a new branch can be created.', 'alert');
- }).success(function(data) {
- if (data.can_create_branch) {
- $container.find('.available').show();
- } else {
- return $container.find('.unavailable').show();
- }
+ return $.getJSON(Issue.$btnNewBranch.data('path')).fail(function() {
+ Issue.setNewBranchButtonState(false, false);
+ new Flash('Failed to check if a new branch can be created.');
+ }).done(function(data) {
+ Issue.setNewBranchButtonState(false, data.can_create_branch);
});
}
+
+ static setNewBranchButtonState(isPending, canCreate) {
+ if (Issue.$btnNewBranch.length === 0) {
+ return;
+ }
+
+ Issue.$btnNewBranch.find('.available').toggle(!isPending && canCreate);
+ Issue.$btnNewBranch.find('.unavailable').toggle(!isPending && !canCreate);
+ }
}
export default Issue;
diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js
index 5c22aea51cd..e31cc5fbabe 100644
--- a/app/assets/javascripts/lib/utils/poll.js
+++ b/app/assets/javascripts/lib/utils/poll.js
@@ -65,7 +65,6 @@ export default class Poll {
this.makeRequest();
}, pollInterval);
}
-
this.options.successCallback(response);
}
@@ -76,8 +75,14 @@ export default class Poll {
notificationCallback(true);
return resource[method](data)
- .then(response => this.checkConditions(response))
- .catch(error => errorCallback(error));
+ .then((response) => {
+ this.checkConditions(response);
+ notificationCallback(false);
+ })
+ .catch((error) => {
+ notificationCallback(false);
+ errorCallback(error);
+ });
}
/**
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index 5b50bc62876..c50ec24c818 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -37,14 +37,7 @@ import './shortcuts_issuable';
import './shortcuts_network';
// behaviors
-import './behaviors/autosize';
-import './behaviors/details_behavior';
-import './behaviors/quick_submit';
-import './behaviors/requires_input';
-import './behaviors/toggler_behavior';
-import './behaviors/bind_in_out';
-import { installGlEmojiElement } from './behaviors/gl_emoji';
-installGlEmojiElement();
+import './behaviors/';
// blob
import './blob/create_branch_dropdown';
@@ -75,12 +68,6 @@ import './u2f/error';
import './u2f/register';
import './u2f/util';
-// droplab
-import './droplab/droplab';
-import './droplab/droplab_ajax';
-import './droplab/droplab_ajax_filter';
-import './droplab/droplab_filter';
-
// everything else
import './abuse_reports';
import './activities';
@@ -285,7 +272,7 @@ $(function () {
// Disable form buttons while a form is submitting
$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) {
var buttons;
- buttons = $('[type="submit"]', this);
+ buttons = $('[type="submit"], .js-disable-on-submit', this);
switch (e.type) {
case 'ajax:beforeSend':
case 'submit':
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index 3c4e6102469..f7f6a773036 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -3,9 +3,6 @@
/* global Flash */
import Cookies from 'js-cookie';
-
-import CommitPipelinesTable from './commit/pipelines/pipelines_table';
-
import './breakpoints';
import './flash';
@@ -90,6 +87,7 @@ import './flash';
.on('click', this.clickTab);
}
+ // Used in tests
unbindEvents() {
$(document)
.off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
@@ -99,10 +97,12 @@ import './flash';
.off('click', this.clickTab);
}
- destroy() {
- this.unbindEvents();
+ destroyPipelinesView() {
if (this.commitPipelinesTable) {
this.commitPipelinesTable.$destroy();
+ this.commitPipelinesTable = null;
+
+ document.querySelector('#commit-pipeline-table-view').innerHTML = '';
}
}
@@ -128,6 +128,7 @@ import './flash';
this.loadCommits($target.attr('href'));
this.expandView();
this.resetViewContainer();
+ this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
this.loadDiff($target.attr('href'));
if (Breakpoints.get().getBreakpointSize() !== 'lg') {
@@ -136,12 +137,14 @@ import './flash';
if (this.diffViewType() === 'parallel') {
this.expandViewContainer();
}
+ this.destroyPipelinesView();
} else if (action === 'pipelines') {
this.resetViewContainer();
- this.loadPipelines();
+ this.mountPipelinesView();
} else {
this.expandView();
this.resetViewContainer();
+ this.destroyPipelinesView();
}
if (this.setUrl) {
this.setCurrentAction(action);
@@ -227,16 +230,12 @@ import './flash';
});
}
- loadPipelines() {
- if (this.pipelinesLoaded) {
- return;
- }
- const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
- // Could already be mounted from the `pipelines_bundle`
- if (pipelineTableViewEl) {
- this.commitPipelinesTable = new CommitPipelinesTable().$mount(pipelineTableViewEl);
- }
- this.pipelinesLoaded = true;
+ mountPipelinesView() {
+ this.commitPipelinesTable = new gl.CommitPipelinesTable().$mount();
+ // $mount(el) replaces the el with the new rendered component. We need it in order to mount
+ // it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
+ document.querySelector('#commit-pipeline-table-view')
+ .appendChild(this.commitPipelinesTable.$el);
}
loadDiff(source) {
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index ac4fad88fe5..773fe3233a7 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -2,8 +2,6 @@
/* global Issuable */
/* global ListMilestone */
-import Vue from 'vue';
-
(function() {
this.MilestoneSelect = (function() {
function MilestoneSelect(currentProject, els) {
@@ -151,12 +149,12 @@ import Vue from 'vue';
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (selected.id !== -1) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'milestone', new ListMilestone({
+ gl.issueBoards.boardStoreIssueSet('milestone', new ListMilestone({
id: selected.id,
title: selected.name
}));
} else {
- Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'milestone');
+ gl.issueBoards.boardStoreIssueDelete('milestone');
}
$dropdown.trigger('loading.gl.dropdown');
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index 1d563c63f39..15f7a813626 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -5,6 +5,7 @@
/* global mrRefreshWidgetUrl */
import Cookies from 'js-cookie';
+import CommentTypeToggle from './comment_type_toggle';
require('./autosave');
window.autosize = require('vendor/autosize');
@@ -110,7 +111,6 @@ require('./task_list');
$(document).on("visibilitychange", this.visibilityChange);
// when issue status changes, we need to refresh data
$(document).on("issuable:change", this.refresh);
-
// when a key is clicked on the notes
return $(document).on("keydown", ".js-note-text", this.keydownNoteText);
};
@@ -137,6 +137,26 @@ require('./task_list');
$(document).off("click", '.system-note-commit-list-toggler');
};
+ Notes.initCommentTypeToggle = function (form) {
+ const dropdownTrigger = form.querySelector('.js-comment-type-dropdown .dropdown-toggle');
+ const dropdownList = form.querySelector('.js-comment-type-dropdown .dropdown-menu');
+ const noteTypeInput = form.querySelector('#note_type');
+ const submitButton = form.querySelector('.js-comment-type-dropdown .js-comment-submit-button');
+ const closeButton = form.querySelector('.js-note-target-close');
+ const reopenButton = form.querySelector('.js-note-target-reopen');
+
+ const commentTypeToggle = new CommentTypeToggle({
+ dropdownTrigger,
+ dropdownList,
+ noteTypeInput,
+ submitButton,
+ closeButton,
+ reopenButton,
+ });
+
+ commentTypeToggle.initDroplab();
+ };
+
Notes.prototype.keydownNoteText = function(e) {
var $textarea, discussionNoteForm, editNote, myLastNote, myLastNoteEditBtn, newText, originalText;
if (gl.utils.isMetaKey(e)) {
@@ -192,7 +212,7 @@ require('./task_list');
};
Notes.prototype.refresh = function() {
- if (!document.hidden && document.URL.indexOf(this.noteable_url) === 0) {
+ if (!document.hidden) {
return this.getContent();
}
};
@@ -213,11 +233,7 @@ require('./task_list');
_this.last_fetched_at = data.last_fetched_at;
_this.setPollingInterval(data.notes.length);
return $.each(notes, function(i, note) {
- if (note.discussion_html != null) {
- return _this.renderDiscussionNote(note);
- } else {
- return _this.renderNote(note);
- }
+ _this.renderNote(note);
});
};
})(this)
@@ -276,8 +292,12 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderNote = function(note) {
+ Notes.prototype.renderNote = function(note, $form) {
var $notesList;
+ if (note.discussion_html != null) {
+ return this.renderDiscussionNote(note, $form);
+ }
+
if (!note.valid) {
if (note.errors.commands_only) {
new Flash(note.errors.commands_only, 'notice', this.parentTimeline);
@@ -317,61 +337,50 @@ require('./task_list');
Note: for rendering inline notes use renderDiscussionNote
*/
- Notes.prototype.renderDiscussionNote = function(note) {
- var discussionContainer, form, note_html, row, lineType, diffAvatarContainer;
+ Notes.prototype.renderDiscussionNote = function(note, $form) {
+ var discussionContainer, form, row, lineType, diffAvatarContainer;
if (!this.isNewNote(note)) {
return;
}
this.note_ids.push(note.id);
- form = $("#new-discussion-note-form-" + note.discussion_id);
- if ((note.original_discussion_id != null) && form.length === 0) {
- form = $("#new-discussion-note-form-" + note.original_discussion_id);
- }
+ form = $form || $(".js-discussion-note-form[data-discussion-id='" + note.discussion_id + "']");
row = form.closest("tr");
lineType = this.isParallelView() ? form.find('#line_type').val() : 'old';
diffAvatarContainer = row.prevAll('.line_holder').first().find('.js-avatar-container.' + lineType + '_line');
- note_html = $(note.html);
- note_html.renderGFM();
// is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
- if ((note.original_discussion_id != null) && discussionContainer.length === 0) {
- discussionContainer = $(".notes[data-discussion-id='" + note.original_discussion_id + "']");
+ if (!discussionContainer.length) {
+ discussionContainer = form.closest('.discussion').find('.notes');
}
if (discussionContainer.length === 0) {
- if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
- // insert the note and the reply button after the temp row
- row.after(note.diff_discussion_html);
+ if (note.diff_discussion_html) {
+ var $discussion = $(note.diff_discussion_html).renderGFM();
- // remove the note (will be added again below)
- row.next().find(".note").remove();
- } else {
- // Merge new discussion HTML in
- var $discussion = $(note.diff_discussion_html);
- var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]');
- var contentContainerClass = '.' + $notes.closest('.notes_content')
- .attr('class')
- .split(' ')
- .join('.');
-
- // remove the note (will be added again below)
- $notes.find('.note').remove();
-
- row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ if (!this.isParallelView() || row.hasClass('js-temp-notes-holder')) {
+ // insert the note and the reply button after the temp row
+ row.after($discussion);
+ } else {
+ // Merge new discussion HTML in
+ var $notes = $discussion.find('.notes[data-discussion-id="' + note.discussion_id + '"]');
+ var contentContainerClass = '.' + $notes.closest('.notes_content')
+ .attr('class')
+ .split(' ')
+ .join('.');
+
+ row.find(contentContainerClass + ' .content').append($notes.closest('.content').children());
+ }
}
- // Before that, the container didn't exist
- discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
- // Add note to 'Changes' page discussions
- discussionContainer.append(note_html);
+
// Init discussion on 'Discussion' page if it is merge request page
- if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) {
- $('ul.main-notes-list').append(note.discussion_html).renderGFM();
+ if ($('body').attr('data-page').indexOf('projects:merge_request') === 0 || !note.diff_discussion_html) {
+ $('ul.main-notes-list').append($(note.discussion_html).renderGFM());
}
} else {
// append new note to all matching discussions
- discussionContainer.append(note_html);
+ discussionContainer.append($(note.html).renderGFM());
}
- if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_id) {
+ if (typeof gl.diffNotesCompileComponents !== 'undefined' && note.discussion_resolvable) {
gl.diffNotesCompileComponents();
this.renderDiscussionAvatar(diffAvatarContainer, note);
}
@@ -455,9 +464,14 @@ require('./task_list');
form.addClass("js-main-target-form");
form.find("#note_line_code").remove();
form.find("#note_position").remove();
- form.find("#note_type").remove();
+ form.find("#note_type").val('');
+ form.find("#in_reply_to_discussion_id").remove();
form.find('.js-comment-resolve-button').closest('comment-and-resolve-btn').remove();
- return this.parentTimeline = form.parents('.timeline');
+ this.parentTimeline = form.parents('.timeline');
+
+ if (form.length) {
+ Notes.initCommentTypeToggle(form.get(0));
+ }
};
/*
@@ -470,10 +484,24 @@ require('./task_list');
*/
Notes.prototype.setupNoteForm = function(form) {
- var textarea;
+ var textarea, key;
new gl.GLForm(form);
textarea = form.find(".js-note-text");
- return new Autosave(textarea, ["Note", form.find("#note_noteable_type").val(), form.find("#note_noteable_id").val(), form.find("#note_commit_id").val(), form.find("#note_type").val(), form.find("#note_line_code").val(), form.find("#note_position").val()]);
+ key = [
+ "Note",
+ form.find("#note_noteable_type").val(),
+ form.find("#note_noteable_id").val(),
+ form.find("#note_commit_id").val(),
+ form.find("#note_type").val(),
+ form.find("#in_reply_to_discussion_id").val(),
+
+ // LegacyDiffNote
+ form.find("#note_line_code").val(),
+
+ // DiffNote
+ form.find("#note_position").val()
+ ];
+ return new Autosave(textarea, key);
};
/*
@@ -510,7 +538,7 @@ require('./task_list');
}
}
- this.renderDiscussionNote(note);
+ this.renderNote(note, $form);
// cleanup after successfully creating a diff/discussion note
this.removeDiscussionNoteForm($form);
};
@@ -656,7 +684,7 @@ require('./task_list');
return function(i, el) {
var note, notes;
note = $(el);
- notes = note.closest(".notes");
+ notes = note.closest(".discussion-notes");
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (gl.diffNoteApps[noteElId]) {
@@ -673,14 +701,13 @@ require('./task_list');
// "Discussions" tab
notes.closest(".timeline-entry").remove();
- if (!_this.isParallelView() || notesTr.find('.note').length === 0) {
- // "Changes" tab / commit view
- notesTr.remove();
+ // The notes tr can contain multiple lists of notes, like on the parallel diff
+ if (notesTr.find('.discussion-notes').length > 1) {
+ notes.remove();
} else {
- notes.closest('.content').empty();
+ notesTr.remove();
}
}
- return note.remove();
};
})(this));
// Decrement the "Discussions" counter only once
@@ -711,7 +738,7 @@ require('./task_list');
Notes.prototype.replyToDiscussionNote = function(e) {
var form, replyLink;
- form = this.formClone.clone();
+ form = this.cleanForm(this.formClone.clone());
replyLink = $(e.target).closest(".js-discussion-reply-button");
// insert the form after the button
replyLink
@@ -727,29 +754,44 @@ require('./task_list');
Sets some hidden fields in the form.
- Note: dataHolder must have the "discussionId", "lineCode", "noteableType"
- and "noteableId" data attributes set.
+ Note: dataHolder must have the "discussionId" and "lineCode" data attributes set.
*/
Notes.prototype.setupDiscussionNoteForm = function(dataHolder, form) {
// setup note target
- form.attr('id', "new-discussion-note-form-" + (dataHolder.data("discussionId")));
+ var discussionID = dataHolder.data("discussionId");
+
+ if (discussionID) {
+ form.attr("data-discussion-id", discussionID);
+ form.find("#in_reply_to_discussion_id").val(discussionID);
+ }
+
form.attr("data-line-code", dataHolder.data("lineCode"));
- form.find("#note_type").val(dataHolder.data("noteType"));
form.find("#line_type").val(dataHolder.data("lineType"));
+
+ form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
+ form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
form.find("#note_commit_id").val(dataHolder.data("commitId"));
+ form.find("#note_type").val(dataHolder.data("noteType"));
+
+ // LegacyDiffNote
form.find("#note_line_code").val(dataHolder.data("lineCode"));
+
+ // DiffNote
form.find("#note_position").val(dataHolder.attr("data-position"));
- form.find("#note_noteable_type").val(dataHolder.data("noteableType"));
- form.find("#note_noteable_id").val(dataHolder.data("noteableId"));
+
form.find('.js-note-discard').show().removeClass('js-note-discard').addClass('js-close-discussion-note-form').text(form.find('.js-close-discussion-note-form').data('cancel-text'));
form.find('.js-note-target-close').remove();
+ form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
+ form
+ .removeClass('js-main-target-form')
+ .addClass("discussion-form js-discussion-note-form");
+
if (typeof gl.diffNotesCompileComponents !== 'undefined') {
var $commentBtn = form.find('comment-and-resolve-btn');
- $commentBtn
- .attr(':discussion-id', "'" + dataHolder.data('discussionId') + "'");
+ $commentBtn.attr(':discussion-id', `'${discussionID}'`);
gl.diffNotesCompileComponents();
}
@@ -757,10 +799,7 @@ require('./task_list');
form.find(".js-note-text").focus();
form
.find('.js-comment-resolve-button')
- .attr('data-discussion-id', dataHolder.data('discussionId'));
- form
- .removeClass('js-main-target-form')
- .addClass("discussion-form js-discussion-note-form");
+ .attr('data-discussion-id', discussionID);
};
/*
@@ -823,7 +862,7 @@ require('./task_list');
}
if (addForm) {
- newForm = this.formClone.clone();
+ newForm = this.cleanForm(this.formClone.clone());
newForm.appendTo(notesContent);
// show the form
return this.setupDiscussionNoteForm($link, newForm);
@@ -900,9 +939,10 @@ require('./task_list');
reopenbtn = form.find('.js-note-target-reopen');
closebtn = form.find('.js-note-target-close');
discardbtn = form.find('.js-note-discard');
+
if (textarea.val().trim().length > 0) {
- reopentext = reopenbtn.data('alternative-text');
- closetext = closebtn.data('alternative-text');
+ reopentext = reopenbtn.attr('data-alternative-text');
+ closetext = closebtn.attr('data-alternative-text');
if (reopenbtn.text() !== reopentext) {
reopenbtn.text(reopentext);
}
@@ -1009,6 +1049,20 @@ require('./task_list');
});
};
+ Notes.prototype.cleanForm = function($form) {
+ // Remove JS classes that are not needed here
+ $form
+ .find('.js-comment-type-dropdown')
+ .removeClass('btn-group');
+
+ // Remove dropdown
+ $form
+ .find('.dropdown-menu')
+ .remove();
+
+ return $form;
+ };
+
return Notes;
})();
}).call(window);
diff --git a/app/assets/javascripts/protected_tags/index.js b/app/assets/javascripts/protected_tags/index.js
new file mode 100644
index 00000000000..61e7ba53862
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/index.js
@@ -0,0 +1,2 @@
+export { default as ProtectedTagCreate } from './protected_tag_create';
+export { default as ProtectedTagEditList } from './protected_tag_edit_list';
diff --git a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
new file mode 100644
index 00000000000..fff83f3af3b
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -0,0 +1,26 @@
+export default class ProtectedTagAccessDropdown {
+ constructor(options) {
+ this.options = options;
+ this.initDropdown();
+ }
+
+ initDropdown() {
+ const { onSelect } = this.options;
+ this.options.$dropdown.glDropdown({
+ data: this.options.data,
+ selectable: true,
+ inputId: this.options.$dropdown.data('input-id'),
+ fieldName: this.options.$dropdown.data('field-name'),
+ toggleLabel(item, $el) {
+ if ($el.is('.is-active')) {
+ return item.text;
+ }
+ return 'Select';
+ },
+ clicked(item, $el, e) {
+ e.preventDefault();
+ onSelect();
+ },
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
new file mode 100644
index 00000000000..91bd140bd12
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -0,0 +1,41 @@
+import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import ProtectedTagDropdown from './protected_tag_dropdown';
+
+export default class ProtectedTagCreate {
+ constructor() {
+ this.$form = $('.js-new-protected-tag');
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ const $allowedToCreateDropdown = this.$form.find('.js-allowed-to-create');
+
+ // Cache callback
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ // Allowed to Create dropdown
+ this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ $dropdown: $allowedToCreateDropdown,
+ data: gon.create_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+
+ // Select default
+ $allowedToCreateDropdown.data('glDropdown').selectRowAtIndex(0);
+
+ // Protected tag dropdown
+ this.protectedTagDropdown = new ProtectedTagDropdown({
+ $dropdown: this.$form.find('.js-protected-tag-select'),
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ // This will run after clicked callback
+ onSelect() {
+ // Enable submit button
+ const $tagInput = this.$form.find('input[name="protected_tag[name]"]');
+ const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes');
+
+ this.$form.find('input[type="submit"]').attr('disabled', !($tagInput.val() && $allowedToCreateInput.length));
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_dropdown.js b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
new file mode 100644
index 00000000000..5ff4e443262
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_dropdown.js
@@ -0,0 +1,86 @@
+export default class ProtectedTagDropdown {
+ /**
+ * @param {Object} options containing
+ * `$dropdown` target element
+ * `onSelect` event callback
+ * $dropdown must be an element created using `dropdown_tag()` rails helper
+ */
+ constructor(options) {
+ this.onSelect = options.onSelect;
+ this.$dropdown = options.$dropdown;
+ this.$dropdownContainer = this.$dropdown.parent();
+ this.$dropdownFooter = this.$dropdownContainer.find('.dropdown-footer');
+ this.$protectedTag = this.$dropdownContainer.find('.create-new-protected-tag');
+
+ this.buildDropdown();
+ this.bindEvents();
+
+ // Hide footer
+ this.toggleFooter(true);
+ }
+
+ buildDropdown() {
+ this.$dropdown.glDropdown({
+ data: this.getProtectedTags.bind(this),
+ filterable: true,
+ remote: false,
+ search: {
+ fields: ['title'],
+ },
+ selectable: true,
+ toggleLabel(selected) {
+ return (selected && 'id' in selected) ? selected.title : 'Protected Tag';
+ },
+ fieldName: 'protected_tag[name]',
+ text(protectedTag) {
+ return _.escape(protectedTag.title);
+ },
+ id(protectedTag) {
+ return _.escape(protectedTag.id);
+ },
+ onFilter: this.toggleCreateNewButton.bind(this),
+ clicked: (item, $el, e) => {
+ e.preventDefault();
+ this.onSelect();
+ },
+ });
+ }
+
+ bindEvents() {
+ this.$protectedTag.on('click', this.onClickCreateWildcard.bind(this));
+ }
+
+ onClickCreateWildcard(e) {
+ this.$dropdown.data('glDropdown').remote.execute();
+ this.$dropdown.data('glDropdown').selectRowAtIndex();
+ e.preventDefault();
+ }
+
+ getProtectedTags(term, callback) {
+ if (this.selectedTag) {
+ callback(gon.open_tags.concat(this.selectedTag));
+ } else {
+ callback(gon.open_tags);
+ }
+ }
+
+ toggleCreateNewButton(tagName) {
+ if (tagName) {
+ this.selectedTag = {
+ title: tagName,
+ id: tagName,
+ text: tagName,
+ };
+
+ this.$dropdownContainer
+ .find('.create-new-protected-tag code')
+ .text(tagName);
+ }
+
+ this.toggleFooter(!tagName);
+ }
+
+ toggleFooter(toggleState) {
+ this.$dropdownFooter.toggleClass('hidden', toggleState);
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
new file mode 100644
index 00000000000..09a387c0f9e
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -0,0 +1,52 @@
+/* eslint-disable no-new */
+/* global Flash */
+
+import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+
+export default class ProtectedTagEdit {
+ constructor(options) {
+ this.$wrap = options.$wrap;
+ this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create');
+ this.onSelectCallback = this.onSelect.bind(this);
+
+ this.buildDropdowns();
+ }
+
+ buildDropdowns() {
+ // Allowed to create dropdown
+ this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({
+ $dropdown: this.$allowedToCreateDropdownButton,
+ data: gon.create_access_levels,
+ onSelect: this.onSelectCallback,
+ });
+ }
+
+ onSelect() {
+ const $allowedToCreateInput = this.$wrap.find(`input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`);
+
+ // Do not update if one dropdown has not selected any option
+ if (!$allowedToCreateInput.length) return;
+
+ this.$allowedToCreateDropdownButton.disable();
+
+ $.ajax({
+ type: 'POST',
+ url: this.$wrap.data('url'),
+ dataType: 'json',
+ data: {
+ _method: 'PATCH',
+ protected_tag: {
+ create_access_levels_attributes: [{
+ id: this.$allowedToCreateDropdownButton.data('access-level-id'),
+ access_level: $allowedToCreateInput.val(),
+ }],
+ },
+ },
+ error() {
+ new Flash('Failed to update tag!', null, $('.js-protected-tags-list'));
+ },
+ }).always(() => {
+ this.$allowedToCreateDropdownButton.enable();
+ });
+ }
+}
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
new file mode 100644
index 00000000000..bd9fc872266
--- /dev/null
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js
@@ -0,0 +1,18 @@
+/* eslint-disable no-new */
+
+import ProtectedTagEdit from './protected_tag_edit';
+
+export default class ProtectedTagEditList {
+ constructor() {
+ this.$wrap = $('.protected-tags-list');
+ this.initEditForm();
+ }
+
+ initEditForm() {
+ this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => {
+ new ProtectedTagEdit({
+ $wrap: $(el),
+ });
+ });
+ }
+}
diff --git a/app/assets/javascripts/render_gfm.js b/app/assets/javascripts/render_gfm.js
index ea91aaa10a6..2c3a9cacd38 100644
--- a/app/assets/javascripts/render_gfm.js
+++ b/app/assets/javascripts/render_gfm.js
@@ -8,6 +8,7 @@
$.fn.renderGFM = function() {
this.find('.js-syntax-highlight').syntaxHighlight();
this.find('.js-render-math').renderMath();
+ return this;
};
$(document).on('ready load', function() {
diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js
index fd5097696ad..5b6bb2bf3f5 100644
--- a/app/assets/javascripts/shortcuts.js
+++ b/app/assets/javascripts/shortcuts.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, quotes, prefer-arrow-callback, consistent-return, object-shorthand, no-unused-vars, one-var, one-var-declaration-per-line, no-else-return, comma-dangle, max-len */
/* global Mousetrap */
/* global findFileURL */
+import findAndFollowLink from './shortcuts_dashboard_navigation';
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
@@ -14,11 +15,33 @@
}
Mousetrap.bind('?', this.onToggleHelp);
Mousetrap.bind('s', Shortcuts.focusSearch);
- Mousetrap.bind('f', (function(_this) {
- return function(e) {
- return _this.focusFilter(e);
- };
- })(this));
+ Mousetrap.bind('f', (e => this.focusFilter(e)));
+
+ const $globalDropdownMenu = $('.global-dropdown-menu');
+ const $globalDropdownToggle = $('.global-dropdown-toggle');
+
+ $('.global-dropdown').on('hide.bs.dropdown', () => {
+ $globalDropdownMenu.removeClass('shortcuts');
+ });
+
+ Mousetrap.bind('n', () => {
+ $globalDropdownMenu.toggleClass('shortcuts');
+ $globalDropdownToggle.trigger('click');
+
+ if (!$globalDropdownMenu.is(':visible')) {
+ $globalDropdownToggle.blur();
+ }
+ });
+
+ Mousetrap.bind('shift+t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('shift+a', () => findAndFollowLink('.dashboard-shortcuts-activity'));
+ Mousetrap.bind('shift+i', () => findAndFollowLink('.dashboard-shortcuts-issues'));
+ Mousetrap.bind('shift+m', () => findAndFollowLink('.dashboard-shortcuts-merge_requests'));
+ Mousetrap.bind('shift+p', () => findAndFollowLink('.dashboard-shortcuts-projects'));
+ Mousetrap.bind('shift+g', () => findAndFollowLink('.dashboard-shortcuts-groups'));
+ Mousetrap.bind('shift+l', () => findAndFollowLink('.dashboard-shortcuts-milestones'));
+ Mousetrap.bind('shift+s', () => findAndFollowLink('.dashboard-shortcuts-snippets'));
+
Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], this.toggleMarkdownPreview);
if (typeof findFileURL !== "undefined" && findFileURL !== null) {
Mousetrap.bind('t', function() {
diff --git a/app/assets/javascripts/shortcuts_dashboard_navigation.js b/app/assets/javascripts/shortcuts_dashboard_navigation.js
index 4f1a19924a4..25f39e4fdb6 100644
--- a/app/assets/javascripts/shortcuts_dashboard_navigation.js
+++ b/app/assets/javascripts/shortcuts_dashboard_navigation.js
@@ -1,43 +1,12 @@
-/* eslint-disable func-names, space-before-function-paren, max-len, one-var, no-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
-/* global Mousetrap */
-/* global Shortcuts */
-
-require('./shortcuts');
-
-(function() {
- var extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
- hasProp = {}.hasOwnProperty;
-
- this.ShortcutsDashboardNavigation = (function(superClass) {
- extend(ShortcutsDashboardNavigation, superClass);
-
- function ShortcutsDashboardNavigation() {
- ShortcutsDashboardNavigation.__super__.constructor.call(this);
- Mousetrap.bind('g a', function() {
- return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-activity');
- });
- Mousetrap.bind('g i', function() {
- return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-issues');
- });
- Mousetrap.bind('g m', function() {
- return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-merge_requests');
- });
- Mousetrap.bind('g t', function() {
- return ShortcutsDashboardNavigation.findAndFollowLink('.shortcuts-todos');
- });
- Mousetrap.bind('g p', function() {
- return ShortcutsDashboardNavigation.findAndFollowLink('.dashboard-shortcuts-projects');
- });
- }
-
- ShortcutsDashboardNavigation.findAndFollowLink = function(selector) {
- var link;
- link = $(selector).attr('href');
- if (link) {
- return window.location = link;
- }
- };
-
- return ShortcutsDashboardNavigation;
- })(Shortcuts);
-}).call(window);
+/**
+ * Helper function that finds the href of the fiven selector and updates the location.
+ *
+ * @param {String} selector
+ */
+export default (selector) => {
+ const link = document.querySelector(selector).getAttribute('href');
+
+ if (link) {
+ window.location = link;
+ }
+};
diff --git a/app/assets/javascripts/shortcuts_navigation.js b/app/assets/javascripts/shortcuts_navigation.js
index 3f5d6724417..c74ab0afd0c 100644
--- a/app/assets/javascripts/shortcuts_navigation.js
+++ b/app/assets/javascripts/shortcuts_navigation.js
@@ -1,6 +1,7 @@
/* eslint-disable func-names, space-before-function-paren, max-len, no-var, one-var, no-restricted-syntax, vars-on-top, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, wrap-iife, prefer-arrow-callback, consistent-return, no-return-assign */
/* global Mousetrap */
/* global Shortcuts */
+import findAndFollowLink from './shortcuts_dashboard_navigation';
require('./shortcuts');
@@ -13,59 +14,23 @@ require('./shortcuts');
function ShortcutsNavigation() {
ShortcutsNavigation.__super__.constructor.call(this);
- Mousetrap.bind('g p', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-project');
- });
- Mousetrap.bind('g e', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-project-activity');
- });
- Mousetrap.bind('g f', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-tree');
- });
- Mousetrap.bind('g c', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-commits');
- });
- Mousetrap.bind('g b', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-builds');
- });
- Mousetrap.bind('g n', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-network');
- });
- Mousetrap.bind('g g', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-repository-charts');
- });
- Mousetrap.bind('g i', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-issues');
- });
- Mousetrap.bind('g l', function() {
- ShortcutsNavigation.findAndFollowLink('.shortcuts-issue-boards');
- });
- Mousetrap.bind('g m', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-merge_requests');
- });
- Mousetrap.bind('g t', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-todos');
- });
- Mousetrap.bind('g w', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-wiki');
- });
- Mousetrap.bind('g s', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-snippets');
- });
- Mousetrap.bind('i', function() {
- return ShortcutsNavigation.findAndFollowLink('.shortcuts-new-issue');
- });
+ Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
+ Mousetrap.bind('g e', () => findAndFollowLink('.shortcuts-project-activity'));
+ Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
+ Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
+ Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
+ Mousetrap.bind('g n', () => findAndFollowLink('.shortcuts-network'));
+ Mousetrap.bind('g d', () => findAndFollowLink('.shortcuts-repository-charts'));
+ Mousetrap.bind('g i', () => findAndFollowLink('.shortcuts-issues'));
+ Mousetrap.bind('g b', () => findAndFollowLink('.shortcuts-issue-boards'));
+ Mousetrap.bind('g m', () => findAndFollowLink('.shortcuts-merge_requests'));
+ Mousetrap.bind('g t', () => findAndFollowLink('.shortcuts-todos'));
+ Mousetrap.bind('g w', () => findAndFollowLink('.shortcuts-wiki'));
+ Mousetrap.bind('g s', () => findAndFollowLink('.shortcuts-snippets'));
+ Mousetrap.bind('i', () => findAndFollowLink('.shortcuts-new-issue'));
this.enabledHelp.push('.hidden-shortcut.project');
}
- ShortcutsNavigation.findAndFollowLink = function(selector) {
- var link;
- link = $(selector).attr('href');
- if (link) {
- return window.location = link;
- }
- };
-
return ShortcutsNavigation;
})(Shortcuts);
}).call(window);
diff --git a/app/assets/javascripts/subscription.js b/app/assets/javascripts/subscription.js
index 9c307915ec4..5f9a3e00c22 100644
--- a/app/assets/javascripts/subscription.js
+++ b/app/assets/javascripts/subscription.js
@@ -1,5 +1,3 @@
-import Vue from 'vue';
-
(() => {
class Subscription {
constructor(containerElm) {
@@ -29,8 +27,7 @@ import Vue from 'vue';
// hack to allow this to work with the issue boards Vue object
if (document.querySelector('html').classList.contains('issue-boards-page')) {
- Vue.set(
- gl.issueBoards.BoardsStore.detail.issue,
+ gl.issueBoards.boardStoreIssueSet(
'subscribed',
!gl.issueBoards.BoardsStore.detail.issue.subscribed,
);
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index 48e20cf501f..3325a7d429c 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -2,8 +2,6 @@
/* global Issuable */
/* global ListUser */
-import Vue from 'vue';
-
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
slice = [].slice;
@@ -74,7 +72,7 @@ import Vue from 'vue';
e.preventDefault();
if ($dropdown.hasClass('js-issue-board-sidebar')) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
+ gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
id: _this.currentUser.id,
username: _this.currentUser.username,
name: _this.currentUser.name,
@@ -225,14 +223,14 @@ import Vue from 'vue';
return $dropdown.closest('form').submit();
} else if ($dropdown.hasClass('js-issue-board-sidebar')) {
if (user.id) {
- Vue.set(gl.issueBoards.BoardsStore.detail.issue, 'assignee', new ListUser({
+ gl.issueBoards.boardStoreIssueSet('assignee', new ListUser({
id: user.id,
username: user.username,
name: user.name,
avatar_url: user.avatar_url
}));
} else {
- Vue.delete(gl.issueBoards.BoardsStore.detail.issue, 'assignee');
+ gl.issueBoards.boardStoreIssueDelete('assignee');
}
updateIssueBoardsIssue();
diff --git a/app/assets/javascripts/vue_pipelines_index/components/async_button.js b/app/assets/javascripts/vue_pipelines_index/components/async_button.vue
index 58b8db4d519..11da6e908b7 100644
--- a/app/assets/javascripts/vue_pipelines_index/components/async_button.js
+++ b/app/assets/javascripts/vue_pipelines_index/components/async_button.vue
@@ -1,3 +1,4 @@
+<script>
/* eslint-disable no-new, no-alert */
/* global Flash */
import '~/flash';
@@ -65,29 +66,31 @@ export default {
this.isLoading = true;
this.service.postAction(this.endpoint)
- .then(() => {
- this.isLoading = false;
- eventHub.$emit('refreshPipelines');
- })
- .catch(() => {
- this.isLoading = false;
- new Flash('An error occured while making the request.');
- });
+ .then(() => {
+ this.isLoading = false;
+ eventHub.$emit('refreshPipelines');
+ })
+ .catch(() => {
+ this.isLoading = false;
+ new Flash('An error occured while making the request.');
+ });
},
},
-
- template: `
- <button
- type="button"
- @click="onClick"
- :class="buttonClass"
- :title="title"
- :aria-label="title"
- data-container="body"
- data-placement="top"
- :disabled="isLoading">
- <i :class="iconClass" aria-hidden="true"/>
- <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading" />
- </button>
- `,
};
+</script>
+
+<template>
+ <button
+ type="button"
+ @click="onClick"
+ :class="buttonClass"
+ :title="title"
+ :aria-label="title"
+ data-container="body"
+ data-placement="top"
+ :disabled="isLoading"
+ >
+ <i :class="iconClass" aria-hidden="true"></i>
+ <i class="fa fa-spinner fa-spin" aria-hidden="true" v-if="isLoading"></i>
+ </button>
+</template>
diff --git a/app/assets/javascripts/vue_pipelines_index/components/empty_state.js b/app/assets/javascripts/vue_pipelines_index/components/empty_state.js
deleted file mode 100644
index 56b4858f4b4..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/components/empty_state.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
-
-export default {
- props: {
- helpPagePath: {
- type: String,
- required: true,
- },
- },
-
- template: `
- <div class="row empty-state">
- <div class="col-xs-12">
- <div class="svg-content">
- ${pipelinesEmptyStateSVG}
- </div>
- </div>
-
- <div class="col-xs-12 text-center">
- <div class="text-content">
- <h4>Build with confidence</h4>
- <p>
- Continous Integration can help catch bugs by running your tests automatically,
- while Continuous Deployment can help you deliver code to your product environment.
- </p>
- <a :href="helpPagePath" class="btn btn-info">
- Get started with Pipelines
- </a>
- </div>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/empty_state.vue b/app/assets/javascripts/vue_pipelines_index/components/empty_state.vue
new file mode 100644
index 00000000000..ba158bc4a1e
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/empty_state.vue
@@ -0,0 +1,34 @@
+<script>
+import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg';
+
+export default {
+ props: {
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+ data: () => ({ pipelinesEmptyStateSVG }),
+};
+</script>
+
+<template>
+ <div class="row empty-state">
+ <div class="col-xs-12">
+ <div class="svg-content" v-html="pipelinesEmptyStateSVG" />
+ </div>
+
+ <div class="col-xs-12 text-center">
+ <div class="text-content">
+ <h4>Build with confidence</h4>
+ <p>
+ Continous Integration can help catch bugs by running your tests automatically,
+ while Continuous Deployment can help you deliver code to your product environment.
+ </p>
+ <a :href="helpPagePath" class="btn btn-info">
+ Get started with Pipelines
+ </a>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_pipelines_index/components/error_state.js b/app/assets/javascripts/vue_pipelines_index/components/error_state.js
deleted file mode 100644
index e5d228bddf8..00000000000
--- a/app/assets/javascripts/vue_pipelines_index/components/error_state.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
-
-export default {
- template: `
- <div class="row empty-state js-pipelines-error-state">
- <div class="col-xs-12">
- <div class="svg-content">
- ${pipelinesErrorStateSVG}
- </div>
- </div>
-
- <div class="col-xs-12 text-center">
- <div class="text-content">
- <h4>The API failed to fetch the pipelines.</h4>
- </div>
- </div>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/vue_pipelines_index/components/error_state.vue b/app/assets/javascripts/vue_pipelines_index/components/error_state.vue
new file mode 100644
index 00000000000..90cee68163e
--- /dev/null
+++ b/app/assets/javascripts/vue_pipelines_index/components/error_state.vue
@@ -0,0 +1,21 @@
+<script>
+import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg';
+
+export default {
+ data: () => ({ pipelinesErrorStateSVG }),
+};
+</script>
+
+<template>
+ <div class="row empty-state js-pipelines-error-state">
+ <div class="col-xs-12">
+ <div class="svg-content" v-html="pipelinesErrorStateSVG" />
+ </div>
+
+ <div class="col-xs-12 text-center">
+ <div class="text-content">
+ <h4>The API failed to fetch the pipelines.</h4>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js b/app/assets/javascripts/vue_pipelines_index/pipelines.js
index 9bdc232b7da..6eea4812f33 100644
--- a/app/assets/javascripts/vue_pipelines_index/pipelines.js
+++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js
@@ -1,12 +1,14 @@
import Vue from 'vue';
+import Visibility from 'visibilityjs';
import PipelinesService from './services/pipelines_service';
import eventHub from './event_hub';
import PipelinesTableComponent from '../vue_shared/components/pipelines_table';
import TablePaginationComponent from '../vue_shared/components/table_pagination';
-import EmptyState from './components/empty_state';
-import ErrorState from './components/error_state';
+import EmptyState from './components/empty_state.vue';
+import ErrorState from './components/error_state.vue';
import NavigationTabs from './components/navigation_tabs';
import NavigationControls from './components/nav_controls';
+import Poll from '../lib/utils/poll';
export default {
props: {
@@ -47,6 +49,7 @@ export default {
pagenum: 1,
isLoading: false,
hasError: false,
+ isMakingRequest: false,
};
},
@@ -120,18 +123,49 @@ export default {
tagsPath: this.tagsPath,
};
},
+
+ pageParameter() {
+ return gl.utils.getParameterByName('page') || this.pagenum;
+ },
+
+ scopeParameter() {
+ return gl.utils.getParameterByName('scope') || this.apiScope;
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
- this.fetchPipelines();
+ const poll = new Poll({
+ resource: this.service,
+ method: 'getPipelines',
+ data: { page: this.pageParameter, scope: this.scopeParameter },
+ successCallback: this.successCallback,
+ errorCallback: this.errorCallback,
+ notificationCallback: this.setIsMakingRequest,
+ });
+
+ if (!Visibility.hidden()) {
+ this.isLoading = true;
+ poll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ poll.restart();
+ } else {
+ poll.stop();
+ }
+ });
eventHub.$on('refreshPipelines', this.fetchPipelines);
},
beforeUpdate() {
- if (this.state.pipelines.length && this.$children) {
+ if (this.state.pipelines.length &&
+ this.$children &&
+ !this.isMakingRequest &&
+ !this.isLoading) {
this.store.startTimeAgoLoops.call(this, Vue);
}
},
@@ -154,27 +188,35 @@ export default {
},
fetchPipelines() {
- const pageNumber = gl.utils.getParameterByName('page') || this.pagenum;
- const scope = gl.utils.getParameterByName('scope') || this.apiScope;
+ if (!this.isMakingRequest) {
+ this.isLoading = true;
- this.isLoading = true;
- return this.service.getPipelines(scope, pageNumber)
- .then(resp => ({
- headers: resp.headers,
- body: resp.json(),
- }))
- .then((response) => {
- this.store.storeCount(response.body.count);
- this.store.storePipelines(response.body.pipelines);
- this.store.storePagination(response.headers);
- })
- .then(() => {
- this.isLoading = false;
- })
- .catch(() => {
- this.hasError = true;
- this.isLoading = false;
- });
+ this.service.getPipelines({ scope: this.scopeParameter, page: this.pageParameter })
+ .then(response => this.successCallback(response))
+ .catch(() => this.errorCallback());
+ }
+ },
+
+ successCallback(resp) {
+ const response = {
+ headers: resp.headers,
+ body: resp.json(),
+ };
+
+ this.store.storeCount(response.body.count);
+ this.store.storePipelines(response.body.pipelines);
+ this.store.storePagination(response.headers);
+
+ this.isLoading = false;
+ },
+
+ errorCallback() {
+ this.hasError = true;
+ this.isLoading = false;
+ },
+
+ setIsMakingRequest(isMakingRequest) {
+ this.isMakingRequest = isMakingRequest;
},
},
diff --git a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js
index 708f5068dd3..255cd513490 100644
--- a/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js
+++ b/app/assets/javascripts/vue_pipelines_index/services/pipelines_service.js
@@ -26,7 +26,8 @@ export default class PipelinesService {
this.pipelines = Vue.resource(endpoint);
}
- getPipelines(scope, page) {
+ getPipelines(data = {}) {
+ const { scope, page } = data;
return this.pipelines.get({ scope, page });
}
diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
index f5b3cb9214e..8ebe12cb1c5 100644
--- a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
+++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js
@@ -1,6 +1,6 @@
/* eslint-disable no-param-reassign */
-import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button';
+import AsyncButtonComponent from '../../vue_pipelines_index/components/async_button.vue';
import PipelinesActionsComponent from '../../vue_pipelines_index/components/pipelines_actions';
import PipelinesArtifactsComponent from '../../vue_pipelines_index/components/pipelines_artifacts';
import PipelinesStatusComponent from '../../vue_pipelines_index/components/status';
diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss
index 9a0f7a14e57..759401a7806 100644
--- a/app/assets/stylesheets/framework/calendar.scss
+++ b/app/assets/stylesheets/framework/calendar.scss
@@ -5,7 +5,7 @@
direction: rtl;
@media (min-width: $screen-sm-min) and (max-width: $screen-md-max) {
- overflow-x: scroll;
+ overflow-x: auto;
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 2ede47e9de6..7767826b033 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -177,10 +177,6 @@
border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color;
- .filtered-search-input-container & {
- max-width: 280px;
- }
-
&.is-loading {
.dropdown-content {
display: none;
@@ -191,6 +187,15 @@
}
}
+ .shortcut-mappings {
+ display: none;
+ }
+
+ &.shortcuts .shortcut-mappings {
+ display: inline-block;
+ margin-right: 5px;
+ }
+
ul {
margin: 0;
padding: 0;
@@ -467,6 +472,11 @@
overflow-y: auto;
}
+.dropdown-info-note {
+ color: $gl-text-color-secondary;
+ text-align: center;
+}
+
.dropdown-footer {
padding-top: 10px;
margin-top: 10px;
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index ddea1cf540b..a5a8522739e 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -281,3 +281,16 @@ span.idiff {
display: none;
}
}
+
+.file-fork-suggestion {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ background-color: $gray-light;
+ border-bottom: 1px solid $border-color;
+ padding: 5px $gl-padding;
+}
+
+.file-fork-suggestion-note {
+ margin-right: 1.5em;
+}
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 51805c5d734..11d44df4867 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -22,7 +22,6 @@
}
@media (min-width: $screen-sm-min) {
- .issues-filters,
.issues_bulk_update {
.dropdown-menu-toggle {
width: 132px;
@@ -56,7 +55,7 @@
}
}
-.filtered-search-container {
+.filtered-search-wrapper {
display: -webkit-flex;
display: flex;
@@ -83,7 +82,7 @@
.input-token:last-child {
flex: 1;
-webkit-flex: 1;
- max-width: initial;
+ max-width: inherit;
}
}
@@ -151,11 +150,13 @@
width: 100%;
}
-.filtered-search-input-container {
+.filtered-search-box {
+ position: relative;
+ flex: 1;
display: -webkit-flex;
display: flex;
- position: relative;
width: 100%;
+ min-width: 0;
border: 1px solid $border-color;
background-color: $white-light;
@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 10px;
-
- .dropdown-menu {
- width: auto;
- left: 0;
- right: 0;
- max-width: none;
- min-width: 100%;
- }
}
&:hover {
@@ -229,6 +222,116 @@
}
}
+.filtered-search-box-input-container {
+ flex: 1;
+ position: relative;
+ // Fix PhantomJS not supporting `flex: 1;` properly.
+ // This is important because it can change the expected `e.target` when clicking things in tests.
+ // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
+ // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
+ // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
+ width: 100%;
+ min-width: 0;
+}
+
+.filtered-search-input-dropdown-menu {
+ max-width: 280px;
+
+ @media (max-width: $screen-xs-min) {
+ width: auto;
+ left: 0;
+ right: 0;
+ max-width: none;
+ min-width: 100%;
+ }
+}
+
+.filtered-search-history-dropdown-wrapper {
+ position: static;
+ display: flex;
+ flex-direction: column;
+}
+
+.filtered-search-history-dropdown-toggle-button {
+ flex: 1;
+ width: auto;
+ padding-right: 10px;
+
+ border-radius: 0;
+ border-top: 0;
+ border-left: 0;
+ border-bottom: 0;
+ border-right: 1px solid $border-color;
+
+ color: $gl-text-color-secondary;
+ line-height: 1;
+
+ transition: color 0.1s linear;
+
+ &:hover,
+ &:focus {
+ color: $gl-text-color;
+ border-color: $dropdown-input-focus-border;
+ outline: none;
+ }
+
+ .dropdown-toggle-text {
+ display: inline-block;
+ color: inherit;
+
+ .fa {
+ vertical-align: middle;
+ color: inherit;
+ }
+ }
+
+ .fa {
+ position: static;
+ }
+
+}
+
+.filtered-search-history-dropdown {
+ width: 40%;
+
+ @media (max-width: $screen-xs-min) {
+ left: 0;
+ right: 0;
+ max-width: none;
+ }
+}
+
+.filtered-search-history-dropdown-content {
+ max-height: none;
+}
+
+.filtered-search-history-dropdown-item,
+.filtered-search-history-clear-button {
+ @include dropdown-link;
+
+ overflow: hidden;
+ width: 100%;
+ margin: 0.5em 0;
+
+ background-color: transparent;
+ border: 0;
+ text-align: left;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.filtered-search-history-dropdown-token {
+ display: inline;
+
+ &:not(:last-child) {
+ margin-right: 0.3em;
+ }
+
+ & > .value {
+ font-weight: 600;
+ }
+}
+
.filter-dropdown-container {
display: -webkit-flex;
display: flex;
@@ -248,10 +351,8 @@
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
- .issues-details-filters {
- .dropdown-menu-toggle {
- width: 100px;
- }
+ .issue-bulk-update-dropdown-toggle {
+ width: 100px;
}
}
@@ -343,10 +444,8 @@
}
}
-.filter-dropdown-item.dropdown-active {
- .btn {
- @extend %filter-dropdown-item-btn-hover;
- }
+.filter-dropdown-item.droplab-item-active .btn {
+ @extend %filter-dropdown-item-btn-hover;
}
.filter-dropdown-loading {
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index ff185cd8767..cd23deb6d75 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -1,15 +1,18 @@
.timeline {
@include basic-list;
-
margin: 0;
padding: 0;
.timeline-entry {
- padding: $gl-padding $gl-btn-padding 11px;
+ padding: $gl-padding $gl-btn-padding 14px;
border-color: $white-normal;
color: $gl-text-color;
border-bottom: 1px solid $border-white-light;
+ .timeline-entry-inner {
+ position: relative;
+ }
+
&:target {
background: $line-target-blue;
}
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 7c0fc1008d0..0be1c215959 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -197,7 +197,7 @@
.card {
position: relative;
- padding: 10px $gl-padding;
+ padding: 11px 10px 11px $gl-padding;
background: $white-light;
border-radius: $border-radius-default;
box-shadow: 0 1px 2px $issue-boards-card-shadow;
@@ -217,6 +217,8 @@
}
.confidential-icon {
+ position: relative;
+ top: 1px;
margin-right: 5px;
}
}
@@ -224,34 +226,43 @@
.card-title {
margin: 0;
font-size: 1em;
+ line-height: inherit;
a {
- color: inherit;
+ color: $gl-text-color;
word-wrap: break-word;
+ margin-right: 2px;
}
}
-.card-footer {
- margin-top: 5px;
- line-height: 25px;
-
- .label {
- margin-right: 5px;
- font-size: (14px / $issue-boards-font-size) * 1em;
- }
+.card-header {
+ display: flex;
+ min-height: 20px;
.card-assignee {
+ margin-left: auto;
margin-right: 5px;
+ padding-left: 10px;
+ height: 20px;
}
.avatar {
- margin-left: 0;
- margin-right: 0;
+ margin: 0;
+ }
+}
+
+.card-footer {
+ margin: 0 0 5px;
+
+ .label {
+ margin-top: 5px;
+ margin-right: 6px;
}
}
.card-number {
- margin-right: 5px;
+ font-size: 12px;
+ color: $gl-text-color-secondary;
}
.issue-boards-search {
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 969fc75c6eb..144adbcdaef 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -39,7 +39,7 @@
overflow-y: hidden;
font-size: 12px;
- .fa-refresh {
+ .fa-spinner {
font-size: 24px;
margin-left: 20px;
}
@@ -57,6 +57,37 @@
margin-right: 5px;
}
}
+
+ .truncated-info {
+ text-align: center;
+ border-bottom: 1px solid;
+ background-color: $black-transparent;
+ height: 45px;
+
+ &.affix {
+ top: 0;
+ }
+
+ // with sidebar
+ &.affix.sidebar-expanded {
+ right: 312px;
+ left: 22px;
+ }
+
+ // without sidebar
+ &.affix.sidebar-collapsed {
+ right: 20px;
+ left: 20px;
+ }
+
+ &.affix-top {
+ position: absolute;
+ top: 0;
+ margin: 0 auto;
+ right: 5px;
+ left: 5px;
+ }
+ }
}
.scroll-controls {
@@ -186,8 +217,9 @@
white-space: pre;
overflow-x: auto;
font-size: 12px;
+ position: relative;
- .fa-refresh {
+ .fa-spinner {
font-size: 24px;
}
@@ -334,7 +366,7 @@
background-color: $row-hover;
}
- .fa-refresh {
+ .fa-spinner {
font-size: 13px;
margin-left: 3px;
}
diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss
new file mode 100644
index 00000000000..3266714396e
--- /dev/null
+++ b/app/assets/stylesheets/pages/container_registry.scss
@@ -0,0 +1,16 @@
+/**
+ * Container Registry
+ */
+
+.container-image {
+ border-bottom: 1px solid $white-normal;
+}
+
+.container-image-head {
+ padding: 0 16px;
+ line-height: 4em;
+}
+
+.table.tags {
+ margin-bottom: 0;
+}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 08398bb43a2..5b723f7c722 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -4,14 +4,18 @@
*/
.event-item {
font-size: $gl-font-size;
- padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top);
+ padding: $gl-padding-top 0 $gl-padding-top 40px;
border-bottom: 1px solid $white-normal;
color: $list-text-color;
+ position: relative;
&.event-inline {
- .avatar {
- position: relative;
- top: -2px;
+ .system-note-image {
+ top: 20px;
+ }
+
+ .user-avatar {
+ top: 14px;
}
.event-title,
@@ -24,8 +28,31 @@
color: $gl-text-color;
}
- .avatar {
- margin-left: -($gl-avatar-size + $gl-padding-top);
+ .system-note-image {
+ position: absolute;
+ left: 0;
+ top: 14px;
+
+ svg {
+ width: 20px;
+ height: 20px;
+ fill: $gl-text-color-secondary;
+ }
+
+ &.opened-icon,
+ &.created-icon {
+ svg {
+ fill: $green-300;
+ }
+ }
+
+ &.closed-icon svg {
+ fill: $red-300;
+ }
+
+ &.accepted-icon svg {
+ fill: $blue-300;
+ }
}
.event-title {
@@ -108,8 +135,7 @@
li {
&.commit {
background: transparent;
- padding: 3px;
- padding-left: 0;
+ padding: 0;
border: none;
.commit-row-title {
@@ -163,7 +189,7 @@
max-width: 100%;
}
- .avatar {
+ .system-note-image {
display: none;
}
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index e84a05e3e9e..0bca3e93e4c 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -196,6 +196,7 @@
transition: width .3s;
background: $gray-light;
padding: 10px 20px;
+ z-index: 2;
&.right-sidebar-expanded {
width: $gutter_width;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 2f946ab2f59..6a419384a34 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -523,11 +523,12 @@
}
.content-block {
- border-top: 1px solid $border-color;
padding: $gl-padding-top $gl-padding;
}
.comments-disabled-notif {
+ line-height: 28px;
+
.btn {
margin-left: 5px;
}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 927bf9805ce..b637994adf8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -310,3 +310,94 @@
margin-bottom: 10px;
}
}
+
+.comment-type-dropdown {
+ .comment-btn {
+ width: auto;
+ }
+
+ .dropdown-toggle {
+ float: right;
+
+ .toggle-icon {
+ color: $white-light;
+ padding-right: 2px;
+ margin-top: 2px;
+ pointer-events: none;
+ }
+ }
+
+ .dropdown-menu {
+ top: initial;
+ bottom: 40px;
+ width: 298px;
+ }
+
+ .description {
+ display: inline-block;
+ white-space: normal;
+ margin-left: 8px;
+ padding-right: 33px;
+ }
+
+ li {
+ padding-top: 6px;
+
+ & > a {
+ margin: 0;
+ padding: 0;
+ color: inherit;
+ border-radius: 0;
+ text-overflow: inherit;
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ color: inherit;
+ }
+ }
+
+ &:hover,
+ &:focus {
+ background-color: $dropdown-hover-color;
+ color: $white-light;
+ }
+
+ &.droplab-item-selected i {
+ visibility: visible;
+ }
+
+ i {
+ visibility: hidden;
+ }
+ }
+
+ i {
+ display: inline-block;
+ vertical-align: top;
+ padding-top: 2px;
+ }
+
+ .divider {
+ margin: 0 8px;
+ padding: 0;
+ border-top: $gray-darkest;
+ }
+
+ @media (max-width: $screen-xs-max) {
+ display: flex;
+ width: 100%;
+
+ .comment-btn {
+ flex-grow: 1;
+ flex-shrink: 0;
+ width: auto;
+ }
+
+ .dropdown-toggle {
+ flex-grow: 0;
+ flex-shrink: 1;
+ width: auto;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 603ef461ffe..ad0f2f6efbb 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -16,6 +16,15 @@ ul.notes {
.timeline-icon {
float: left;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: $gray-darkest;
+ position: absolute;
+ left: 0;
+ top: 16px;
+ }
}
.timeline-content {
@@ -33,11 +42,112 @@ ul.notes {
white-space: nowrap;
}
+ .discussion-body {
+ padding-top: 15px;
+ }
+
+ .discussion {
+ overflow: hidden;
+ display: block;
+ position: relative;
+ }
+
+ .note {
+ display: block;
+ position: relative;
+ border-bottom: 1px solid $white-normal;
+
+ &.note-discussion {
+ &.timeline-entry {
+ padding: 14px 10px;
+ }
+
+ .system-note {
+ padding: 0;
+ }
+ }
+
+ &.is-editting {
+ .note-header,
+ .note-text,
+ .edited-text {
+ display: none;
+ }
+
+ .note-edit-form {
+ display: block;
+
+ &.current-note-edit-form + .note-awards {
+ display: none;
+ }
+ }
+ }
+
+ .note-body {
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ .note-text {
+ word-wrap: break-word;
+ @include md-typography;
+ // Reset ul style types since we're nested inside a ul already
+ @include bulleted-list;
+ ul.task-list {
+ ul:not(.task-list) {
+ padding-left: 1.3em;
+ }
+ }
+ }
+ }
+
+ .note-awards {
+ .js-awards-block {
+ padding: 2px;
+ margin-top: 10px;
+ }
+ }
+
+ .note-header {
+ padding-bottom: 3px;
+ padding-right: 20px;
+
+ @media (min-width: $screen-sm-min) {
+ padding-right: 0;
+ }
+
+ @media (max-width: $screen-xs-min) {
+ .inline {
+ display: block;
+ }
+ }
+ }
+
+ .note-emoji-button {
+ .fa-spinner {
+ display: none;
+ }
+
+ &.is-loading {
+ .fa-smile-o {
+ display: none;
+ }
+
+ .fa-spinner {
+ display: inline-block;
+ }
+ }
+ }
+ }
+
.system-note {
font-size: 14px;
padding: 0;
clear: both;
+ @media (min-width: $screen-sm-min) {
+ margin-left: 65px;
+ }
+
&.timeline-entry::after {
clear: none;
}
@@ -66,6 +176,14 @@ ul.notes {
.timeline-content {
padding: 14px 10px;
+
+ @media (min-width: $screen-sm-min) {
+ margin-left: 20px;
+ }
+ }
+
+ .note-header {
+ padding-bottom: 0;
}
.note-body {
@@ -130,116 +248,6 @@ ul.notes {
}
}
}
-
- .timeline-icon {
- display: none;
-
- .avatar {
- visibility: hidden;
-
- .discussion-body & {
- visibility: visible;
- }
- }
- }
- }
-
- .discussion-body {
- padding-top: 15px;
- }
-
- .discussion {
- overflow: hidden;
- display: block;
- position: relative;
- }
-
- .note {
- display: block;
- position: relative;
- border-bottom: 1px solid $white-normal;
-
- &.note-discussion {
- &.timeline-entry {
- padding: 14px 10px;
- }
-
- .system-note {
- padding: 0;
- }
- }
-
- &.is-editting {
- .note-header,
- .note-text,
- .edited-text {
- display: none;
- }
-
- .note-edit-form {
- display: block;
-
- &.current-note-edit-form + .note-awards {
- display: none;
- }
- }
- }
-
- .note-body {
- overflow-x: auto;
- overflow-y: hidden;
-
- .note-text {
- word-wrap: break-word;
- @include md-typography;
- // Reset ul style types since we're nested inside a ul already
- @include bulleted-list;
- ul.task-list {
- ul:not(.task-list) {
- padding-left: 1.3em;
- }
- }
- }
- }
-
- .note-awards {
- .js-awards-block {
- padding: 2px;
- margin-top: 10px;
- }
- }
-
- .note-header {
- padding-bottom: 3px;
- padding-right: 20px;
-
- @media (min-width: $screen-sm-min) {
- padding-right: 0;
- }
-
- @media (max-width: $screen-xs-min) {
- .inline {
- display: block;
- }
- }
- }
-
- .note-emoji-button {
- .fa-spinner {
- display: none;
- }
-
- &.is-loading {
- .fa-smile-o {
- display: none;
- }
-
- .fa-spinner {
- display: inline-block;
- }
- }
- }
-
}
}
@@ -294,6 +302,18 @@ ul.notes {
border-width: 1px;
}
+ .discussion-notes {
+ &:not(:first-child) {
+ border-top: 1px solid $white-normal;
+ margin-top: 20px;
+ }
+
+ &:not(:last-child) {
+ border-bottom: 1px solid $white-normal;
+ margin-bottom: 20px;
+ }
+ }
+
.notes {
background-color: $white-light;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 0fa1f68e034..717ebb44a23 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -744,7 +744,8 @@ pre.light-well {
text-align: left;
}
-.protected-branches-list {
+.protected-branches-list,
+.protected-tags-list {
margin-bottom: 30px;
a {
@@ -776,6 +777,17 @@ pre.light-well {
}
}
+.protected-tags-list {
+ .dropdown-menu-toggle {
+ width: 100%;
+ max-width: 300px;
+ }
+
+ .flash-container {
+ padding: 0;
+ }
+}
+
.custom-notifications-form {
.is-loading {
.custom-notification-event-loading {
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
index cf795d977ce..a4648b33cfa 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -6,6 +6,6 @@ class Admin::ApplicationController < ApplicationController
layout 'admin'
def authenticate_admin!
- render_404 unless current_user.is_admin?
+ render_404 unless current_user.admin?
end
end
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index cea3d088e94..f28bbdeff5a 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
:name,
:path,
:request_access_enabled,
- :visibility_level
+ :visibility_level,
+ :require_two_factor_authentication,
+ :two_factor_grace_period
]
end
end
diff --git a/app/controllers/admin/impersonations_controller.rb b/app/controllers/admin/impersonations_controller.rb
index 9433da02f64..8e7adc06584 100644
--- a/app/controllers/admin/impersonations_controller.rb
+++ b/app/controllers/admin/impersonations_controller.rb
@@ -21,6 +21,6 @@ class Admin::ImpersonationsController < Admin::ApplicationController
end
def authenticate_impersonator!
- render_404 unless impersonator && impersonator.is_admin? && !impersonator.blocked?
+ render_404 unless impersonator && impersonator.admin? && !impersonator.blocked?
end
end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 6a6e335d314..e77094fe2a8 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
include PageLayoutHelper
include SentryHelper
include WorkhorseHelper
+ include EnforcesTwoFactorAuthentication
before_action :authenticate_user_from_private_token!
before_action :authenticate_user!
before_action :validate_user_service_ticket!
before_action :check_password_expiration
- before_action :check_2fa_requirement
before_action :ldap_security_check
before_action :sentry_context
before_action :default_headers
@@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
end
end
- def check_2fa_requirement
- if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
- redirect_to profile_two_factor_auth_path
- end
- end
-
def ldap_security_check
if current_user && current_user.requires_ldap_check?
return unless current_user.try_obtain_ldap_lease
@@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
current_application_settings.import_sources.include?('gitlab_project')
end
- def two_factor_authentication_required?
- current_application_settings.require_two_factor_authentication
- end
-
- def two_factor_grace_period
- current_application_settings.two_factor_grace_period
- end
-
- def two_factor_grace_period_expired?
- date = current_user.otp_grace_period_started_at
- date && (date + two_factor_grace_period.hours) < Time.current
- end
-
- def skip_two_factor?
- session[:skip_tfa] && session[:skip_tfa] > Time.current
- end
-
# U2F (universal 2nd factor) devices need a unique identifier for the application
# to perform authentication.
# https://developers.yubico.com/U2F/App_ID.html
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
new file mode 100644
index 00000000000..688e8bd4a37
--- /dev/null
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -0,0 +1,58 @@
+# == EnforcesTwoFactorAuthentication
+#
+# Controller concern to enforce two-factor authentication requirements
+#
+# Upon inclusion, adds `check_two_factor_requirement` as a before_action,
+# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?`
+# available as view helpers.
+module EnforcesTwoFactorAuthentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :check_two_factor_requirement
+ helper_method :two_factor_grace_period_expired?, :two_factor_skippable?
+ end
+
+ def check_two_factor_requirement
+ if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
+ redirect_to profile_two_factor_auth_path
+ end
+ end
+
+ def two_factor_authentication_required?
+ current_application_settings.require_two_factor_authentication? ||
+ current_user.try(:require_two_factor_authentication_from_group?)
+ end
+
+ def two_factor_authentication_reason(global: -> {}, group: -> {})
+ if two_factor_authentication_required?
+ if current_application_settings.require_two_factor_authentication?
+ global.call
+ else
+ groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
+ group.call(groups)
+ end
+ end
+ end
+
+ def two_factor_grace_period
+ periods = [current_application_settings.two_factor_grace_period]
+ periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
+ periods.min
+ end
+
+ def two_factor_grace_period_expired?
+ date = current_user.otp_grace_period_started_at
+ date && (date + two_factor_grace_period.hours) < Time.current
+ end
+
+ def two_factor_skippable?
+ two_factor_authentication_required? &&
+ !current_user.two_factor_enabled? &&
+ !two_factor_grace_period_expired?
+ end
+
+ def skip_two_factor?
+ session[:skip_two_factor] && session[:skip_two_factor] > Time.current
+ end
+end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
new file mode 100644
index 00000000000..dd21066ac13
--- /dev/null
+++ b/app/controllers/concerns/renders_notes.rb
@@ -0,0 +1,20 @@
+module RendersNotes
+ def prepare_notes_for_rendering(notes)
+ preload_noteable_for_regular_notes(notes)
+ preload_max_access_for_authors(notes, @project)
+ Banzai::NoteRenderer.render(notes, @project, current_user)
+
+ notes
+ end
+
+ private
+
+ def preload_max_access_for_authors(notes, project)
+ user_ids = notes.map(&:author_id)
+ project.team.max_member_access_for_user_ids(user_ids)
+ end
+
+ def preload_noteable_for_regular_notes(notes)
+ ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable)
+ end
+end
diff --git a/app/controllers/concerns/requires_health_token.rb b/app/controllers/concerns/requires_health_token.rb
new file mode 100644
index 00000000000..34ab1a97649
--- /dev/null
+++ b/app/controllers/concerns/requires_health_token.rb
@@ -0,0 +1,25 @@
+module RequiresHealthToken
+ extend ActiveSupport::Concern
+ included do
+ before_action :validate_health_check_access!
+ end
+
+ private
+
+ def validate_health_check_access!
+ render_404 unless token_valid?
+ end
+
+ def token_valid?
+ token = params[:token].presence || request.headers['TOKEN']
+ token.present? &&
+ ActiveSupport::SecurityUtils.variable_size_secure_compare(
+ token,
+ current_application_settings.health_check_access_token
+ )
+ end
+
+ def render_404
+ render file: Rails.root.join('public', '404'), layout: false, status: '404'
+ end
+end
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index 78c9f1f7004..593001e6396 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController
:visibility_level,
:parent_id,
:create_chat_team,
- :chat_team_name
+ :chat_team_name,
+ :require_two_factor_authentication,
+ :two_factor_grace_period
]
end
diff --git a/app/controllers/health_check_controller.rb b/app/controllers/health_check_controller.rb
index 037da7d2bce..5d3109b7187 100644
--- a/app/controllers/health_check_controller.rb
+++ b/app/controllers/health_check_controller.rb
@@ -1,22 +1,3 @@
class HealthCheckController < HealthCheck::HealthCheckController
- before_action :validate_health_check_access!
-
- private
-
- def validate_health_check_access!
- render_404 unless token_valid?
- end
-
- def token_valid?
- token = params[:token].presence || request.headers['TOKEN']
- token.present? &&
- ActiveSupport::SecurityUtils.variable_size_secure_compare(
- token,
- current_application_settings.health_check_access_token
- )
- end
-
- def render_404
- render file: Rails.root.join('public', '404'), layout: false, status: '404'
- end
+ include RequiresHealthToken
end
diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb
new file mode 100644
index 00000000000..df0fc3132ed
--- /dev/null
+++ b/app/controllers/health_controller.rb
@@ -0,0 +1,60 @@
+class HealthController < ActionController::Base
+ protect_from_forgery with: :exception
+ include RequiresHealthToken
+
+ CHECKS = [
+ Gitlab::HealthChecks::DbCheck,
+ Gitlab::HealthChecks::RedisCheck,
+ Gitlab::HealthChecks::FsShardsCheck,
+ ].freeze
+
+ def readiness
+ results = CHECKS.map { |check| [check.name, check.readiness] }
+
+ render_check_results(results)
+ end
+
+ def liveness
+ results = CHECKS.map { |check| [check.name, check.liveness] }
+
+ render_check_results(results)
+ end
+
+ def metrics
+ results = CHECKS.flat_map(&:metrics)
+
+ response = results.map(&method(:metric_to_prom_line)).join("\n")
+
+ render text: response, content_type: 'text/plain; version=0.0.4'
+ end
+
+ private
+
+ def metric_to_prom_line(metric)
+ labels = metric.labels&.map { |key, value| "#{key}=\"#{value}\"" }&.join(',') || ''
+ if labels.empty?
+ "#{metric.name} #{metric.value}"
+ else
+ "#{metric.name}{#{labels}} #{metric.value}"
+ end
+ end
+
+ def render_check_results(results)
+ flattened = results.flat_map do |name, result|
+ if result.is_a?(Gitlab::HealthChecks::Result)
+ [[name, result]]
+ else
+ result.map { |r| [name, r] }
+ end
+ end
+ success = flattened.all? { |name, r| r.success }
+
+ response = flattened.map do |name, r|
+ info = { status: r.success ? 'ok' : 'failed' }
+ info['message'] = r.message if r.message
+ info[:labels] = r.labels if r.labels
+ [name, info]
+ end
+ render json: response.to_h, status: success ? :ok : :service_unavailable
+ end
+end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index 26e7e93533e..d3fa81cd623 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -1,5 +1,5 @@
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
- skip_before_action :check_2fa_requirement
+ skip_before_action :check_two_factor_requirement
def show
unless current_user.otp_secret
@@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
current_user.save! if current_user.changed?
if two_factor_authentication_required? && !current_user.two_factor_enabled?
- if two_factor_grace_period_expired?
- flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
- else
+ two_factor_authentication_reason(
+ global: lambda do
+ flash.now[:alert] =
+ 'The global settings require you to enable Two-Factor Authentication for your account.'
+ end,
+ group: lambda do |groups|
+ group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
+
+ flash.now[:alert] = %{
+ The group settings for #{group_links} require you to enable
+ Two-Factor Authentication for your account.
+ }.html_safe
+ end
+ )
+
+ unless two_factor_grace_period_expired?
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
- flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
+ flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}."
end
end
@@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if two_factor_grace_period_expired?
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
else
- session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
+ session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
redirect_to root_path
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 80a95c6158b..73706bf8dae 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
# Raised when given an invalid file path
InvalidPathError = Class.new(StandardError)
+ prepend_before_action :authenticate_user!, only: [:edit]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
- before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy]
+ before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
before_action :assign_blob_vars
before_action :commit, except: [:new, :create]
before_action :blob, except: [:new, :create]
@@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
end
def edit
- blob.load_all_data!(@repository)
+ if can_collaborate_with_project?
+ blob.load_all_data!(@repository)
+ else
+ redirect_to action: 'show'
+ end
end
def update
diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb
index 3f3c90a49ab..04e8cdf6256 100644
--- a/app/controllers/projects/builds_controller.rb
+++ b/app/controllers/projects/builds_controller.rb
@@ -19,6 +19,11 @@ class Projects::BuildsController < Projects::ApplicationController
else
@builds
end
+ @builds = @builds.includes([
+ { pipeline: :project },
+ :project,
+ :tags
+ ])
@builds = @builds.page(params[:page]).per(30)
end
@@ -31,25 +36,25 @@ class Projects::BuildsController < Projects::ApplicationController
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
@builds = @builds.where("id not in (?)", @build.id)
@pipeline = @build.pipeline
-
- respond_to do |format|
- format.html
- format.json do
- render json: {
- id: @build.id,
- status: @build.status,
- trace_html: @build.trace_html
- }
- end
- end
end
def trace
- respond_to do |format|
- format.json do
- state = params[:state].presence
- render json: @build.trace_with_state(state: state).
- merge!(id: @build.id, status: @build.status)
+ build.trace.read do |stream|
+ respond_to do |format|
+ format.json do
+ result = {
+ id: @build.id, status: @build.status, complete: @build.complete?
+ }
+
+ if stream.valid?
+ stream.limit
+ state = params[:state].presence
+ trace = stream.html_with_state(state)
+ result.merge!(trace.to_h)
+ end
+
+ render json: result
+ end
end
end
end
@@ -86,10 +91,12 @@ class Projects::BuildsController < Projects::ApplicationController
end
def raw
- if @build.has_trace_file?
- send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline'
- else
- render_404
+ build.trace.read do |stream|
+ if stream.file?
+ send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
+ else
+ render_404
+ end
end
end
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index cc67f688d51..d25bbddd1bb 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -2,6 +2,7 @@
#
# Not to be confused with CommitsController, plural.
class Projects::CommitController < Projects::ApplicationController
+ include RendersNotes
include CreatesCommit
include DiffForPath
include DiffHelper
@@ -35,6 +36,8 @@ class Projects::CommitController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
render json: PipelineSerializer
.new(project: @project, user: @current_user)
.represent(@pipelines)
@@ -111,22 +114,19 @@ class Projects::CommitController < Projects::ApplicationController
end
def define_note_vars
- @grouped_diff_discussions = commit.notes.grouped_diff_discussions
- @notes = commit.notes.non_diff_notes.fresh
-
- Banzai::NoteRenderer.render(
- @grouped_diff_discussions.values.flat_map(&:notes) + @notes,
- @project,
- current_user,
- )
-
+ @noteable = @commit
@note = @project.build_commit_note(commit)
- @noteable = @commit
- @comments_target = {
+ @new_diff_note_attrs = {
noteable_type: 'Commit',
commit_id: @commit.id
}
+
+ @grouped_diff_discussions = commit.grouped_diff_discussions
+ @discussions = commit.discussions
+
+ @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes)
+ @notes = prepare_notes_for_rendering(@notes)
end
def assign_change_commit_vars
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index c6651254d70..008d2f5815f 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -61,7 +61,6 @@ class Projects::CompareController < Projects::ApplicationController
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
@diff_notes_disabled = true
- @grouped_diff_discussions = {}
end
end
diff --git a/app/controllers/projects/container_registry_controller.rb b/app/controllers/projects/container_registry_controller.rb
deleted file mode 100644
index d1f46497207..00000000000
--- a/app/controllers/projects/container_registry_controller.rb
+++ /dev/null
@@ -1,34 +0,0 @@
-class Projects::ContainerRegistryController < Projects::ApplicationController
- before_action :verify_registry_enabled
- before_action :authorize_read_container_image!
- before_action :authorize_update_container_image!, only: [:destroy]
- layout 'project'
-
- def index
- @tags = container_registry_repository.tags
- end
-
- def destroy
- url = namespace_project_container_registry_index_path(project.namespace, project)
-
- if tag.delete
- redirect_to url
- else
- redirect_to url, alert: 'Failed to remove tag'
- end
- end
-
- private
-
- def verify_registry_enabled
- render_404 unless Gitlab.config.registry.enabled
- end
-
- def container_registry_repository
- @container_registry_repository ||= project.container_registry_repository
- end
-
- def tag
- @tag ||= container_registry_repository.tag(params[:id])
- end
-end
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index 1349b015a63..f4a18a5e8f7 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -28,7 +28,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
end
def discussion
- @discussion ||= @merge_request.find_diff_discussion(params[:id]) || render_404
+ @discussion ||= @merge_request.find_discussion(params[:id]) || render_404
end
def authorize_resolve_discussion!
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index b668a9331e7..1e41f980f31 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -10,7 +10,7 @@ class Projects::HooksController < Projects::ApplicationController
@hook = @project.hooks.new(hook_params)
@hook.save
- unless @hook.valid?
+ unless @hook.valid?
@hooks = @project.hooks.select(&:persisted?)
flash[:alert] = @hook.errors.full_messages.join.html_safe
end
@@ -49,7 +49,7 @@ class Projects::HooksController < Projects::ApplicationController
def hook_params
params.require(:hook).permit(
- :build_events,
+ :job_events,
:pipeline_events,
:enable_ssl_verification,
:issues_events,
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index a50e16fa4ff..cbf67137261 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -1,5 +1,5 @@
class Projects::IssuesController < Projects::ApplicationController
- include NotesHelper
+ include RendersNotes
include ToggleSubscriptionAction
include IssuableActions
include ToggleAwardEmoji
@@ -84,15 +84,11 @@ class Projects::IssuesController < Projects::ApplicationController
end
def show
- raw_notes = @issue.notes.inc_relations_for_view.fresh
-
- @notes = Banzai::NoteRenderer.
- render(raw_notes, @project, current_user, @path, @project_wiki, @ref)
-
- @note = @project.notes.new(noteable: @issue)
@noteable = @issue
+ @note = @project.notes.new(noteable: @issue)
- preload_max_access_for_authors(@notes, @project)
+ @discussions = @issue.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
respond_to do |format|
format.html
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index a79d801991a..09dc8b38229 100755
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -3,7 +3,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
include DiffForPath
include DiffHelper
include IssuableActions
- include NotesHelper
+ include RendersNotes
include ToggleAwardEmoji
include IssuableCollections
@@ -16,7 +16,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
before_action :define_show_vars, only: [:show, :diffs, :commits, :conflicts, :conflict_for_path, :builds, :pipelines]
before_action :define_widget_vars, only: [:merge, :cancel_merge_when_pipeline_succeeds, :merge_check]
before_action :define_commit_vars, only: [:diffs]
- before_action :define_diff_comment_vars, only: [:diffs]
before_action :ensure_ref_fetched, only: [:show, :diffs, :commits, :builds, :conflicts, :conflict_for_path, :pipelines]
before_action :close_merge_request_without_source_project, only: [:show, :diffs, :commits, :builds, :pipelines]
before_action :apply_diff_view_cookie!, only: [:new_diffs]
@@ -39,7 +38,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@collection_type = "MergeRequest"
@merge_requests = merge_requests_collection
@merge_requests = @merge_requests.page(params[:page])
- @merge_requests = @merge_requests.includes(merge_request_diff: :merge_request)
+ @merge_requests = @merge_requests.preload(merge_request_diff: :merge_request)
@issuable_meta_data = issuable_meta_data(@merge_requests, @collection_type)
if @merge_requests.out_of_range? && @merge_requests.total_pages != 0
@@ -101,34 +100,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController
respond_to do |format|
format.html { define_discussion_vars }
format.json do
- @merge_request_diff =
- if params[:diff_id]
- @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
- else
- @merge_request.merge_request_diff
- end
-
- @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
- @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
-
- if params[:start_sha].present?
- @start_sha = params[:start_sha]
- @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
-
- unless @start_version
- @start_sha = @merge_request_diff.head_commit_sha
- @start_version = @merge_request_diff
- end
- end
+ define_diff_vars
+ define_diff_comment_vars
@environment = @merge_request.environments_for(current_user).last
- if @start_sha
- compared_diff_version
- else
- original_diff_version
- end
-
render json: { html: view_to_html_string("projects/merge_requests/show/_diffs") }
end
end
@@ -140,16 +116,17 @@ class Projects::MergeRequestsController < Projects::ApplicationController
def diff_for_path
if params[:id]
merge_request
+ define_diff_vars
define_diff_comment_vars
else
build_merge_request
+ @diffs = @merge_request.diffs(diff_options)
@diff_notes_disabled = true
- @grouped_diff_discussions = {}
end
define_commit_vars
- render_diff_for_path(@merge_request.diffs(diff_options))
+ render_diff_for_path(@diffs)
end
def commits
@@ -233,6 +210,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
end
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
render json: PipelineSerializer
.new(project: @project, user: @current_user)
.represent(@pipelines)
@@ -246,6 +225,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController
format.json do
define_pipelines_vars
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
render json: {
pipelines: PipelineSerializer
.new(project: @project, user: @current_user)
@@ -452,7 +433,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
if pipeline
status = pipeline.status
- coverage = pipeline.try(:coverage)
+ coverage = pipeline.coverage
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
@@ -570,20 +551,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@note = @project.notes.new(noteable: @merge_request)
@discussions = @merge_request.discussions
-
- preload_noteable_for_regular_notes(@discussions.flat_map(&:notes))
-
- # This is not executed lazily
- @notes = Banzai::NoteRenderer.render(
- @discussions.flat_map(&:notes),
- @project,
- current_user,
- @path,
- @project_wiki,
- @ref
- )
-
- preload_max_access_for_authors(@notes, @project)
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
end
def define_widget_vars
@@ -595,23 +563,47 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@base_commit = @merge_request.diff_base_commit || @merge_request.likely_diff_base_commit
end
+ def define_diff_vars
+ @merge_request_diff =
+ if params[:diff_id]
+ @merge_request.merge_request_diffs.viewable.find(params[:diff_id])
+ else
+ @merge_request.merge_request_diff
+ end
+
+ @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff
+ @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
+
+ if params[:start_sha].present?
+ @start_sha = params[:start_sha]
+ @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha }
+
+ unless @start_version
+ @start_sha = @merge_request_diff.head_commit_sha
+ @start_version = @merge_request_diff
+ end
+ end
+
+ @diffs =
+ if @start_sha
+ @merge_request_diff.compare_with(@start_sha).diffs(diff_options)
+ else
+ @merge_request_diff.diffs(diff_options)
+ end
+ end
+
def define_diff_comment_vars
- @comments_target = {
+ @new_diff_note_attrs = {
noteable_type: 'MergeRequest',
noteable_id: @merge_request.id
}
+ @diff_notes_disabled = !@merge_request_diff.latest? || @start_sha
+
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
- @grouped_diff_discussions = @merge_request.notes.inc_relations_for_view.grouped_diff_discussions
- Banzai::NoteRenderer.render(
- @grouped_diff_discussions.values.flat_map(&:notes),
- @project,
- current_user,
- @path,
- @project_wiki,
- @ref
- )
+ @grouped_diff_discussions = @merge_request.grouped_diff_discussions(@merge_request_diff.diff_refs)
+ @notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
end
def define_pipelines_vars
@@ -694,16 +686,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController
@merge_request = MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
end
- def compared_diff_version
- @diff_notes_disabled = true
- @diffs = @merge_request_diff.compare_with(@start_sha).diffs(diff_options)
- end
-
- def original_diff_version
- @diff_notes_disabled = !@merge_request_diff.latest?
- @diffs = @merge_request_diff.diffs(diff_options)
- end
-
def close_merge_request_without_source_project
if !@merge_request.source_project && @merge_request.open?
@merge_request.close
diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb
index d00177e7612..405ea3c0a4f 100644
--- a/app/controllers/projects/notes_controller.rb
+++ b/app/controllers/projects/notes_controller.rb
@@ -1,4 +1,5 @@
class Projects::NotesController < Projects::ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
# Authorize
@@ -6,13 +7,15 @@ class Projects::NotesController < Projects::ApplicationController
before_action :authorize_create_note!, only: [:create]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :authorize_resolve_note!, only: [:resolve, :unresolve]
- before_action :find_current_user_notes, only: [:index]
def index
current_fetched_at = Time.now.to_i
notes_json = { notes: [], last_fetched_at: current_fetched_at }
+ @notes = notes_finder.execute.inc_relations_for_view
+ @notes = prepare_notes_for_rendering(@notes)
+
@notes.each do |note|
next if note.cross_reference_not_visible_for?(current_user)
@@ -23,7 +26,10 @@ class Projects::NotesController < Projects::ApplicationController
end
def create
- create_params = note_params.merge(merge_request_diff_head_sha: params[:merge_request_diff_head_sha])
+ create_params = note_params.merge(
+ merge_request_diff_head_sha: params[:merge_request_diff_head_sha],
+ in_reply_to_discussion_id: params[:in_reply_to_discussion_id]
+ )
@note = Notes::CreateService.new(project, current_user, create_params).execute
if @note.is_a?(Note)
@@ -111,6 +117,17 @@ class Projects::NotesController < Projects::ApplicationController
)
end
+ def discussion_html(discussion)
+ return if discussion.individual_note?
+
+ render_to_string(
+ "discussions/_discussion",
+ layout: false,
+ formats: [:html],
+ locals: { discussion: discussion }
+ )
+ end
+
def diff_discussion_html(discussion)
return unless discussion.diff_discussion?
@@ -118,13 +135,13 @@ class Projects::NotesController < Projects::ApplicationController
template = "discussions/_parallel_diff_discussion"
locals =
if params[:line_type] == 'old'
- { discussion_left: discussion, discussion_right: nil }
+ { discussions_left: [discussion], discussions_right: nil }
else
- { discussion_left: nil, discussion_right: discussion }
+ { discussions_left: nil, discussions_right: [discussion] }
end
else
template = "discussions/_diff_discussion"
- locals = { discussion: discussion }
+ locals = { discussions: [discussion] }
end
render_to_string(
@@ -135,54 +152,28 @@ class Projects::NotesController < Projects::ApplicationController
)
end
- def discussion_html(discussion)
- return unless discussion.diff_discussion?
-
- render_to_string(
- "discussions/_discussion",
- layout: false,
- formats: [:html],
- locals: { discussion: discussion }
- )
- end
-
def note_json(note)
attrs = {
- id: note.id
+ commands_changes: note.commands_changes
}
if note.persisted?
- Banzai::NoteRenderer.render([note], @project, current_user)
-
attrs.merge!(
valid: true,
- discussion_id: note.discussion_id,
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
html: note_html(note),
note: note.note
)
- if note.diff_note?
- discussion = note.to_discussion
-
+ discussion = note.to_discussion(noteable)
+ unless discussion.individual_note?
attrs.merge!(
+ discussion_resolvable: discussion.resolvable?,
+
diff_discussion_html: diff_discussion_html(discussion),
discussion_html: discussion_html(discussion)
)
-
- # The discussion_id is used to add the comment to the correct discussion
- # element on the merge request page. Among other things, the discussion_id
- # contains the sha of head commit of the merge request.
- # When new commits are pushed into the merge request after the initial
- # load of the merge request page, the discussion elements will still have
- # the old discussion_ids, with the old head commit sha. The new comment,
- # however, will have the new discussion_id with the new commit sha.
- # To ensure that these new comments will still end up in the correct
- # discussion element, we also send the original discussion_id, with the
- # old commit sha, along, and fall back on this value when no discussion
- # element with the new discussion_id could be found.
- if note.new_diff_note? && note.position != note.original_position
- attrs[:original_discussion_id] = note.original_discussion_id
- end
end
else
attrs.merge!(
@@ -191,7 +182,6 @@ class Projects::NotesController < Projects::ApplicationController
)
end
- attrs[:commands_changes] = note.commands_changes
attrs
end
@@ -205,14 +195,30 @@ class Projects::NotesController < Projects::ApplicationController
def note_params
params.require(:note).permit(
- :note, :noteable, :noteable_id, :noteable_type, :project_id,
- :attachment, :line_code, :commit_id, :type, :position
+ :project_id,
+ :noteable_type,
+ :noteable_id,
+ :commit_id,
+ :noteable,
+ :type,
+
+ :note,
+ :attachment,
+
+ # LegacyDiffNote
+ :line_code,
+
+ # DiffNote
+ :position
)
end
- def find_current_user_notes
- @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
- .execute.inc_author
+ def notes_finder
+ @notes_finder ||= NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
+ end
+
+ def noteable
+ @noteable ||= notes_finder.target
end
def last_fetched_at
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 43a1abaa662..1780cc0233c 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -29,6 +29,8 @@ class Projects::PipelinesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
+ Gitlab::PollingInterval.set_header(response, interval: 10_000)
+
render json: {
pipelines: PipelineSerializer
.new(project: @project, user: @current_user)
@@ -114,7 +116,7 @@ class Projects::PipelinesController < Projects::ApplicationController
end
def pipeline
- @pipeline ||= project.pipelines.find_by!(id: params[:id])
+ @pipeline ||= project.pipelines.find_by!(id: params[:id]).present(current_user: current_user)
end
def commit
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index c8c80551ac9..ff50602831c 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -23,7 +23,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
def update_params
params.require(:project).permit(
:runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex,
- :public_builds
+ :public_builds, :auto_cancel_pending_pipelines
)
end
end
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index a8cb07eb67a..ba24fa9acfe 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -1,58 +1,23 @@
-class Projects::ProtectedBranchesController < Projects::ApplicationController
- include RepositorySettingsRedirect
- # Authorize
- before_action :require_non_empty_project
- before_action :authorize_admin_project!
- before_action :load_protected_branch, only: [:show, :update, :destroy]
+class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
+ protected
- layout "project_settings"
-
- def index
- redirect_to_repository_settings(@project)
- end
-
- def create
- @protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
- unless @protected_branch.persisted?
- flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe
- end
- redirect_to_repository_settings(@project)
- end
-
- def show
- @matching_branches = @protected_branch.matching(@project.repository.branches)
+ def project_refs
+ @project.repository.branches
end
- def update
- @protected_branch = ::ProtectedBranches::UpdateService.new(@project, current_user, protected_branch_params).execute(@protected_branch)
-
- if @protected_branch.valid?
- respond_to do |format|
- format.json { render json: @protected_branch, status: :ok }
- end
- else
- respond_to do |format|
- format.json { render json: @protected_branch.errors, status: :unprocessable_entity }
- end
- end
+ def create_service_class
+ ::ProtectedBranches::CreateService
end
- def destroy
- @protected_branch.destroy
-
- respond_to do |format|
- format.html { redirect_to_repository_settings(@project) }
- format.js { head :ok }
- end
+ def update_service_class
+ ::ProtectedBranches::UpdateService
end
- private
-
- def load_protected_branch
- @protected_branch = @project.protected_branches.find(params[:id])
+ def load_protected_ref
+ @protected_ref = @project.protected_branches.find(params[:id])
end
- def protected_branch_params
+ def protected_ref_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: [:access_level, :id],
push_access_levels_attributes: [:access_level, :id])
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
new file mode 100644
index 00000000000..083a70968e5
--- /dev/null
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -0,0 +1,47 @@
+class Projects::ProtectedRefsController < Projects::ApplicationController
+ include RepositorySettingsRedirect
+
+ # Authorize
+ before_action :require_non_empty_project
+ before_action :authorize_admin_project!
+ before_action :load_protected_ref, only: [:show, :update, :destroy]
+
+ layout "project_settings"
+
+ def index
+ redirect_to_repository_settings(@project)
+ end
+
+ def create
+ protected_ref = create_service_class.new(@project, current_user, protected_ref_params).execute
+
+ unless protected_ref.persisted?
+ flash[:alert] = protected_ref.errors.full_messages.join(', ').html_safe
+ end
+
+ redirect_to_repository_settings(@project)
+ end
+
+ def show
+ @matching_refs = @protected_ref.matching(project_refs)
+ end
+
+ def update
+ @protected_ref = update_service_class.new(@project, current_user, protected_ref_params).execute(@protected_ref)
+
+ if @protected_ref.valid?
+ render json: @protected_ref, status: :ok
+ else
+ render json: @protected_ref.errors, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @protected_ref.destroy
+
+ respond_to do |format|
+ format.html { redirect_to_repository_settings(@project) }
+ format.js { head :ok }
+ end
+ end
+end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
new file mode 100644
index 00000000000..c61ddf145e6
--- /dev/null
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -0,0 +1,23 @@
+class Projects::ProtectedTagsController < Projects::ProtectedRefsController
+ protected
+
+ def project_refs
+ @project.repository.tags
+ end
+
+ def create_service_class
+ ::ProtectedTags::CreateService
+ end
+
+ def update_service_class
+ ::ProtectedTags::UpdateService
+ end
+
+ def load_protected_ref
+ @protected_ref = @project.protected_tags.find(params[:id])
+ end
+
+ def protected_ref_params
+ params.require(:protected_tag).permit(:name, create_access_levels_attributes: [:access_level, :id])
+ end
+end
diff --git a/app/controllers/projects/registry/application_controller.rb b/app/controllers/projects/registry/application_controller.rb
new file mode 100644
index 00000000000..a56f9c58726
--- /dev/null
+++ b/app/controllers/projects/registry/application_controller.rb
@@ -0,0 +1,16 @@
+module Projects
+ module Registry
+ class ApplicationController < Projects::ApplicationController
+ layout 'project'
+
+ before_action :verify_registry_enabled!
+ before_action :authorize_read_container_image!
+
+ private
+
+ def verify_registry_enabled!
+ render_404 unless Gitlab.config.registry.enabled
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb
new file mode 100644
index 00000000000..17f391ba07f
--- /dev/null
+++ b/app/controllers/projects/registry/repositories_controller.rb
@@ -0,0 +1,43 @@
+module Projects
+ module Registry
+ class RepositoriesController < ::Projects::Registry::ApplicationController
+ before_action :authorize_update_container_image!, only: [:destroy]
+ before_action :ensure_root_container_repository!, only: [:index]
+
+ def index
+ @images = project.container_repositories
+ end
+
+ def destroy
+ if image.destroy
+ redirect_to project_container_registry_path(@project),
+ notice: 'Image repository has been removed successfully!'
+ else
+ redirect_to project_container_registry_path(@project),
+ alert: 'Failed to remove image repository!'
+ end
+ end
+
+ private
+
+ def image
+ @image ||= project.container_repositories.find(params[:id])
+ end
+
+ ##
+ # Container repository object for root project path.
+ #
+ # Needed to maintain a backwards compatibility.
+ #
+ def ensure_root_container_repository!
+ ContainerRegistry::Path.new(@project.full_path).tap do |path|
+ break if path.has_repository?
+
+ ContainerRepository.build_from_path(path).tap do |repository|
+ repository.save! if repository.has_tags?
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb
new file mode 100644
index 00000000000..d689cade3ab
--- /dev/null
+++ b/app/controllers/projects/registry/tags_controller.rb
@@ -0,0 +1,28 @@
+module Projects
+ module Registry
+ class TagsController < ::Projects::Registry::ApplicationController
+ before_action :authorize_update_container_image!, only: [:destroy]
+
+ def destroy
+ if tag.delete
+ redirect_to project_container_registry_path(@project),
+ notice: 'Registry tag has been removed successfully!'
+ else
+ redirect_to project_container_registry_path(@project),
+ alert: 'Failed to remove registry tag!'
+ end
+ end
+
+ private
+
+ def image
+ @image ||= project.container_repositories
+ .find(params[:repository_id])
+ end
+
+ def tag
+ @tag ||= image.tag(params[:id])
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index b6ce4abca45..44de8a49593 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -4,46 +4,48 @@ module Projects
before_action :authorize_admin_project!
def show
- @deploy_keys = DeployKeysPresenter
- .new(@project, current_user: current_user)
+ @deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
- define_protected_branches
+ define_protected_refs
end
private
- def define_protected_branches
- load_protected_branches
+ def define_protected_refs
+ @protected_branches = @project.protected_branches.order(:name).page(params[:page])
+ @protected_tags = @project.protected_tags.order(:name).page(params[:page])
@protected_branch = @project.protected_branches.new
+ @protected_tag = @project.protected_tags.new
load_gon_index
end
- def load_protected_branches
- @protected_branches = @project.protected_branches.order(:name).page(params[:page])
- end
-
def access_levels_options
{
- push_access_levels: {
- roles: ProtectedBranch::PushAccessLevel.human_access_levels.map do |id, text|
- { id: id, text: text, before_divider: true }
- end
- },
- merge_access_levels: {
- roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map do |id, text|
- { id: id, text: text, before_divider: true }
- end
- }
+ create_access_levels: levels_for_dropdown(ProtectedTag::CreateAccessLevel),
+ push_access_levels: levels_for_dropdown(ProtectedBranch::PushAccessLevel),
+ merge_access_levels: levels_for_dropdown(ProtectedBranch::MergeAccessLevel)
}
end
- def open_branches
- branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }
- { open_branches: branches }
+ def levels_for_dropdown(access_level_type)
+ roles = access_level_type.human_access_levels.map do |id, text|
+ { id: id, text: text, before_divider: true }
+ end
+ { roles: roles }
+ end
+
+ def protectable_tags_for_dropdown
+ { open_tags: ProtectableDropdown.new(@project, :tags).hash }
+ end
+
+ def protectable_branches_for_dropdown
+ { open_branches: ProtectableDropdown.new(@project, :branches).hash }
end
def load_gon_index
- gon.push(open_branches.merge(access_levels_options))
+ gon.push(protectable_tags_for_dropdown)
+ gon.push(protectable_branches_for_dropdown)
+ gon.push(access_levels_options)
end
end
end
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index ea1a97b7cf0..5c9e0d4d1a1 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -1,4 +1,5 @@
class Projects::SnippetsController < Projects::ApplicationController
+ include RendersNotes
include ToggleAwardEmoji
include SpammableActions
include SnippetsActions
@@ -55,8 +56,10 @@ class Projects::SnippetsController < Projects::ApplicationController
def show
@note = @project.notes.new(noteable: @snippet)
- @notes = Banzai::NoteRenderer.render(@snippet.notes.fresh, @project, current_user)
@noteable = @snippet
+
+ @discussions = @snippet.discussions
+ @notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
end
def destroy
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index c47198c5eb6..afa56de920b 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -11,7 +11,7 @@ class Projects::TriggersController < Projects::ApplicationController
end
def create
- @trigger = project.triggers.create(create_params.merge(owner: current_user))
+ @trigger = project.triggers.create(trigger_params.merge(owner: current_user))
if @trigger.valid?
flash[:notice] = 'Trigger was created successfully.'
@@ -36,7 +36,7 @@ class Projects::TriggersController < Projects::ApplicationController
end
def update
- if trigger.update(update_params)
+ if trigger.update(trigger_params)
redirect_to namespace_project_settings_ci_cd_path(@project.namespace, @project), notice: 'Trigger was successfully updated.'
else
render action: "edit"
@@ -67,11 +67,10 @@ class Projects::TriggersController < Projects::ApplicationController
@trigger ||= project.triggers.find(params[:id]) || render_404
end
- def create_params
- params.require(:trigger).permit(:description)
- end
-
- def update_params
- params.require(:trigger).permit(:description)
+ def trigger_params
+ params.require(:trigger).permit(
+ :description,
+ trigger_schedule_attributes: [:id, :active, :cron, :cron_timezone, :ref]
+ )
end
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 47f7e0b1b28..6807c37f972 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -345,7 +345,11 @@ class ProjectsController < Projects::ApplicationController
end
def project_view_files?
- current_user && current_user.project_view == 'files'
+ if current_user
+ current_user.project_view == 'files'
+ else
+ project_view_files_allowed?
+ end
end
# Override extract_ref from ExtractsPath, which returns the branch and file path
@@ -359,4 +363,8 @@ class ProjectsController < Projects::ApplicationController
def get_id
project.repository.root_ref
end
+
+ def project_view_files_allowed?
+ !project.empty_repo? && can?(current_user, :download_code, project)
+ end
end
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index d8561871098..d3091a4f8e9 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
include Devise::Controllers::Rememberable
include Recaptcha::ClientHelper
- skip_before_action :check_2fa_requirement, only: [:destroy]
+ skip_before_action :check_two_factor_requirement, only: [:destroy]
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb
index 6630c6384f2..3c499184b41 100644
--- a/app/finders/notes_finder.rb
+++ b/app/finders/notes_finder.rb
@@ -17,29 +17,46 @@ class NotesFinder
@project = project
@current_user = current_user
@params = params
- init_collection
end
def execute
- @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at]
- @notes
+ notes = init_collection
+ notes = since_fetch_at(notes)
+ notes.fresh
end
- private
+ def target
+ return @target if defined?(@target)
- def init_collection
- @notes =
- if @params[:target_id]
- on_target(@params[:target_type], @params[:target_id])
+ target_type = @params[:target_type]
+ target_id = @params[:target_id]
+
+ return @target = nil unless target_type && target_id
+
+ @target =
+ if target_type == "commit"
+ if Ability.allowed?(@current_user, :download_code, @project)
+ @project.commit(target_id)
+ end
else
- notes_of_any_type
+ noteables_for_type(target_type).find(target_id)
end
end
+ private
+
+ def init_collection
+ if target
+ notes_on_target
+ else
+ notes_of_any_type
+ end
+ end
+
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
- note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search]
+ note_relations.map! { |notes| search(notes) }
UnionFinder.new.find_union(note_relations, Note)
end
@@ -69,17 +86,11 @@ class NotesFinder
end
end
- def on_target(target_type, target_id)
- if target_type == "commit"
- notes_for_type('commit').for_commit_id(target_id)
+ def notes_on_target
+ if target.respond_to?(:related_notes)
+ target.related_notes
else
- target = noteables_for_type(target_type).find(target_id)
-
- if target.respond_to?(:related_notes)
- target.related_notes
- else
- target.notes
- end
+ target.notes
end
end
@@ -87,17 +98,21 @@ class NotesFinder
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
- def search(query, notes_relation = @notes)
+ def search(notes)
+ query = @params[:search]
+ return notes unless query
+
pattern = "%#{query}%"
- notes_relation.where(Note.arel_table[:note].matches(pattern))
+ notes.where(Note.arel_table[:note].matches(pattern))
end
# Notes changed since last fetch
# Uses overlapping intervals to avoid worrying about race conditions
- def since_fetch_at(fetch_time)
+ def since_fetch_at(notes)
+ return notes unless @params[:last_fetched_at]
+
# Default to 0 to remain compatible with old clients
last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i)
-
- @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh
+ notes.updated_after(last_fetched_at - FETCH_OVERLAP)
end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 101fe579da2..9c71d6c7f4c 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -64,18 +64,6 @@ module AuthHelper
current_user.identities.exists?(provider: provider.to_s)
end
- def two_factor_skippable?
- current_application_settings.require_two_factor_authentication &&
- !current_user.two_factor_enabled? &&
- current_application_settings.two_factor_grace_period &&
- !two_factor_grace_period_expired?
- end
-
- def two_factor_grace_period_expired?
- current_user.otp_grace_period_started_at &&
- (current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current
- end
-
def unlink_allowed?(provider)
%w(saml cas3).exclude?(provider.to_s)
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index 8631bc54509..6c3f3a61e0a 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -8,31 +8,36 @@ module BlobHelper
%w(credits changelog news copying copyright license authors)
end
- def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
- return unless current_user
+ def edit_path(project = @project, ref = @ref, path = @path, options = {})
+ namespace_project_edit_blob_path(project.namespace, project,
+ tree_join(ref, path),
+ options[:link_opts])
+ end
+ def fork_path(project = @project, ref = @ref, path = @path, options = {})
+ continue_params = {
+ to: edit_path,
+ notice: edit_in_new_fork_notice,
+ notice_now: edit_in_new_fork_notice_now
+ }
+ namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
+ end
+
+ def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
blob = options.delete(:blob)
blob ||= project.repository.blob_at(ref, path) rescue nil
return unless blob
- edit_path = namespace_project_edit_blob_path(project.namespace, project,
- tree_join(ref, path),
- options[:link_opts])
+ common_classes = "btn js-edit-blob #{options[:extra_class]}"
if !on_top_of_branch?(project, ref)
- button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
- elsif can_edit_blob?(blob, project, ref)
- link_to "Edit", edit_path, class: 'btn btn-sm'
- elsif can?(current_user, :fork_project, project)
- continue_params = {
- to: edit_path,
- notice: edit_in_new_fork_notice,
- notice_now: edit_in_new_fork_notice_now
- }
- fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
-
- link_to "Edit", fork_path, class: 'btn', method: :post
+ button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
+ # This condition applies to anonymous or users who can edit directly
+ elsif !current_user || (current_user && can_edit_blob?(blob, project, ref))
+ link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
+ elsif current_user && can?(current_user, :fork_project, project)
+ button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler"
end
end
@@ -97,7 +102,7 @@ module BlobHelper
if Gitlab::MarkupHelper.previewable?(filename)
'Preview'
else
- 'Preview Changes'
+ 'Preview changes'
end
end
@@ -113,6 +118,10 @@ module BlobHelper
blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw?
end
+ def blob_rendered_as_text?(blob)
+ blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text'
+ end
+
def blob_size(blob)
if blob.lfs_pointer?
blob.lfs_size
@@ -205,13 +214,13 @@ module BlobHelper
end
def copy_file_path_button(file_path)
- clipboard_button(clipboard_text: file_path, class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
+ clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
end
def copy_blob_content_button(blob)
return if markup?(blob.name)
- clipboard_button(clipboard_target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
+ clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
end
def open_raw_file_button(path)
diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb
index 3fc85dc6b2b..b7a28b1b4a7 100644
--- a/app/helpers/branches_helper.rb
+++ b/app/helpers/branches_helper.rb
@@ -1,6 +1,6 @@
module BranchesHelper
def can_remove_branch?(project, branch_name)
- if project.protected_branch? branch_name
+ if ProtectedBranch.protected?(project, branch_name)
false
elsif branch_name == project.repository.root_ref
false
@@ -29,4 +29,8 @@ module BranchesHelper
def project_branches
options_for_select(@project.repository.branch_names, @project.default_branch)
end
+
+ def protected_branch?(project, branch)
+ ProtectedBranch.protected?(project, branch.name)
+ end
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 0b30471f2ae..c85e96cf78d 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -1,23 +1,42 @@
module ButtonHelper
# Output a "Copy to Clipboard" button
#
- # data - Data attributes passed to `content_tag`
+ # data - Data attributes passed to `content_tag` (default: {}):
+ # :text - Text to copy (optional)
+ # :gfm - GitLab Flavored Markdown to copy, if different from `text` (optional)
+ # :target - Selector for target element to copy from (optional)
#
# Examples:
#
# # Define the clipboard's text
- # clipboard_button(clipboard_text: "Foo")
+ # clipboard_button(text: "Foo")
# # => "<button class='...' data-clipboard-text='Foo'>...</button>"
#
# # Define the target element
- # clipboard_button(clipboard_target: "div#foo")
+ # clipboard_button(target: "div#foo")
# # => "<button class='...' data-clipboard-target='div#foo'>...</button>"
#
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
title = data[:title] || 'Copy to clipboard'
+
+ # This supports code in app/assets/javascripts/copy_to_clipboard.js that
+ # works around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM.
+ if text = data.delete(:text)
+ data[:clipboard_text] =
+ if gfm = data.delete(:gfm)
+ { text: text, gfm: gfm }
+ else
+ text
+ end
+ end
+
+ target = data.delete(:target)
+ data[:clipboard_target] = target if target
+
data = { toggle: 'tooltip', placement: 'bottom', container: 'body' }.merge(data)
+
content_tag :button,
icon('clipboard', 'aria-hidden': 'true'),
class: "btn #{css_class}",
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index aed1d7c839f..dc144906548 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -62,19 +62,21 @@ module DiffHelper
end
def parallel_diff_discussions(left, right, diff_file)
- discussion_left = discussion_right = nil
+ return unless @grouped_diff_discussions
+
+ discussions_left = discussions_right = nil
if left && (left.unchanged? || left.removed?)
line_code = diff_file.line_code(left)
- discussion_left = @grouped_diff_discussions[line_code]
+ discussions_left = @grouped_diff_discussions[line_code]
end
if right && right.added?
line_code = diff_file.line_code(right)
- discussion_right = @grouped_diff_discussions[line_code]
+ discussions_right = @grouped_diff_discussions[line_code]
end
- [discussion_left, discussion_right]
+ [discussions_left, discussions_right]
end
def inline_diff_btn
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 81e0b6bb5ae..8ed99642c7a 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -1,6 +1,6 @@
module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block)
- content_tag :div, class: "dropdown" do
+ content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
data_attr = { toggle: "dropdown" }
if options.has_key?(:data)
@@ -20,7 +20,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder])
end
- output << content_tag(:div, class: "dropdown-content") do
+ output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
capture(&block) if block && !options.has_key?(:footer_content)
end
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index fb872a13f74..5f5c76d3722 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -1,4 +1,15 @@
module EventsHelper
+ ICON_NAMES_BY_EVENT_TYPE = {
+ 'pushed to' => 'icon_commit',
+ 'pushed new' => 'icon_commit',
+ 'created' => 'icon_status_open',
+ 'opened' => 'icon_status_open',
+ 'closed' => 'icon_status_closed',
+ 'accepted' => 'icon_code_fork',
+ 'commented on' => 'icon_comment_o',
+ 'deleted' => 'icon_trash_o'
+ }.freeze
+
def link_to_author(event)
author = event.author
@@ -183,4 +194,21 @@ module EventsHelper
"event-inline"
end
end
+
+ def icon_for_event(note)
+ icon_name = ICON_NAMES_BY_EVENT_TYPE[note]
+ custom_icon(icon_name) if icon_name
+ end
+
+ def icon_for_profile_event(event)
+ if current_path?('users#show')
+ content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do
+ icon_for_event(event.action_name)
+ end
+ else
+ content_tag :div, class: 'system-note-image user-avatar' do
+ author_avatar(event, size: 32)
+ end
+ end
+ end
end
diff --git a/app/helpers/javascript_helper.rb b/app/helpers/javascript_helper.rb
index 68c09c922a6..d5e77c7e271 100644
--- a/app/helpers/javascript_helper.rb
+++ b/app/helpers/javascript_helper.rb
@@ -3,7 +3,8 @@ module JavascriptHelper
javascript_include_tag asset_path(js)
end
- def page_specific_javascript_bundle_tag(js)
- javascript_include_tag(*webpack_asset_paths(js))
+ # deprecated; use webpack_bundle_tag directly instead
+ def page_specific_javascript_bundle_tag(bundle)
+ webpack_bundle_tag(bundle)
end
end
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index b0331f36a2f..eab0738a368 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -24,57 +24,24 @@ module NotesHelper
end
def diff_view_data
- return {} unless @comments_target
+ return {} unless @new_diff_note_attrs
- @comments_target.slice(:noteable_id, :noteable_type, :commit_id)
+ @new_diff_note_attrs.slice(:noteable_id, :noteable_type, :commit_id)
end
def diff_view_line_data(line_code, position, line_type)
return if @diff_notes_disabled
- use_legacy_diff_note = @use_legacy_diff_notes
- # If the controller doesn't force the use of legacy diff notes, we
- # determine this on a line-by-line basis by seeing if there already exist
- # active legacy diff notes at this line, in which case newly created notes
- # will use the legacy technology as well.
- # We do this because the discussion_id values of legacy and "new" diff
- # notes, which are used to group notes on the merge request discussion tab,
- # are incompatible.
- # If we didn't, diff notes that would show for the same line on the changes
- # tab, would show in different discussions on the discussion tab.
- use_legacy_diff_note ||= begin
- discussion = @grouped_diff_discussions[line_code]
- discussion && discussion.legacy_diff_discussion?
- end
-
data = {
line_code: line_code,
line_type: line_type,
}
- if use_legacy_diff_note
- discussion_id = LegacyDiffNote.discussion_id(
- @comments_target[:noteable_type],
- @comments_target[:noteable_id] || @comments_target[:commit_id],
- line_code
- )
-
- data.merge!(
- note_type: LegacyDiffNote.name,
- discussion_id: discussion_id
- )
+ if @use_legacy_diff_notes
+ data[:note_type] = LegacyDiffNote.name
else
- discussion_id = DiffNote.discussion_id(
- @comments_target[:noteable_type],
- @comments_target[:noteable_id] || @comments_target[:commit_id],
- position
- )
-
- data.merge!(
- position: position.to_json,
- note_type: DiffNote.name,
- discussion_id: discussion_id
- )
+ data[:note_type] = DiffNote.name
+ data[:position] = position.to_json
end
data
@@ -83,32 +50,34 @@ module NotesHelper
def link_to_reply_discussion(discussion, line_type = nil)
return unless current_user
- data = discussion.reply_attributes.merge(line_type: line_type)
+ data = { discussion_id: discussion.id, line_type: line_type }
button_tag 'Reply...', class: 'btn btn-text-field js-discussion-reply-button',
data: data, title: 'Add a reply'
end
- def preload_max_access_for_authors(notes, project)
- user_ids = notes.map(&:author_id)
- project.team.max_member_access_for_user_ids(user_ids)
- end
-
- def preload_noteable_for_regular_notes(notes)
- ActiveRecord::Associations::Preloader.new.preload(notes.select { |note| !note.for_commit? }, :noteable)
- end
-
def note_max_access_for_user(note)
note.project.team.human_max_access(note.author_id)
end
def discussion_diff_path(discussion)
- return unless discussion.diff_discussion?
-
- if discussion.for_merge_request? && discussion.active?
- diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code)
+ if discussion.for_merge_request? && discussion.diff_discussion?
+ if discussion.active?
+ # Without a diff ID, the link always points to the latest diff version
+ diff_id = nil
+ elsif merge_request_diff = discussion.latest_merge_request_diff
+ diff_id = merge_request_diff.id
+ else
+ # If the discussion is not active, and we cannot find the latest
+ # merge request diff for this discussion, we return no path at all.
+ return
+ end
+
+ diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, diff_id: diff_id, anchor: discussion.line_code)
elsif discussion.for_commit?
- namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code)
+ anchor = discussion.line_code if discussion.diff_discussion?
+
+ namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: anchor)
end
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index 243ef39ef61..de959f13713 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -63,6 +63,10 @@ module PreferencesHelper
end
def anonymous_project_view
- @project.empty_repo? || !can?(current_user, :download_code, @project) ? 'activity' : 'readme'
+ if !@project.empty_repo? && can?(current_user, :download_code, @project)
+ 'files'
+ else
+ 'activity'
+ end
end
end
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
new file mode 100644
index 00000000000..1ea60e39386
--- /dev/null
+++ b/app/helpers/system_note_helper.rb
@@ -0,0 +1,26 @@
+module SystemNoteHelper
+ ICON_NAMES_BY_ACTION = {
+ 'commit' => 'icon_commit',
+ 'merge' => 'icon_merge',
+ 'merged' => 'icon_merged',
+ 'opened' => 'icon_status_open',
+ 'closed' => 'icon_status_closed',
+ 'time_tracking' => 'icon_stopwatch',
+ 'assignee' => 'icon_user',
+ 'title' => 'icon_edit',
+ 'task' => 'icon_check_square_o',
+ 'label' => 'icon_tags',
+ 'cross_reference' => 'icon_random',
+ 'branch' => 'icon_code_fork',
+ 'confidential' => 'icon_eye_slash',
+ 'visible' => 'icon_eye',
+ 'milestone' => 'icon_clock_o',
+ 'discussion' => 'icon_comment_o',
+ 'moved' => 'icon_arrow_circle_o_right'
+ }.freeze
+
+ def icon_for_system_note(note)
+ icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ custom_icon(icon_name) if icon_name
+ end
+end
diff --git a/app/helpers/tags_helper.rb b/app/helpers/tags_helper.rb
index c0ec1634cdb..31aaf9e5607 100644
--- a/app/helpers/tags_helper.rb
+++ b/app/helpers/tags_helper.rb
@@ -21,4 +21,8 @@ module TagsHelper
html.html_safe
end
+
+ def protected_tag?(project, tag)
+ ProtectedTag.protected?(project, tag.name)
+ end
end
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 169cedeb796..b4aaf498068 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -85,7 +85,7 @@ module VisibilityLevelHelper
end
def restricted_visibility_levels(show_all = false)
- return [] if current_user.is_admin? && !show_all
+ return [] if current_user.admin? && !show_all
current_application_settings.restricted_visibility_levels || []
end
diff --git a/app/helpers/webpack_helper.rb b/app/helpers/webpack_helper.rb
new file mode 100644
index 00000000000..6bacda9fe75
--- /dev/null
+++ b/app/helpers/webpack_helper.rb
@@ -0,0 +1,30 @@
+require 'webpack/rails/manifest'
+
+module WebpackHelper
+ def webpack_bundle_tag(bundle)
+ javascript_include_tag(*gitlab_webpack_asset_paths(bundle))
+ end
+
+ # override webpack-rails gem helper until changes can make it upstream
+ def gitlab_webpack_asset_paths(source, extension: nil)
+ return "" unless source.present?
+
+ paths = Webpack::Rails::Manifest.asset_paths(source)
+ if extension
+ paths = paths.select { |p| p.ends_with? ".#{extension}" }
+ end
+
+ # include full webpack-dev-server url for rspec tests running locally
+ if Rails.env.test? && Rails.configuration.webpack.dev_server.enabled
+ host = Rails.configuration.webpack.dev_server.host
+ port = Rails.configuration.webpack.dev_server.port
+ protocol = Rails.configuration.webpack.dev_server.https ? 'https' : 'http'
+
+ paths.map! do |p|
+ "#{protocol}://#{host}:#{port}#{p}"
+ end
+ end
+
+ paths
+ end
+end
diff --git a/app/mailers/emails/notes.rb b/app/mailers/emails/notes.rb
index 46fa6fd9f6d..00707a0023e 100644
--- a/app/mailers/emails/notes.rb
+++ b/app/mailers/emails/notes.rb
@@ -4,13 +4,8 @@ module Emails
setup_note_mail(note_id, recipient_id)
@commit = @note.noteable
- @discussion = @note.to_discussion if @note.diff_note?
@target_url = namespace_project_commit_url(*note_target_url_options)
-
- mail_answer_thread(@commit,
- from: sender(@note.author_id),
- to: recipient(recipient_id),
- subject: subject("#{@commit.title} (#{@commit.short_id})"))
+ mail_answer_thread(@commit, note_thread_options(recipient_id))
end
def note_issue_email(recipient_id, note_id)
@@ -25,7 +20,6 @@ module Emails
setup_note_mail(note_id, recipient_id)
@merge_request = @note.noteable
- @discussion = @note.to_discussion if @note.diff_note?
@target_url = namespace_project_merge_request_url(*note_target_url_options)
mail_answer_thread(@merge_request, note_thread_options(recipient_id))
end
@@ -56,15 +50,18 @@ module Emails
{
from: sender(@note.author_id),
to: recipient(recipient_id),
- subject: subject("#{@note.noteable.title} (#{@note.noteable.to_reference})")
+ subject: subject("#{@note.noteable.title} (#{@note.noteable.reference_link_text})")
}
end
def setup_note_mail(note_id, recipient_id)
- @note = Note.find(note_id)
+ # `note_id` is a `Note` when originating in `NotifyPreview`
+ @note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project
- @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
+ if @project && @note.persisted?
+ @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
+ end
end
end
end
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 14df6f8f0a3..f315e38bcaa 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -111,7 +111,7 @@ class Notify < BaseMailer
headers["X-GitLab-#{model.class.name}-ID"] = model.id
headers['X-GitLab-Reply-Key'] = reply_key
- if Gitlab::IncomingEmail.enabled?
+ if Gitlab::IncomingEmail.enabled? && @sent_notification
address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key))
address.display_name = @project.name_with_namespace
@@ -176,6 +176,6 @@ class Notify < BaseMailer
end
headers['List-Unsubscribe'] = list_unsubscribe_methods.map { |e| "<#{e}>" }.join(',')
- @sent_notification_url = unsubscribe_sent_notification_url(@sent_notification)
+ @unsubscribe_url = unsubscribe_sent_notification_url(@sent_notification)
end
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 6937ad3bdd9..6ada6fae4eb 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
UPVOTE_NAME = "thumbsup".freeze
include Participable
+ include GhostUser
belongs_to :awardable, polymorphic: true
belongs_to :user
validates :awardable, :user, presence: true
validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names }
- validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }
+ validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user?
participant :user
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 801d3442803..55872acef51 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -42,12 +42,16 @@ class Blob < SimpleDelegator
size && truncated?
end
+ def extension
+ extname.downcase.delete('.')
+ end
+
def svg?
text? && language && language.name == 'SVG'
end
def pdf?
- name && File.extname(name) == '.pdf'
+ extension == 'pdf'
end
def ipython_notebook?
@@ -55,11 +59,15 @@ class Blob < SimpleDelegator
end
def sketch?
- binary? && extname.downcase.delete('.') == 'sketch'
+ binary? && extension == 'sketch'
end
def stl?
- extname.downcase.delete('.') == 'stl'
+ extension == 'stl'
+ end
+
+ def markup?
+ text? && Gitlab::MarkupHelper.markup?(name)
end
def size_within_svg_limits?
@@ -77,8 +85,10 @@ class Blob < SimpleDelegator
else
'text'
end
- elsif image? || svg?
+ elsif image?
'image'
+ elsif svg?
+ 'svg'
elsif pdf?
'pdf'
elsif ipython_notebook?
@@ -87,8 +97,18 @@ class Blob < SimpleDelegator
'sketch'
elsif stl?
'stl'
+ elsif markup?
+ if only_display_raw?
+ 'too_large'
+ else
+ 'markup'
+ end
elsif text?
- 'text'
+ if only_display_raw?
+ 'too_large'
+ else
+ 'text'
+ end
else
'download'
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index b3acb25b9ce..971ab7cb0ee 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -103,18 +103,13 @@ module Ci
end
def playable?
- project.builds_enabled? && has_commands? &&
- action? && manual?
+ action? && manual?
end
def action?
self.when == 'manual'
end
- def has_commands?
- commands.present?
- end
-
def play(current_user)
Ci::PlayBuildService
.new(project, current_user)
@@ -126,8 +121,7 @@ module Ci
end
def retryable?
- project.builds_enabled? && has_commands? &&
- (success? || failed? || canceled?)
+ success? || failed? || canceled?
end
def retried?
@@ -166,19 +160,6 @@ module Ci
latest_builds.where('stage_idx < ?', stage_idx)
end
- def trace_html(**args)
- trace_with_state(**args)[:html] || ''
- end
-
- def trace_with_state(state: nil, last_lines: nil)
- trace_ansi = trace(last_lines: last_lines)
- if trace_ansi.present?
- Ci::Ansi2html.convert(trace_ansi, state)
- else
- {}
- end
- end
-
def timeout
project.build_timeout
end
@@ -239,136 +220,35 @@ module Ci
end
def update_coverage
- coverage = extract_coverage(trace, coverage_regex)
+ coverage = trace.extract_coverage(coverage_regex)
update_attributes(coverage: coverage) if coverage.present?
end
- def extract_coverage(text, regex)
- return unless regex
-
- matches = text.scan(Regexp.new(regex)).last
- matches = matches.last if matches.is_a?(Array)
- coverage = matches.gsub(/\d+(\.\d+)?/).first
-
- if coverage.present?
- coverage.to_f
- end
- rescue
- # if bad regex or something goes wrong we dont want to interrupt transition
- # so we just silentrly ignore error for now
- end
-
- def has_trace_file?
- File.exist?(path_to_trace) || has_old_trace_file?
+ def trace
+ Gitlab::Ci::Trace.new(self)
end
def has_trace?
- raw_trace.present?
- end
-
- def raw_trace(last_lines: nil)
- if File.exist?(trace_file_path)
- Gitlab::Ci::TraceReader.new(trace_file_path).
- read(last_lines: last_lines)
- else
- # backward compatibility
- read_attribute :trace
- end
- end
-
- ##
- # Deprecated
- #
- # This is a hotfix for CI build data integrity, see #4246
- def has_old_trace_file?
- project.ci_id && File.exist?(old_path_to_trace)
+ trace.exist?
end
- def trace(last_lines: nil)
- hide_secrets(raw_trace(last_lines: last_lines))
- end
-
- def trace_length
- if raw_trace
- raw_trace.bytesize
- else
- 0
- end
+ def trace=(data)
+ raise NotImplementedError
end
- def trace=(trace)
- recreate_trace_dir
- trace = hide_secrets(trace)
- File.write(path_to_trace, trace)
+ def old_trace
+ read_attribute(:trace)
end
- def recreate_trace_dir
- unless Dir.exist?(dir_to_trace)
- FileUtils.mkdir_p(dir_to_trace)
- end
- end
- private :recreate_trace_dir
-
- def append_trace(trace_part, offset)
- recreate_trace_dir
- touch if needs_touch?
-
- trace_part = hide_secrets(trace_part)
-
- File.truncate(path_to_trace, offset) if File.exist?(path_to_trace)
- File.open(path_to_trace, 'ab') do |f|
- f.write(trace_part)
- end
+ def erase_old_trace!
+ write_attribute(:trace, nil)
+ save
end
def needs_touch?
Time.now - updated_at > 15.minutes.to_i
end
- def trace_file_path
- if has_old_trace_file?
- old_path_to_trace
- else
- path_to_trace
- end
- end
-
- def dir_to_trace
- File.join(
- Settings.gitlab_ci.builds_path,
- created_at.utc.strftime("%Y_%m"),
- project.id.to_s
- )
- end
-
- def path_to_trace
- "#{dir_to_trace}/#{id}.log"
- end
-
- ##
- # Deprecated
- #
- # This is a hotfix for CI build data integrity, see #4246
- # Should be removed in 8.4, after CI files migration has been done.
- #
- def old_dir_to_trace
- File.join(
- Settings.gitlab_ci.builds_path,
- created_at.utc.strftime("%Y_%m"),
- project.ci_id.to_s
- )
- end
-
- ##
- # Deprecated
- #
- # This is a hotfix for CI build data integrity, see #4246
- # Should be removed in 8.4, after CI files migration has been done.
- #
- def old_path_to_trace
- "#{old_dir_to_trace}/#{id}.log"
- end
-
##
# Deprecated
#
@@ -550,6 +430,15 @@ module Ci
options[:dependencies]&.empty?
end
+ def hide_secrets(trace)
+ return unless trace
+
+ trace = trace.dup
+ Ci::MaskSecret.mask!(trace, project.runners_token) if project
+ Ci::MaskSecret.mask!(trace, token)
+ trace
+ end
+
private
def update_artifacts_size
@@ -561,7 +450,7 @@ module Ci
end
def erase_trace!
- self.trace = nil
+ trace.erase!
end
def update_erased!(user = nil)
@@ -623,15 +512,6 @@ module Ci
pipeline.config_processor.build_attributes(name)
end
- def hide_secrets(trace)
- return unless trace
-
- trace = trace.dup
- Ci::MaskSecret.mask!(trace, project.runners_token) if project
- Ci::MaskSecret.mask!(trace, token)
- trace
- end
-
def update_project_statistics
return unless project
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 49dec770096..445247f1b41 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -4,14 +4,25 @@ module Ci
include HasStatus
include Importable
include AfterCommitQueue
+ include Presentable
belongs_to :project
belongs_to :user
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
+
+ has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id'
+ has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id'
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id
has_many :builds, foreign_key: :commit_id
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id
+ has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :retryable_builds, -> { latest.failed_or_canceled }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus'
+ has_many :manual_actions, -> { latest.manual_actions }, foreign_key: :commit_id, class_name: 'Ci::Build'
+ has_many :artifacts, -> { latest.with_artifacts_not_expired }, foreign_key: :commit_id, class_name: 'Ci::Build'
+
delegate :id, to: :project, prefix: true
validates :sha, presence: { unless: :importing? }
@@ -20,7 +31,6 @@ module Ci
validate :valid_commit_sha, unless: :importing?
after_create :keep_around_commits, unless: :importing?
- after_create :refresh_build_status_cache
state_machine :status, initial: :created do
event :enqueue do
@@ -65,6 +75,10 @@ module Ci
pipeline.update_duration
end
+ before_transition canceled: any - [:canceled] do |pipeline|
+ pipeline.auto_canceled_by = nil
+ end
+
after_transition [:created, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) }
end
@@ -82,6 +96,8 @@ module Ci
pipeline.run_after_commit do
PipelineHooksWorker.perform_async(id)
+ Ci::ExpirePipelineCacheService.new(project, nil)
+ .execute(pipeline)
end
end
@@ -160,10 +176,6 @@ module Ci
end
end
- def artifacts
- builds.latest.with_artifacts_not_expired.includes(project: [:namespace])
- end
-
def valid_commit_sha
if self.sha == Gitlab::Git::BLANK_SHA
self.errors.add(:sha, " cant be 00000000 (branch removal)")
@@ -200,27 +212,37 @@ module Ci
!tag?
end
- def manual_actions
- builds.latest.manual_actions.includes(project: [:namespace])
- end
-
def stuck?
- builds.pending.includes(:project).any?(&:stuck?)
+ pending_builds.any?(&:stuck?)
end
def retryable?
- builds.latest.failed_or_canceled.any?(&:retryable?)
+ retryable_builds.any?
end
def cancelable?
- statuses.cancelable.any?
+ cancelable_statuses.any?
+ end
+
+ def auto_canceled?
+ canceled? && auto_canceled_by_id?
end
def cancel_running
- Gitlab::OptimisticLocking.retry_lock(
- statuses.cancelable) do |cancelable|
- cancelable.find_each(&:cancel)
+ Gitlab::OptimisticLocking.retry_lock(cancelable_statuses) do |cancelable|
+ cancelable.find_each do |job|
+ yield(job) if block_given?
+ job.cancel
end
+ end
+ end
+
+ def auto_cancel_running(pipeline)
+ update(auto_canceled_by: pipeline)
+
+ cancel_running do |job|
+ job.auto_canceled_by = pipeline
+ end
end
def retry_failed(current_user)
@@ -328,7 +350,6 @@ module Ci
when 'manual' then block
end
end
- refresh_build_status_cache
end
def predefined_variables
@@ -370,10 +391,6 @@ module Ci
.fabricate!
end
- def refresh_build_status_cache
- Ci::PipelineStatus.new(project, sha: sha, status: status).store_in_cache_if_needed
- end
-
private
def pipeline_data
diff --git a/app/models/ci/pipeline_status.rb b/app/models/ci/pipeline_status.rb
deleted file mode 100644
index 048047d0e34..00000000000
--- a/app/models/ci/pipeline_status.rb
+++ /dev/null
@@ -1,86 +0,0 @@
-# This class is not backed by a table in the main database.
-# It loads the latest Pipeline for the HEAD of a repository, and caches that
-# in Redis.
-module Ci
- class PipelineStatus
- attr_accessor :sha, :status, :project, :loaded
-
- delegate :commit, to: :project
-
- def self.load_for_project(project)
- new(project).tap do |status|
- status.load_status
- end
- end
-
- def initialize(project, sha: nil, status: nil)
- @project = project
- @sha = sha
- @status = status
- end
-
- def has_status?
- loaded? && sha.present? && status.present?
- end
-
- def load_status
- return if loaded?
-
- if has_cache?
- load_from_cache
- else
- load_from_commit
- store_in_cache
- end
-
- self.loaded = true
- end
-
- def load_from_commit
- return unless commit
-
- self.sha = commit.sha
- self.status = commit.status
- end
-
- # We only cache the status for the HEAD commit of a project
- # This status is rendered in project lists
- def store_in_cache_if_needed
- return unless sha
- return delete_from_cache unless commit
- store_in_cache if commit.sha == self.sha
- end
-
- def load_from_cache
- Gitlab::Redis.with do |redis|
- self.sha, self.status = redis.hmget(cache_key, :sha, :status)
- end
- end
-
- def store_in_cache
- Gitlab::Redis.with do |redis|
- redis.mapped_hmset(cache_key, { sha: sha, status: status })
- end
- end
-
- def delete_from_cache
- Gitlab::Redis.with do |redis|
- redis.del(cache_key)
- end
- end
-
- def has_cache?
- Gitlab::Redis.with do |redis|
- redis.exists(cache_key)
- end
- end
-
- def loaded?
- self.loaded
- end
-
- def cache_key
- "projects/#{project.id}/build_status"
- end
- end
-end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index cba1d81a861..2f64f70685a 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -7,12 +7,15 @@ module Ci
belongs_to :project
belongs_to :owner, class_name: "User"
- has_many :trigger_requests, dependent: :destroy
+ has_many :trigger_requests
+ has_one :trigger_schedule, dependent: :destroy
validates :token, presence: true, uniqueness: true
before_validation :set_default_values
+ accepts_nested_attributes_for :trigger_schedule
+
def set_default_values
self.token = SecureRandom.hex(15) if self.token.blank?
end
@@ -36,5 +39,9 @@ module Ci
def can_access_project?
self.owner_id.blank? || Ability.allowed?(self.owner, :create_build, project)
end
+
+ def trigger_schedule
+ super || build_trigger_schedule(project: project)
+ end
end
end
diff --git a/app/models/ci/trigger_schedule.rb b/app/models/ci/trigger_schedule.rb
new file mode 100644
index 00000000000..012a18eb439
--- /dev/null
+++ b/app/models/ci/trigger_schedule.rb
@@ -0,0 +1,41 @@
+module Ci
+ class TriggerSchedule < ActiveRecord::Base
+ extend Ci::Model
+ include Importable
+
+ acts_as_paranoid
+
+ belongs_to :project
+ belongs_to :trigger
+
+ validates :trigger, presence: { unless: :importing? }
+ validates :cron, unless: :importing_or_inactive?, cron: true, presence: { unless: :importing_or_inactive? }
+ validates :cron_timezone, cron_timezone: true, presence: { unless: :importing_or_inactive? }
+ validates :ref, presence: { unless: :importing_or_inactive? }
+
+ before_save :set_next_run_at
+
+ scope :active, -> { where(active: true) }
+
+ def importing_or_inactive?
+ importing? || !active?
+ end
+
+ def set_next_run_at
+ self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
+ end
+
+ def schedule_next_run!
+ save! # with set_next_run_at
+ rescue ActiveRecord::RecordInvalid
+ update_attribute(:next_run_at, nil) # update without validation
+ end
+
+ def real_next_run(
+ worker_cron: Settings.cron_jobs['trigger_schedule_worker']['cron'],
+ worker_time_zone: Time.zone.name)
+ Gitlab::Ci::CronParser.new(worker_cron, worker_time_zone)
+ .next_time_from(next_run_at)
+ end
+ end
+end
diff --git a/app/models/commit.rb b/app/models/commit.rb
index ce92cc369ad..8b8b3f00202 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -2,6 +2,7 @@ class Commit
extend ActiveModel::Naming
include ActiveModel::Conversion
+ include Noteable
include Participable
include Mentionable
include Referable
@@ -203,6 +204,10 @@ class Commit
project.notes.for_commit_id(self.id)
end
+ def discussion_notes
+ notes.non_diff_notes
+ end
+
def notes_with_associations
notes.includes(:author)
end
@@ -321,14 +326,13 @@ class Commit
end
def raw_diffs(*args)
- use_gitaly = Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
- deltas_only = args.last.is_a?(Hash) && args.last[:deltas_only]
-
- if use_gitaly && !deltas_only
- Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
- else
- raw.diffs(*args)
- end
+ # NOTE: This feature is intentionally disabled until
+ # https://gitlab.com/gitlab-org/gitaly/issues/178 is resolved
+ # if Gitlab::GitalyClient.feature_enabled?(:commit_raw_diffs)
+ # Gitlab::GitalyClient::Commit.diff_from_parent(self, *args)
+ # else
+ raw.diffs(*args)
+ # end
end
def diffs(diff_options = nil)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 17b322b5ae3..2c4033146bf 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -7,6 +7,7 @@ class CommitStatus < ActiveRecord::Base
belongs_to :project
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :commit_id
+ belongs_to :auto_canceled_by, class_name: 'Ci::Pipeline'
belongs_to :user
delegate :commit, to: :pipeline
@@ -137,6 +138,10 @@ class CommitStatus < ActiveRecord::Base
false
end
+ def auto_canceled?
+ canceled? && auto_canceled_by_id?
+ end
+
# Added in 9.0 to keep backward compatibility for projects exported in 8.17
# and prior.
def gl_project_id
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
new file mode 100644
index 00000000000..8ee42875670
--- /dev/null
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -0,0 +1,49 @@
+# Contains functionality shared between `DiffDiscussion` and `LegacyDiffDiscussion`.
+module DiscussionOnDiff
+ extend ActiveSupport::Concern
+
+ NUMBER_OF_TRUNCATED_DIFF_LINES = 16
+
+ included do
+ delegate :line_code,
+ :original_line_code,
+ :diff_file,
+ :diff_line,
+ :for_line?,
+ :active?,
+
+ to: :first_note
+
+ delegate :file_path,
+ :blob,
+ :highlighted_diff_lines,
+ :diff_lines,
+
+ to: :diff_file,
+ allow_nil: true
+ end
+
+ def diff_discussion?
+ true
+ end
+
+ # Returns an array of at most 16 highlighted lines above a diff note
+ def truncated_diff_lines(highlight: true)
+ lines = highlight ? highlighted_diff_lines : diff_lines
+ prev_lines = []
+
+ lines.each do |line|
+ if line.meta?
+ prev_lines.clear
+ else
+ prev_lines << line
+
+ break if for_line?(line)
+
+ prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
+ end
+ end
+
+ prev_lines
+ end
+end
diff --git a/app/models/concerns/ghost_user.rb b/app/models/concerns/ghost_user.rb
new file mode 100644
index 00000000000..da696127a80
--- /dev/null
+++ b/app/models/concerns/ghost_user.rb
@@ -0,0 +1,7 @@
+module GhostUser
+ extend ActiveSupport::Concern
+
+ def ghost_user?
+ user && user.ghost?
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index 0a1a65da05a..dff7b6e3523 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -68,7 +68,7 @@ module HasStatus
end
scope :created, -> { where(status: 'created') }
- scope :relevant, -> { where.not(status: 'created') }
+ scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) }
scope :running, -> { where(status: 'running') }
scope :pending, -> { where(status: 'pending') }
scope :success, -> { where(status: 'success') }
@@ -76,6 +76,7 @@ module HasStatus
scope :canceled, -> { where(status: 'canceled') }
scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') }
+ scope :created_or_pending, -> { where(status: [:created, :pending]) }
scope :running_or_pending, -> { where(status: [:running, :pending]) }
scope :finished, -> { where(status: [:success, :failed, :canceled]) }
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb
new file mode 100644
index 00000000000..eb9f3423e48
--- /dev/null
+++ b/app/models/concerns/ignorable_column.rb
@@ -0,0 +1,28 @@
+# Module that can be included into a model to make it easier to ignore database
+# columns.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# include IgnorableColumn
+#
+# ignore_column :updated_at
+# end
+#
+module IgnorableColumn
+ extend ActiveSupport::Concern
+
+ module ClassMethods
+ def columns
+ super.reject { |column| ignored_columns.include?(column.name) }
+ end
+
+ def ignored_columns
+ @ignored_columns ||= Set.new
+ end
+
+ def ignore_column(name)
+ ignored_columns << name.to_s
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index b4dded7e27e..3d2258d5e3e 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -292,17 +292,6 @@ module Issuable
self.class.to_ability_name
end
- # Convert this Issuable class name to a format usable by notifications.
- #
- # Examples:
- #
- # issuable.class # => MergeRequest
- # issuable.human_class_name # => "merge request"
-
- def human_class_name
- @human_class_name ||= self.class.name.titleize.downcase
- end
-
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
diff --git a/app/models/concerns/note_on_diff.rb b/app/models/concerns/note_on_diff.rb
index b8dd27a7afe..6c27dd5aa5c 100644
--- a/app/models/concerns/note_on_diff.rb
+++ b/app/models/concerns/note_on_diff.rb
@@ -1,3 +1,4 @@
+# Contains functionality shared between `DiffNote` and `LegacyDiffNote`.
module NoteOnDiff
extend ActiveSupport::Concern
@@ -25,11 +26,17 @@ module NoteOnDiff
raise NotImplementedError
end
- def can_be_award_emoji?
- false
+ def active?(diff_refs = nil)
+ raise NotImplementedError
end
- def to_discussion
- Discussion.new([self])
+ private
+
+ def noteable_diff_refs
+ if noteable.respond_to?(:diff_sha_refs)
+ noteable.diff_sha_refs
+ else
+ noteable.diff_refs
+ end
end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
new file mode 100644
index 00000000000..dd1e6630642
--- /dev/null
+++ b/app/models/concerns/noteable.rb
@@ -0,0 +1,68 @@
+module Noteable
+ # Names of all implementers of `Noteable` that support resolvable notes.
+ RESOLVABLE_TYPES = %w(MergeRequest).freeze
+
+ def base_class_name
+ self.class.base_class.name
+ end
+
+ # Convert this Noteable class name to a format usable by notifications.
+ #
+ # Examples:
+ #
+ # noteable.class # => MergeRequest
+ # noteable.human_class_name # => "merge request"
+ def human_class_name
+ @human_class_name ||= base_class_name.titleize.downcase
+ end
+
+ def supports_resolvable_notes?
+ RESOLVABLE_TYPES.include?(base_class_name)
+ end
+
+ def supports_discussions?
+ DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
+ end
+
+ def discussion_notes
+ notes
+ end
+
+ delegate :find_discussion, to: :discussion_notes
+
+ def discussions
+ @discussions ||= discussion_notes
+ .inc_relations_for_view
+ .discussions(self)
+ end
+
+ def grouped_diff_discussions(*args)
+ # Doesn't use `discussion_notes`, because this may include commit diff notes
+ # besides MR diff notes, that we do no want to display on the MR Changes tab.
+ notes.inc_relations_for_view.grouped_diff_discussions(*args)
+ end
+
+ def resolvable_discussions
+ @resolvable_discussions ||= discussion_notes.resolvable.discussions(self)
+ end
+
+ def discussions_resolvable?
+ resolvable_discussions.any?(&:resolvable?)
+ end
+
+ def discussions_resolved?
+ discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?)
+ end
+
+ def discussions_to_be_resolved?
+ discussions_resolvable? && !discussions_resolved?
+ end
+
+ def discussions_to_be_resolved
+ @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?)
+ end
+
+ def discussions_can_be_resolved_by?(user)
+ discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) }
+ end
+end
diff --git a/app/models/concerns/protected_branch_access.rb b/app/models/concerns/protected_branch_access.rb
index 9dd4d9c6f24..c41b807df8a 100644
--- a/app/models/concerns/protected_branch_access.rb
+++ b/app/models/concerns/protected_branch_access.rb
@@ -2,20 +2,10 @@ module ProtectedBranchAccess
extend ActiveSupport::Concern
included do
- belongs_to :protected_branch
- delegate :project, to: :protected_branch
-
- scope :master, -> { where(access_level: Gitlab::Access::MASTER) }
- scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
- end
+ include ProtectedRefAccess
- def humanize
- self.class.human_access_levels[self.access_level]
- end
-
- def check_access(user)
- return true if user.is_admin?
+ belongs_to :protected_branch
- project.team.max_member_access(user.id) >= access_level
+ delegate :project, to: :protected_branch
end
end
diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb
new file mode 100644
index 00000000000..62eaec2407f
--- /dev/null
+++ b/app/models/concerns/protected_ref.rb
@@ -0,0 +1,42 @@
+module ProtectedRef
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :project
+
+ validates :name, presence: true
+ validates :project, presence: true
+
+ delegate :matching, :matches?, :wildcard?, to: :ref_matcher
+
+ def self.protected_ref_accessible_to?(ref, user, action:)
+ access_levels_for_ref(ref, action: action).any? do |access_level|
+ access_level.check_access(user)
+ end
+ end
+
+ def self.developers_can?(action, ref)
+ access_levels_for_ref(ref, action: action).any? do |access_level|
+ access_level.access_level == Gitlab::Access::DEVELOPER
+ end
+ end
+
+ def self.access_levels_for_ref(ref, action:)
+ self.matching(ref).map(&:"#{action}_access_levels").flatten
+ end
+
+ def self.matching(ref_name, protected_refs: nil)
+ ProtectedRefMatcher.matching(self, ref_name, protected_refs: protected_refs)
+ end
+ end
+
+ def commit
+ project.commit(self.name)
+ end
+
+ private
+
+ def ref_matcher
+ @ref_matcher ||= ProtectedRefMatcher.new(self)
+ end
+end
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
new file mode 100644
index 00000000000..c4f158e569a
--- /dev/null
+++ b/app/models/concerns/protected_ref_access.rb
@@ -0,0 +1,18 @@
+module ProtectedRefAccess
+ extend ActiveSupport::Concern
+
+ included do
+ scope :master, -> { where(access_level: Gitlab::Access::MASTER) }
+ scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) }
+ end
+
+ def humanize
+ self.class.human_access_levels[self.access_level]
+ end
+
+ def check_access(user)
+ return true if user.admin?
+
+ project.team.max_member_access(user.id) >= access_level
+ end
+end
diff --git a/app/models/concerns/protected_tag_access.rb b/app/models/concerns/protected_tag_access.rb
new file mode 100644
index 00000000000..ee65de24dd8
--- /dev/null
+++ b/app/models/concerns/protected_tag_access.rb
@@ -0,0 +1,11 @@
+module ProtectedTagAccess
+ extend ActiveSupport::Concern
+
+ included do
+ include ProtectedRefAccess
+
+ belongs_to :protected_tag
+
+ delegate :project, to: :protected_tag
+ end
+end
diff --git a/app/models/concerns/resolvable_discussion.rb b/app/models/concerns/resolvable_discussion.rb
new file mode 100644
index 00000000000..dd979e7bb17
--- /dev/null
+++ b/app/models/concerns/resolvable_discussion.rb
@@ -0,0 +1,103 @@
+module ResolvableDiscussion
+ extend ActiveSupport::Concern
+
+ included do
+ # A number of properties of this `Discussion`, like `first_note` and `resolvable?`, are memoized.
+ # When this discussion is resolved or unresolved, the values of these properties potentially change.
+ # To make sure all memoized values are reset when this happens, `update` resets all instance variables with names in
+ # `memoized_variables`. If you add a memoized method in `ResolvableDiscussion` or any `Discussion` subclass,
+ # please make sure the instance variable name is added to `memoized_values`, like below.
+ cattr_accessor :memoized_values, instance_accessor: false do
+ []
+ end
+
+ memoized_values.push(
+ :resolvable,
+ :resolved,
+ :first_note,
+ :first_note_to_resolve,
+ :last_resolved_note,
+ :last_note
+ )
+
+ delegate :potentially_resolvable?, to: :first_note
+
+ delegate :resolved_at,
+ :resolved_by,
+
+ to: :last_resolved_note,
+ allow_nil: true
+ end
+
+ def resolvable?
+ return @resolvable if @resolvable.present?
+
+ @resolvable = potentially_resolvable? && notes.any?(&:resolvable?)
+ end
+
+ def resolved?
+ return @resolved if @resolved.present?
+
+ @resolved = resolvable? && notes.none?(&:to_be_resolved?)
+ end
+
+ def first_note
+ @first_note ||= notes.first
+ end
+
+ def first_note_to_resolve
+ return unless resolvable?
+
+ @first_note_to_resolve ||= notes.find(&:to_be_resolved?)
+ end
+
+ def last_resolved_note
+ return unless resolved?
+
+ @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ end
+
+ def resolved_notes
+ notes.select(&:resolved?)
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
+ end
+
+ def can_resolve?(current_user)
+ return false unless current_user
+ return false unless resolvable?
+
+ current_user == self.noteable.author ||
+ current_user.can?(:resolve_note, self.project)
+ end
+
+ def resolve!(current_user)
+ return unless resolvable?
+
+ update { |notes| notes.resolve!(current_user) }
+ end
+
+ def unresolve!
+ return unless resolvable?
+
+ update { |notes| notes.unresolve! }
+ end
+
+ private
+
+ def update
+ # Do not select `Note.resolvable`, so that system notes remain in the collection
+ notes_relation = Note.where(id: notes.map(&:id))
+
+ yield(notes_relation)
+
+ # Set the notes array to the updated notes
+ @notes = notes_relation.fresh.to_a
+
+ self.class.memoized_values.each do |var|
+ instance_variable_set(:"@#{var}", nil)
+ end
+ end
+end
diff --git a/app/models/concerns/resolvable_note.rb b/app/models/concerns/resolvable_note.rb
new file mode 100644
index 00000000000..05eb6f86704
--- /dev/null
+++ b/app/models/concerns/resolvable_note.rb
@@ -0,0 +1,72 @@
+module ResolvableNote
+ extend ActiveSupport::Concern
+
+ # Names of all subclasses of `Note` that can be resolvable.
+ RESOLVABLE_TYPES = %w(DiffNote DiscussionNote).freeze
+
+ included do
+ belongs_to :resolved_by, class_name: "User"
+
+ validates :resolved_by, presence: true, if: :resolved?
+
+ # Keep this scope in sync with `#potentially_resolvable?`
+ scope :potentially_resolvable, -> { where(type: RESOLVABLE_TYPES).where(noteable_type: Noteable::RESOLVABLE_TYPES) }
+ # Keep this scope in sync with `#resolvable?`
+ scope :resolvable, -> { potentially_resolvable.user }
+
+ scope :resolved, -> { resolvable.where.not(resolved_at: nil) }
+ scope :unresolved, -> { resolvable.where(resolved_at: nil) }
+ end
+
+ module ClassMethods
+ # This method must be kept in sync with `#resolve!`
+ def resolve!(current_user)
+ unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id)
+ end
+
+ # This method must be kept in sync with `#unresolve!`
+ def unresolve!
+ resolved.update_all(resolved_at: nil, resolved_by_id: nil)
+ end
+ end
+
+ # Keep this method in sync with the `potentially_resolvable` scope
+ def potentially_resolvable?
+ RESOLVABLE_TYPES.include?(self.class.name) && noteable.supports_resolvable_notes?
+ end
+
+ # Keep this method in sync with the `resolvable` scope
+ def resolvable?
+ potentially_resolvable? && !system?
+ end
+
+ def resolved?
+ return false unless resolvable?
+
+ self.resolved_at.present?
+ end
+
+ def to_be_resolved?
+ resolvable? && !resolved?
+ end
+
+ # If you update this method remember to also update `.resolve!`
+ def resolve!(current_user)
+ return unless resolvable?
+ return if resolved?
+
+ self.resolved_at = Time.now
+ self.resolved_by = current_user
+ save!
+ end
+
+ # If you update this method remember to also update `.unresolve!`
+ def unresolve!
+ return unless resolvable?
+ return unless resolved?
+
+ self.resolved_at = nil
+ self.resolved_by = nil
+ save!
+ end
+end
diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb
index 529fb5ce988..aca99feee53 100644
--- a/app/models/concerns/routable.rb
+++ b/app/models/concerns/routable.rb
@@ -83,6 +83,74 @@ module Routable
AND members.source_type = r2.source_type").
where('members.user_id = ?', user_id)
end
+
+ # Builds a relation to find multiple objects that are nested under user
+ # membership. Includes the parent, as opposed to `#member_descendants`
+ # which only includes the descendants.
+ #
+ # Usage:
+ #
+ # Klass.member_self_and_descendants(1)
+ #
+ # Returns an ActiveRecord::Relation.
+ def member_self_and_descendants(user_id)
+ joins(:route).
+ joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
+ OR routes.path = r2.path
+ INNER JOIN members ON members.source_id = r2.source_id
+ AND members.source_type = r2.source_type").
+ where('members.user_id = ?', user_id)
+ end
+
+ # Returns all objects in a hierarchy, where any node in the hierarchy is
+ # under the user membership.
+ #
+ # Usage:
+ #
+ # Klass.member_hierarchy(1)
+ #
+ # Examples:
+ #
+ # Given the following group tree...
+ #
+ # _______group_1_______
+ # | |
+ # | |
+ # nested_group_1 nested_group_2
+ # | |
+ # | |
+ # nested_group_1_1 nested_group_2_1
+ #
+ #
+ # ... the following results are returned:
+ #
+ # * the user is a member of group 1
+ # => 'group_1',
+ # 'nested_group_1', nested_group_1_1',
+ # 'nested_group_2', 'nested_group_2_1'
+ #
+ # * the user is a member of nested_group_2
+ # => 'group1',
+ # 'nested_group_2', 'nested_group_2_1'
+ #
+ # * the user is a member of nested_group_2_1
+ # => 'group1',
+ # 'nested_group_2', 'nested_group_2_1'
+ #
+ # Returns an ActiveRecord::Relation.
+ def member_hierarchy(user_id)
+ paths = member_self_and_descendants(user_id).pluck('routes.path')
+
+ return none if paths.empty?
+
+ wheres = paths.map do |path|
+ "#{connection.quote(path)} = routes.path
+ OR
+ #{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
+ end
+
+ joins(:route).where(wheres.join(' OR '))
+ end
end
def full_name
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
new file mode 100644
index 00000000000..82f4182d59a
--- /dev/null
+++ b/app/models/container_repository.rb
@@ -0,0 +1,81 @@
+class ContainerRepository < ActiveRecord::Base
+ belongs_to :project
+
+ validates :name, length: { minimum: 0, allow_nil: false }
+ validates :name, uniqueness: { scope: :project_id }
+
+ delegate :client, to: :registry
+
+ before_destroy :delete_tags!
+
+ def registry
+ @registry ||= begin
+ token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
+
+ url = Gitlab.config.registry.api_url
+ host_port = Gitlab.config.registry.host_port
+
+ ContainerRegistry::Registry.new(url, token: token, path: host_port)
+ end
+ end
+
+ def path
+ @path ||= [project.full_path, name].select(&:present?).join('/')
+ end
+
+ def location
+ File.join(registry.path, path)
+ end
+
+ def tag(tag)
+ ContainerRegistry::Tag.new(self, tag)
+ end
+
+ def manifest
+ @manifest ||= client.repository_tags(path)
+ end
+
+ def tags
+ return @tags if defined?(@tags)
+ return [] unless manifest && manifest['tags']
+
+ @tags = manifest['tags'].map do |tag|
+ ContainerRegistry::Tag.new(self, tag)
+ end
+ end
+
+ def blob(config)
+ ContainerRegistry::Blob.new(self, config)
+ end
+
+ def has_tags?
+ tags.any?
+ end
+
+ def root_repository?
+ name.empty?
+ end
+
+ def delete_tags!
+ return unless has_tags?
+
+ digests = tags.map { |tag| tag.digest }.to_set
+
+ digests.all? do |digest|
+ client.delete_repository_tag(self.path, digest)
+ end
+ end
+
+ def self.build_from_path(path)
+ self.new(project: path.repository_project,
+ name: path.repository_name)
+ end
+
+ def self.create_from_path!(path)
+ build_from_path(path).tap(&:save!)
+ end
+
+ def self.build_root_repository(project)
+ self.new(project: project, name: '')
+ end
+end
diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb
new file mode 100644
index 00000000000..6a6466b493b
--- /dev/null
+++ b/app/models/diff_discussion.rb
@@ -0,0 +1,27 @@
+# A discussion on merge request or commit diffs consisting of `DiffNote` notes.
+#
+# A discussion of this type can be resolvable.
+class DiffDiscussion < Discussion
+ include DiscussionOnDiff
+
+ def self.note_class
+ DiffNote
+ end
+
+ delegate :position,
+ :original_position,
+ :latest_merge_request_diff,
+
+ to: :first_note
+
+ def legacy_diff_discussion?
+ false
+ end
+
+ def reply_attributes
+ super.merge(
+ original_position: original_position.to_json,
+ position: position.to_json,
+ )
+ end
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 895a91139c9..abe4518d62a 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -1,6 +1,11 @@
+# A note on merge request or commit diffs
+#
+# A note of this type can be resolvable.
class DiffNote < Note
include NoteOnDiff
+ NOTEABLE_TYPES = %w(MergeRequest Commit).freeze
+
serialize :original_position, Gitlab::Diff::Position
serialize :position, Gitlab::Diff::Position
@@ -8,59 +13,31 @@ class DiffNote < Note
validates :position, presence: true
validates :diff_line, presence: true
validates :line_code, presence: true, line_code: true
- validates :noteable_type, inclusion: { in: %w(Commit MergeRequest) }
- validates :resolved_by, presence: true, if: :resolved?
+ validates :noteable_type, inclusion: { in: NOTEABLE_TYPES }
validate :positions_complete
validate :verify_supported
- # Keep this scope in sync with the logic in `#resolvable?`
- scope :resolvable, -> { user.where(noteable_type: 'MergeRequest') }
- scope :resolved, -> { resolvable.where.not(resolved_at: nil) }
- scope :unresolved, -> { resolvable.where(resolved_at: nil) }
-
- after_initialize :ensure_original_discussion_id
before_validation :set_original_position, :update_position, on: :create
- before_validation :set_line_code, :set_original_discussion_id
- # We need to do this again, because it's already in `Note`, but is affected by
- # `update_position` and needs to run after that.
- before_validation :set_discussion_id
+ before_validation :set_line_code
after_save :keep_around_commits
- class << self
- def build_discussion_id(noteable_type, noteable_id, position)
- [super(noteable_type, noteable_id), *position.key].join("-")
- end
-
- # This method must be kept in sync with `#resolve!`
- def resolve!(current_user)
- unresolved.update_all(resolved_at: Time.now, resolved_by_id: current_user.id)
- end
-
- # This method must be kept in sync with `#unresolve!`
- def unresolve!
- resolved.update_all(resolved_at: nil, resolved_by_id: nil)
- end
- end
-
- def new_diff_note?
- true
+ def discussion_class(*)
+ DiffDiscussion
end
- def diff_attributes
- { position: position.to_json }
- end
+ %i(original_position position).each do |meth|
+ define_method "#{meth}=" do |new_position|
+ if new_position.is_a?(String)
+ new_position = JSON.parse(new_position) rescue nil
+ end
- def position=(new_position)
- if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
- end
+ if new_position.is_a?(Hash)
+ new_position = new_position.with_indifferent_access
+ new_position = Gitlab::Diff::Position.new(new_position)
+ end
- if new_position.is_a?(Hash)
- new_position = new_position.with_indifferent_access
- new_position = Gitlab::Diff::Position.new(new_position)
+ super(new_position)
end
-
- super(new_position)
end
def diff_file
@@ -88,41 +65,10 @@ class DiffNote < Note
self.position.diff_refs == diff_refs
end
- # If you update this method remember to also update the scope `resolvable`
- def resolvable?
- !system? && for_merge_request?
- end
-
- def resolved?
- return false unless resolvable?
+ def latest_merge_request_diff
+ return unless for_merge_request?
- self.resolved_at.present?
- end
-
- # If you update this method remember to also update `.resolve!`
- def resolve!(current_user)
- return unless resolvable?
- return if resolved?
-
- self.resolved_at = Time.now
- self.resolved_by = current_user
- save!
- end
-
- # If you update this method remember to also update `.unresolve!`
- def unresolve!
- return unless resolvable?
- return unless resolved?
-
- self.resolved_at = nil
- self.resolved_by = nil
- save!
- end
-
- def discussion
- return unless resolvable?
-
- self.noteable.find_diff_discussion(self.discussion_id)
+ self.noteable.merge_request_diff_for(self.position.diff_refs)
end
private
@@ -131,42 +77,14 @@ class DiffNote < Note
for_commit? || self.noteable.has_complete_diff_refs?
end
- def noteable_diff_refs
- if noteable.respond_to?(:diff_sha_refs)
- noteable.diff_sha_refs
- else
- noteable.diff_refs
- end
- end
-
def set_original_position
- self.original_position = self.position.dup
+ self.original_position = self.position.dup unless self.original_position&.complete?
end
def set_line_code
self.line_code = self.position.line_code(self.project.repository)
end
- def ensure_original_discussion_id
- return unless self.persisted?
- return if self.original_discussion_id
-
- set_original_discussion_id
- update_column(:original_discussion_id, self.original_discussion_id)
- end
-
- def set_original_discussion_id
- self.original_discussion_id = Digest::SHA1.hexdigest(build_original_discussion_id)
- end
-
- def build_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, position)
- end
-
- def build_original_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, original_position)
- end
-
def update_position
return unless supported?
return if for_commit?
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index bbe813db823..0b6b920ed66 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -1,7 +1,10 @@
+# A non-diff discussion on an issue, merge request, commit, or snippet, consisting of `DiscussionNote` notes.
+#
+# A discussion of this type can be resolvable.
class Discussion
- NUMBER_OF_TRUNCATED_DIFF_LINES = 16
+ include ResolvableDiscussion
- attr_reader :notes
+ attr_reader :notes, :context_noteable
delegate :created_at,
:project,
@@ -11,43 +14,62 @@ class Discussion
:for_commit?,
:for_merge_request?,
- :line_code,
- :original_line_code,
- :diff_file,
- :for_line?,
- :active?,
-
to: :first_note
- delegate :resolved_at,
- :resolved_by,
+ def self.build(notes, context_noteable = nil)
+ notes.first.discussion_class(context_noteable).new(notes, context_noteable)
+ end
- to: :last_resolved_note,
- allow_nil: true
+ def self.build_collection(notes, context_noteable = nil)
+ notes.group_by { |n| n.discussion_id(context_noteable) }.values.map { |notes| build(notes, context_noteable) }
+ end
- delegate :blob,
- :highlighted_diff_lines,
- :diff_lines,
+ # Returns an alphanumeric discussion ID based on `build_discussion_id`
+ def self.discussion_id(note)
+ Digest::SHA1.hexdigest(build_discussion_id(note).join("-"))
+ end
- to: :diff_file,
- allow_nil: true
+ # Returns an array of discussion ID components
+ def self.build_discussion_id(note)
+ [*base_discussion_id(note), SecureRandom.hex]
+ end
- def self.for_notes(notes)
- notes.group_by(&:discussion_id).values.map { |notes| new(notes) }
+ def self.base_discussion_id(note)
+ noteable_id = note.noteable_id || note.commit_id
+ [:discussion, note.noteable_type.try(:underscore), noteable_id]
end
- def self.for_diff_notes(notes)
- notes.group_by(&:line_code).values.map { |notes| new(notes) }
+ # When notes on a commit are displayed in context of a merge request that contains that commit,
+ # these notes are to be displayed as if they were part of one discussion, even though they were actually
+ # individual notes on the commit with different discussion IDs, so that it's clear that these are not
+ # notes on the merge request itself.
+ #
+ # To turn a list of notes into a list of discussions, they are grouped by discussion ID, so to
+ # get these out-of-context notes to end up in the same discussion, we need to get them to return the same
+ # `discussion_id` when this grouping happens. To enable this, `Note#discussion_id` calls out
+ # to the `override_discussion_id` method on the appropriate `Discussion` subclass, as determined by
+ # the `discussion_class` method on `Note` or a subclass of `Note`.
+ #
+ # If no override is necessary, return `nil`.
+ # For the case described above, see `OutOfContextDiscussion.override_discussion_id`.
+ def self.override_discussion_id(note)
+ nil
end
- def initialize(notes)
- @notes = notes
+ def self.note_class
+ DiscussionNote
end
- def last_resolved_note
- return unless resolved?
+ def initialize(notes, context_noteable = nil)
+ @notes = notes
+ @context_noteable = context_noteable
+ end
- @last_resolved_note ||= resolved_notes.sort_by(&:resolved_at).last
+ def ==(other)
+ other.class == self.class &&
+ other.context_noteable == self.context_noteable &&
+ other.id == self.id &&
+ other.notes == self.notes
end
def last_updated_at
@@ -59,91 +81,29 @@ class Discussion
end
def id
- first_note.discussion_id
+ first_note.discussion_id(context_noteable)
end
alias_method :to_param, :id
def diff_discussion?
- first_note.diff_note?
- end
-
- def legacy_diff_discussion?
- notes.any?(&:legacy_diff_note?)
+ false
end
- def resolvable?
- return @resolvable if @resolvable.present?
-
- @resolvable = diff_discussion? && notes.any?(&:resolvable?)
+ def individual_note?
+ false
end
- def resolved?
- return @resolved if @resolved.present?
-
- @resolved = resolvable? && notes.none?(&:to_be_resolved?)
- end
-
- def first_note
- @first_note ||= @notes.first
- end
-
- def first_note_to_resolve
- @first_note_to_resolve ||= notes.detect(&:to_be_resolved?)
+ def new_discussion?
+ notes.length == 1
end
def last_note
- @last_note ||= @notes.last
- end
-
- def resolved_notes
- notes.select(&:resolved?)
- end
-
- def to_be_resolved?
- resolvable? && !resolved?
- end
-
- def can_resolve?(current_user)
- return false unless current_user
- return false unless resolvable?
-
- current_user == self.noteable.author ||
- current_user.can?(:resolve_note, self.project)
- end
-
- def resolve!(current_user)
- return unless resolvable?
-
- update { |notes| notes.resolve!(current_user) }
- end
-
- def unresolve!
- return unless resolvable?
-
- update { |notes| notes.unresolve! }
- end
-
- def for_target?(target)
- self.noteable == target && !diff_discussion?
- end
-
- def active?
- return @active if @active.present?
-
- @active = first_note.active?
+ @last_note ||= notes.last
end
def collapsed?
- return false unless diff_discussion?
-
- if resolvable?
- # New diff discussions only disappear once they are marked resolved
- resolved?
- else
- # Old diff discussions disappear once they become outdated
- !active?
- end
+ resolved?
end
def expanded?
@@ -151,52 +111,6 @@ class Discussion
end
def reply_attributes
- data = {
- noteable_type: first_note.noteable_type,
- noteable_id: first_note.noteable_id,
- commit_id: first_note.commit_id,
- discussion_id: self.id,
- }
-
- if diff_discussion?
- data[:note_type] = first_note.type
-
- data.merge!(first_note.diff_attributes)
- end
-
- data
- end
-
- # Returns an array of at most 16 highlighted lines above a diff note
- def truncated_diff_lines(highlight: true)
- lines = highlight ? highlighted_diff_lines : diff_lines
- prev_lines = []
-
- lines.each do |line|
- if line.meta?
- prev_lines.clear
- else
- prev_lines << line
-
- break if for_line?(line)
-
- prev_lines.shift if prev_lines.length >= NUMBER_OF_TRUNCATED_DIFF_LINES
- end
- end
-
- prev_lines
- end
-
- private
-
- def update
- notes_relation = DiffNote.where(id: notes.map(&:id)).fresh
- yield(notes_relation)
-
- # Set the notes array to the updated notes
- @notes = notes_relation.to_a
-
- # Reset the memoized values
- @last_resolved_note = @resolvable = @resolved = @first_note = @last_note = nil
+ first_note.slice(:type, :noteable_type, :noteable_id, :commit_id, :discussion_id)
end
end
diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb
new file mode 100644
index 00000000000..e660b024083
--- /dev/null
+++ b/app/models/discussion_note.rb
@@ -0,0 +1,13 @@
+# A note in a non-diff discussion on an issue, merge request, commit, or snippet.
+#
+# A note of this type can be resolvable.
+class DiscussionNote < Note
+ # Names of all implementers of `Noteable` that support discussions.
+ NOTEABLE_TYPES = %w(MergeRequest Issue Commit Snippet).freeze
+
+ validates :noteable_type, inclusion: { in: NOTEABLE_TYPES }
+
+ def discussion_class(*)
+ Discussion
+ end
+end
diff --git a/app/models/group.rb b/app/models/group.rb
index 60274386103..106084175ff 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -27,11 +27,14 @@ class Group < Namespace
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
+ validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
+
mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy
after_create :post_create_hook
after_destroy :post_destroy_hook
+ after_save :update_two_factor_requirement
class << self
# Searches for groups matching the given query.
@@ -223,4 +226,12 @@ class Group < Namespace
type: public? ? 'O' : 'I' # Open vs Invite-only
}
end
+
+ protected
+
+ def update_two_factor_requirement
+ return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
+
+ users.find_each(&:update_two_factor_requirement)
+ end
end
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
new file mode 100644
index 00000000000..c3f21c55240
--- /dev/null
+++ b/app/models/individual_note_discussion.rb
@@ -0,0 +1,13 @@
+# A discussion to wrap a single `Note` note on the root of an issue, merge request,
+# commit, or snippet, that is not displayed as a discussion.
+#
+# A discussion of this type is never resolvable.
+class IndividualNoteDiscussion < Discussion
+ def self.note_class
+ Note
+ end
+
+ def individual_note?
+ true
+ end
+end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index f9704b0d754..d39ae3a6c92 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord'
class Issue < ActiveRecord::Base
include InternalId
include Issuable
+ include Noteable
include Referable
include Sortable
include Spammable
@@ -25,8 +26,6 @@ class Issue < ActiveRecord::Base
validates :project, presence: true
- scope :cared, ->(user) { where(assignee_id: user) }
- scope :open_for, ->(user) { opened.assigned_to(user) }
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :without_due_date, -> { where(due_date: nil) }
diff --git a/app/models/label.rb b/app/models/label.rb
index 568fa6d44f5..d8b0e250732 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -21,6 +21,8 @@ class Label < ActiveRecord::Base
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
+ before_validation :strip_whitespace_from_title_and_color
+
validates :color, color: true, allow_blank: false
# Don't allow ',' for label titles
@@ -193,4 +195,8 @@ class Label < ActiveRecord::Base
def sanitize_title(value)
CGI.unescapeHTML(Sanitize.clean(value.to_s))
end
+
+ def strip_whitespace_from_title_and_color
+ %w(color title).each { |attr| self[attr] = self[attr]&.strip }
+ end
end
diff --git a/app/models/legacy_diff_discussion.rb b/app/models/legacy_diff_discussion.rb
new file mode 100644
index 00000000000..e617ce36f56
--- /dev/null
+++ b/app/models/legacy_diff_discussion.rb
@@ -0,0 +1,33 @@
+# A discussion on merge request or commit diffs consisting of `LegacyDiffNote` notes.
+#
+# All new diff discussions are of the type `DiffDiscussion`, but any diff discussions created
+# before the introduction of the new implementation still use `LegacyDiffDiscussion`.
+#
+# A discussion of this type is never resolvable.
+class LegacyDiffDiscussion < Discussion
+ include DiscussionOnDiff
+
+ memoized_values << :active
+
+ def legacy_diff_discussion?
+ true
+ end
+
+ def self.note_class
+ LegacyDiffNote
+ end
+
+ def active?(*args)
+ return @active if @active.present?
+
+ @active = first_note.active?(*args)
+ end
+
+ def collapsed?
+ !active?
+ end
+
+ def reply_attributes
+ super.merge(line_code: line_code)
+ end
+end
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 40277a9b139..d7c627432d2 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -1,3 +1,9 @@
+# A note on merge request or commit diffs, using the legacy implementation.
+#
+# All new diff notes are of the type `DiffNote`, but any diff notes created
+# before the introduction of the new implementation still use `LegacyDiffNote`.
+#
+# A note of this type is never resolvable.
class LegacyDiffNote < Note
include NoteOnDiff
@@ -7,18 +13,8 @@ class LegacyDiffNote < Note
before_create :set_diff
- class << self
- def build_discussion_id(noteable_type, noteable_id, line_code)
- [super(noteable_type, noteable_id), line_code].join("-")
- end
- end
-
- def legacy_diff_note?
- true
- end
-
- def diff_attributes
- { line_code: line_code }
+ def discussion_class(*)
+ LegacyDiffDiscussion
end
def project_repository
@@ -60,11 +56,12 @@ class LegacyDiffNote < Note
#
# If the note's current diff cannot be matched in the MergeRequest's current
# diff, it's considered inactive.
- def active?
+ def active?(diff_refs = nil)
return @active if defined?(@active)
return true if for_commit?
return true unless diff_line
return false unless noteable
+ return false if diff_refs && diff_refs != noteable_diff_refs
noteable_diff = find_noteable_diff
@@ -119,8 +116,4 @@ class LegacyDiffNote < Note
diffs = noteable.raw_diffs(Commit.max_diff_options)
diffs.find { |d| d.new_path == self.diff.new_path }
end
-
- def build_discussion_id
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
- end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index 446f9f8f8a7..483425cd30f 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -3,11 +3,16 @@ class GroupMember < Member
belongs_to :group, foreign_key: 'source_id'
+ delegate :update_two_factor_requirement, to: :user
+
# Make sure group member points only to group as it source
default_value_for :source_type, SOURCE_TYPE
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
+ after_create :update_two_factor_requirement, unless: :invite?
+ after_destroy :update_two_factor_requirement, unless: :invite?
+
def self.access_level_roles
Gitlab::Access.options_with_owner
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 8d740adb771..1d4827375d7 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,6 +1,7 @@
class MergeRequest < ActiveRecord::Base
include InternalId
include Issuable
+ include Noteable
include Referable
include Sortable
@@ -103,7 +104,6 @@ class MergeRequest < ActiveRecord::Base
scope :by_source_or_target_branch, ->(branch_name) do
where("source_branch = :branch OR target_branch = :branch", branch: branch_name)
end
- scope :cared, ->(user) { where('assignee_id = :user OR author_id = :user', user: user.id) }
scope :by_milestone, ->(milestone) { where(milestone_id: milestone) }
scope :of_projects, ->(ids) { where(target_project_id: ids) }
scope :from_project, ->(project) { where(source_project_id: project.id) }
@@ -366,6 +366,14 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff(true)
end
+ def merge_request_diff_for(diff_refs)
+ @merge_request_diffs_by_diff_refs ||= Hash.new do |h, diff_refs|
+ h[diff_refs] = merge_request_diffs.viewable.select_without_diff.find_by_diff_refs(diff_refs)
+ end
+
+ @merge_request_diffs_by_diff_refs[diff_refs]
+ end
+
def reload_diff_if_branch_changed
if source_branch_changed? || target_branch_changed?
reload_diff
@@ -442,7 +450,7 @@ class MergeRequest < ActiveRecord::Base
end
def can_remove_source_branch?(current_user)
- !source_project.protected_branch?(source_branch) &&
+ !ProtectedBranch.protected?(source_project, source_branch) &&
!source_project.root_ref?(source_branch) &&
Ability.allowed?(current_user, :push_code, source_project) &&
diff_head_commit == source_branch_head
@@ -475,43 +483,7 @@ class MergeRequest < ActiveRecord::Base
)
end
- def discussions
- @discussions ||= self.related_notes.
- inc_relations_for_view.
- fresh.
- discussions
- end
-
- def diff_discussions
- @diff_discussions ||= self.notes.diff_notes.discussions
- end
-
- def resolvable_discussions
- @resolvable_discussions ||= diff_discussions.select(&:to_be_resolved?)
- end
-
- def discussions_can_be_resolved_by?(user)
- resolvable_discussions.all? { |discussion| discussion.can_resolve?(user) }
- end
-
- def find_diff_discussion(discussion_id)
- notes = self.notes.diff_notes.where(discussion_id: discussion_id).fresh.to_a
- return if notes.empty?
-
- Discussion.new(notes)
- end
-
- def discussions_resolvable?
- diff_discussions.any?(&:resolvable?)
- end
-
- def discussions_resolved?
- discussions_resolvable? && diff_discussions.none?(&:to_be_resolved?)
- end
-
- def discussions_to_be_resolved?
- discussions_resolvable? && !discussions_resolved?
- end
+ alias_method :discussion_notes, :related_notes
def mergeable_discussions_state?
return true unless project.only_allow_merge_if_all_discussions_are_resolved?
@@ -857,8 +829,8 @@ class MergeRequest < ActiveRecord::Base
return unless has_complete_diff_refs?
return if new_diff_refs == old_diff_refs
- active_diff_notes = self.notes.diff_notes.select do |note|
- note.new_diff_note? && note.active?(old_diff_refs)
+ active_diff_notes = self.notes.new_diff_notes.select do |note|
+ note.active?(old_diff_refs)
end
return if active_diff_notes.empty?
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 6ad56b842b2..6604af2b47e 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -31,6 +31,10 @@ class MergeRequestDiff < ActiveRecord::Base
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ def self.find_by_diff_refs(diff_refs)
+ find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
+ end
+
def self.select_without_diff
select(column_names - ['st_diffs'])
end
@@ -130,6 +134,12 @@ class MergeRequestDiff < ActiveRecord::Base
st_commits.map { |commit| commit[:id] }
end
+ def diff_refs=(new_diff_refs)
+ self.base_commit_sha = new_diff_refs&.base_sha
+ self.start_commit_sha = new_diff_refs&.start_sha
+ self.head_commit_sha = new_diff_refs&.head_sha
+ end
+
def diff_refs
return unless start_commit_sha || base_commit_sha
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index ac205b9b738..652b1551928 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -153,10 +153,6 @@ class Milestone < ActiveRecord::Base
active? && issues.opened.count.zero?
end
- def is_empty?(user = nil)
- total_items_count(user).zero?
- end
-
def author_id
nil
end
diff --git a/app/models/note.rb b/app/models/note.rb
index 16d66cb1427..630d0adbece 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -1,3 +1,6 @@
+# A note on the root of an issue, merge request, commit, or snippet.
+#
+# A note of this type is never resolvable.
class Note < ActiveRecord::Base
extend ActiveModel::Naming
include Gitlab::CurrentSettings
@@ -8,6 +11,10 @@ class Note < ActiveRecord::Base
include FasterCacheKeys
include CacheMarkdownField
include AfterCommitQueue
+ include ResolvableNote
+ include IgnorableColumn
+
+ ignore_column :original_discussion_id
cache_markdown_field :note, pipeline: :note
@@ -32,9 +39,6 @@ class Note < ActiveRecord::Base
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
- # Only used by DiffNote, but defined here so that it can be used in `Note.includes`
- belongs_to :resolved_by, class_name: "User"
-
has_many :todos, dependent: :destroy
has_many :events, as: :target, dependent: :destroy
has_one :system_note_metadata
@@ -54,10 +58,11 @@ class Note < ActiveRecord::Base
validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
validates :commit_id, presence: true, if: :for_commit?
validates :author, presence: true
+ validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
unless note.noteable.try(:project) == note.project
- errors.add(:invalid_project, 'Note and noteable project mismatch')
+ errors.add(:project, 'does not match noteable project')
end
end
@@ -69,6 +74,7 @@ class Note < ActiveRecord::Base
scope :user, ->{ where(system: false) }
scope :common, ->{ where(noteable_type: ["", nil]) }
scope :fresh, ->{ order(created_at: :asc, id: :asc) }
+ scope :updated_after, ->(time){ where('updated_at > ?', time) }
scope :inc_author_project, ->{ includes(:project, :author) }
scope :inc_author, ->{ includes(:author) }
scope :inc_relations_for_view, -> do
@@ -76,7 +82,8 @@ class Note < ActiveRecord::Base
end
scope :diff_notes, ->{ where(type: %w(LegacyDiffNote DiffNote)) }
- scope :non_diff_notes, ->{ where(type: ['Note', nil]) }
+ scope :new_diff_notes, ->{ where(type: 'DiffNote') }
+ scope :non_diff_notes, ->{ where(type: ['Note', 'DiscussionNote', nil]) }
scope :with_associations, -> do
# FYI noteable cannot be loaded for LegacyDiffNote for commits
@@ -86,31 +93,33 @@ class Note < ActiveRecord::Base
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
- before_validation :set_discussion_id
+ before_validation :set_discussion_id, on: :create
after_save :keep_around_commit, unless: :for_personal_snippet?
after_save :expire_etag_cache
+ after_destroy :expire_etag_cache
class << self
def model_name
ActiveModel::Name.new(self, nil, 'note')
end
- def build_discussion_id(noteable_type, noteable_id)
- [:discussion, noteable_type.try(:underscore), noteable_id].join("-")
+ def discussions(context_noteable = nil)
+ Discussion.build_collection(fresh, context_noteable)
end
- def discussion_id(*args)
- Digest::SHA1.hexdigest(build_discussion_id(*args))
- end
+ def find_discussion(discussion_id)
+ notes = where(discussion_id: discussion_id).fresh.to_a
+ return if notes.empty?
- def discussions
- Discussion.for_notes(fresh)
+ Discussion.build(notes)
end
- def grouped_diff_discussions
- active_notes = diff_notes.fresh.select(&:active?)
- Discussion.for_diff_notes(active_notes).
- map { |d| [d.line_code, d] }.to_h
+ def grouped_diff_discussions(diff_refs = nil)
+ diff_notes.
+ fresh.
+ discussions.
+ select { |n| n.active?(diff_refs) }.
+ group_by(&:line_code)
end
def count_for_collection(ids, type)
@@ -121,35 +130,19 @@ class Note < ActiveRecord::Base
end
def cross_reference?
- system && SystemNoteService.cross_reference?(note)
+ system? && SystemNoteService.cross_reference?(note)
end
def diff_note?
false
end
- def legacy_diff_note?
- false
- end
-
- def new_diff_note?
- false
- end
-
def active?
true
end
- def resolvable?
- false
- end
-
- def resolved?
- false
- end
-
- def to_be_resolved?
- resolvable? && !resolved?
+ def latest_merge_request_diff
+ nil
end
def max_attachment_size
@@ -228,7 +221,7 @@ class Note < ActiveRecord::Base
end
def can_be_award_emoji?
- noteable.is_a?(Awardable)
+ noteable.is_a?(Awardable) && !part_of_discussion?
end
def contains_emoji_only?
@@ -239,6 +232,63 @@ class Note < ActiveRecord::Base
for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
end
+ def can_be_discussion_note?
+ self.noteable.supports_discussions? && !part_of_discussion?
+ end
+
+ def discussion_class(noteable = nil)
+ # When commit notes are rendered on an MR's Discussion page, they are
+ # displayed in one discussion instead of individually.
+ # See also `#discussion_id` and `Discussion.override_discussion_id`.
+ if noteable && noteable != self.noteable
+ OutOfContextDiscussion
+ else
+ IndividualNoteDiscussion
+ end
+ end
+
+ # See `Discussion.override_discussion_id` for details.
+ def discussion_id(noteable = nil)
+ discussion_class(noteable).override_discussion_id(self) || super()
+ end
+
+ # Returns a discussion containing just this note.
+ # This method exists as an alternative to `#discussion` to use when the methods
+ # we intend to call on the Discussion object don't require it to have all of its notes,
+ # and just depend on the first note or the type of discussion. This saves us a DB query.
+ def to_discussion(noteable = nil)
+ Discussion.build([self], noteable)
+ end
+
+ # Returns the entire discussion this note is part of.
+ # Consider using `#to_discussion` if we do not need to render the discussion
+ # and all its notes and if we don't care about the discussion's resolvability status.
+ def discussion
+ full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
+ full_discussion || to_discussion
+ end
+
+ def part_of_discussion?
+ !to_discussion.individual_note?
+ end
+
+ def in_reply_to?(other)
+ case other
+ when Note
+ if part_of_discussion?
+ in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion)
+ else
+ in_reply_to?(other.noteable)
+ end
+ when Discussion
+ self.discussion_id == other.id
+ when Noteable
+ self.noteable == other
+ else
+ false
+ end
+ end
+
private
def keep_around_commit
@@ -264,17 +314,7 @@ class Note < ActiveRecord::Base
end
def set_discussion_id
- self.discussion_id = Digest::SHA1.hexdigest(build_discussion_id)
- end
-
- def build_discussion_id
- if for_merge_request?
- # Notes on merge requests are always in a discussion of their own,
- # so we generate a unique discussion ID.
- [:discussion, :note, SecureRandom.hex].join("-")
- else
- self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
- end
+ self.discussion_id ||= discussion_class.discussion_id(self)
end
def expire_etag_cache
diff --git a/app/models/out_of_context_discussion.rb b/app/models/out_of_context_discussion.rb
new file mode 100644
index 00000000000..85794630f70
--- /dev/null
+++ b/app/models/out_of_context_discussion.rb
@@ -0,0 +1,22 @@
+# When notes on a commit are displayed in the context of a merge request that
+# contains that commit, they are displayed as if they were a discussion.
+#
+# This represents one of those discussions, consisting of `Note` notes.
+#
+# A discussion of this type is never resolvable.
+class OutOfContextDiscussion < Discussion
+ # Returns an array of discussion ID components
+ def self.build_discussion_id(note)
+ base_discussion_id(note)
+ end
+
+ # To make sure all out-of-context notes end up grouped as one discussion,
+ # we override the discussion ID to be a newly generated but consistent ID.
+ def self.override_discussion_id(note)
+ discussion_id(note)
+ end
+
+ def self.note_class
+ Note
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 1f95d00baf8..a160efba912 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -116,6 +116,7 @@ class Project < ActiveRecord::Base
has_one :mock_ci_service, dependent: :destroy
has_one :mock_deployment_service, dependent: :destroy
has_one :mock_monitoring_service, dependent: :destroy
+ has_one :microsoft_teams_service, dependent: :destroy
has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
has_one :forked_from_project, through: :forked_project_link
@@ -134,6 +135,7 @@ class Project < ActiveRecord::Base
has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet'
has_many :hooks, dependent: :destroy, class_name: 'ProjectHook'
has_many :protected_branches, dependent: :destroy
+ has_many :protected_tags, dependent: :destroy
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
@@ -159,6 +161,7 @@ class Project < ActiveRecord::Base
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
has_one :project_feature, dependent: :destroy
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
+ has_many :container_repositories, dependent: :destroy
has_many :commit_statuses, dependent: :destroy
has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
@@ -170,6 +173,8 @@ class Project < ActiveRecord::Base
has_many :environments, dependent: :destroy
has_many :deployments, dependent: :destroy
+ has_many :active_runners, -> { active }, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
+
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature
@@ -258,6 +263,8 @@ class Project < ActiveRecord::Base
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
+ enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
+
# project features may be "disabled", "internal" or "enabled". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either enabled, or internal with permission for the user.
@@ -406,32 +413,15 @@ class Project < ActiveRecord::Base
@repository ||= Repository.new(path_with_namespace, self)
end
- def container_registry_path_with_namespace
- path_with_namespace.downcase
- end
-
- def container_registry_repository
- return unless Gitlab.config.registry.enabled
-
- @container_registry_repository ||= begin
- token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace)
- url = Gitlab.config.registry.api_url
- host_port = Gitlab.config.registry.host_port
- registry = ContainerRegistry::Registry.new(url, token: token, path: host_port)
- registry.repository(container_registry_path_with_namespace)
- end
- end
-
- def container_registry_repository_url
+ def container_registry_url
if Gitlab.config.registry.enabled
- "#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}"
+ "#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
end
end
def has_container_registry_tags?
- return unless container_registry_repository
-
- container_registry_repository.tags.any?
+ container_repositories.to_a.any?(&:has_tags?) ||
+ has_root_container_repository_tags?
end
def commit(ref = 'HEAD')
@@ -870,14 +860,6 @@ class Project < ActiveRecord::Base
@repo_exists = false
end
- # Branches that are not _exactly_ matched by a protected branch.
- def open_branches
- exact_protected_branch_names = protected_branches.reject(&:wildcard?).map(&:name)
- branch_names = repository.branches.map(&:name)
- non_open_branch_names = Set.new(exact_protected_branch_names).intersection(Set.new(branch_names))
- repository.branches.reject { |branch| non_open_branch_names.include? branch.name }
- end
-
def root_ref?(branch)
repository.root_ref == branch
end
@@ -892,16 +874,8 @@ class Project < ActiveRecord::Base
Gitlab::UrlSanitizer.new("#{web_url}.git", credentials: credentials).full_url
end
- # Check if current branch name is marked as protected in the system
- def protected_branch?(branch_name)
- return true if empty_repo? && default_branch_protected?
-
- @protected_branches ||= self.protected_branches.to_a
- ProtectedBranch.matching(branch_name, protected_branches: @protected_branches).present?
- end
-
def user_can_push_to_empty_repo?(user)
- !default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
+ !ProtectedBranch.default_branch_protected? || team.max_member_access(user.id) > Gitlab::Access::DEVELOPER
end
def forked?
@@ -922,10 +896,10 @@ class Project < ActiveRecord::Base
expire_caches_before_rename(old_path_with_namespace)
if has_container_registry_tags?
- Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present"
+ Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
- # we currently doesn't support renaming repository if it contains tags in container registry
- raise StandardError.new('Project cannot be renamed, because tags are present in its container registry')
+ # we currently doesn't support renaming repository if it contains images in container registry
+ raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
end
if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
@@ -1100,25 +1074,21 @@ class Project < ActiveRecord::Base
end
def shared_runners
- shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none
+ @shared_runners ||= shared_runners_available? ? Ci::Runner.shared : Ci::Runner.none
end
- def any_runners?(&block)
- if runners.active.any?(&block)
- return true
- end
+ def active_shared_runners
+ @active_shared_runners ||= shared_runners.active
+ end
- shared_runners.active.any?(&block)
+ def any_runners?(&block)
+ active_runners.any?(&block) || active_shared_runners.any?(&block)
end
def valid_runners_token?(token)
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
end
- def build_coverage_enabled?
- build_coverage_regex.present?
- end
-
def build_timeout_in_minutes
build_timeout / 60
end
@@ -1212,7 +1182,7 @@ class Project < ActiveRecord::Base
end
def pipeline_status
- @pipeline_status ||= Ci::PipelineStatus.load_for_project(self)
+ @pipeline_status ||= Gitlab::Cache::Ci::ProjectPipelineStatus.load_for_project(self)
end
def mark_import_as_failed(error_message)
@@ -1272,7 +1242,7 @@ class Project < ActiveRecord::Base
]
if container_registry_enabled?
- variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true }
+ variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
end
variables
@@ -1368,11 +1338,6 @@ class Project < ActiveRecord::Base
"projects/#{id}/pushes_since_gc"
end
- def default_branch_protected?
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
- current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
- end
-
# Similar to the normal callbacks that hook into the life cycle of an
# Active Record object, you can also define callbacks that get triggered
# when you add an object to an association collection. If any of these
@@ -1405,4 +1370,15 @@ class Project < ActiveRecord::Base
Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
end
+
+ ##
+ # This method is here because of support for legacy container repository
+ # which has exactly the same path like project does, but which might not be
+ # persisted in `container_repositories` table.
+ #
+ def has_root_container_repository_tags?
+ return false unless Gitlab.config.registry.enabled
+
+ ContainerRepository.build_root_repository(self).has_tags?
+ end
end
diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb
index 86d271a3f69..7621a5fa2d8 100644
--- a/app/models/project_services/chat_message/base_message.rb
+++ b/app/models/project_services/chat_message/base_message.rb
@@ -2,11 +2,23 @@ require 'slack-notifier'
module ChatMessage
class BaseMessage
+ attr_reader :markdown
+ attr_reader :user_name
+ attr_reader :user_avatar
+ attr_reader :project_name
+ attr_reader :project_url
+
def initialize(params)
- raise NotImplementedError
+ @markdown = params[:markdown] || false
+ @project_name = params.dig(:project, :path_with_namespace) || params[:project_name]
+ @project_url = params.dig(:project, :web_url) || params[:project_url]
+ @user_name = params.dig(:user, :username) || params[:user_name]
+ @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar]
end
def pretext
+ return message if markdown
+
format(message)
end
@@ -17,6 +29,10 @@ module ChatMessage
raise NotImplementedError
end
+ def activity
+ raise NotImplementedError
+ end
+
private
def message
diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb
index 791e5b0cec7..4b9a2b1e1f3 100644
--- a/app/models/project_services/chat_message/issue_message.rb
+++ b/app/models/project_services/chat_message/issue_message.rb
@@ -1,9 +1,6 @@
module ChatMessage
class IssueMessage < BaseMessage
- attr_reader :user_name
attr_reader :title
- attr_reader :project_name
- attr_reader :project_url
attr_reader :issue_iid
attr_reader :issue_url
attr_reader :action
@@ -11,9 +8,7 @@ module ChatMessage
attr_reader :description
def initialize(params)
- @user_name = params[:user][:username]
- @project_name = params[:project_name]
- @project_url = params[:project_url]
+ super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@@ -27,15 +22,24 @@ module ChatMessage
def attachments
return [] unless opened_issue?
+ return description if markdown
description_message
end
+ def activity
+ {
+ title: "Issue #{state} by #{user_name}",
+ subtitle: "in #{project_link}",
+ text: issue_link,
+ image: user_avatar
+ }
+ end
+
private
def message
- case state
- when "opened"
+ if state == 'opened'
"[#{project_link}] Issue #{state} by #{user_name}"
else
"[#{project_link}] Issue #{issue_link} #{state} by #{user_name}"
@@ -64,7 +68,7 @@ module ChatMessage
end
def issue_title
- "##{issue_iid} #{title}"
+ "#{Issue.reference_prefix}#{issue_iid} #{title}"
end
end
end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index 5e5efca7bec..7d0de81cdf0 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -1,36 +1,36 @@
module ChatMessage
class MergeMessage < BaseMessage
- attr_reader :user_name
- attr_reader :project_name
- attr_reader :project_url
- attr_reader :merge_request_id
+ attr_reader :merge_request_iid
attr_reader :source_branch
attr_reader :target_branch
attr_reader :state
attr_reader :title
def initialize(params)
- @user_name = params[:user][:username]
- @project_name = params[:project_name]
- @project_url = params[:project_url]
+ super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
- @merge_request_id = obj_attr[:iid]
+ @merge_request_iid = obj_attr[:iid]
@source_branch = obj_attr[:source_branch]
@target_branch = obj_attr[:target_branch]
@state = obj_attr[:state]
@title = format_title(obj_attr[:title])
end
- def pretext
- format(message)
- end
-
def attachments
[]
end
+ def activity
+ {
+ title: "Merge Request #{state} by #{user_name}",
+ subtitle: "in #{project_link}",
+ text: merge_request_link,
+ image: user_avatar
+ }
+ end
+
private
def format_title(title)
@@ -50,11 +50,15 @@ module ChatMessage
end
def merge_request_link
- link("merge request !#{merge_request_id}", merge_request_url)
+ link(merge_request_title, merge_request_url)
+ end
+
+ def merge_request_title
+ "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}"
end
def merge_request_url
- "#{project_url}/merge_requests/#{merge_request_id}"
+ "#{project_url}/merge_requests/#{merge_request_iid}"
end
end
end
diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb
index 552113bac29..2da4c244229 100644
--- a/app/models/project_services/chat_message/note_message.rb
+++ b/app/models/project_services/chat_message/note_message.rb
@@ -1,70 +1,74 @@
module ChatMessage
class NoteMessage < BaseMessage
- attr_reader :message
- attr_reader :user_name
- attr_reader :project_name
- attr_reader :project_url
attr_reader :note
attr_reader :note_url
+ attr_reader :title
+ attr_reader :target
def initialize(params)
- params = HashWithIndifferentAccess.new(params)
- @user_name = params[:user][:username]
- @project_name = params[:project_name]
- @project_url = params[:project_url]
+ super
+ params = HashWithIndifferentAccess.new(params)
obj_attr = params[:object_attributes]
- obj_attr = HashWithIndifferentAccess.new(obj_attr)
@note = obj_attr[:note]
@note_url = obj_attr[:url]
- noteable_type = obj_attr[:noteable_type]
-
- case noteable_type
- when "Commit"
- create_commit_note(HashWithIndifferentAccess.new(params[:commit]))
- when "Issue"
- create_issue_note(HashWithIndifferentAccess.new(params[:issue]))
- when "MergeRequest"
- create_merge_note(HashWithIndifferentAccess.new(params[:merge_request]))
- when "Snippet"
- create_snippet_note(HashWithIndifferentAccess.new(params[:snippet]))
- end
+ @target, @title = case obj_attr[:noteable_type]
+ when "Commit"
+ create_commit_note(params[:commit])
+ when "Issue"
+ create_issue_note(params[:issue])
+ when "MergeRequest"
+ create_merge_note(params[:merge_request])
+ when "Snippet"
+ create_snippet_note(params[:snippet])
+ end
end
def attachments
+ return note if markdown
+
description_message
end
+ def activity
+ {
+ title: "#{user_name} #{link('commented on ' + target, note_url)}",
+ subtitle: "in #{project_link}",
+ text: formatted_title,
+ image: user_avatar
+ }
+ end
+
private
+ def message
+ "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*"
+ end
+
def format_title(title)
title.lines.first.chomp
end
- def create_commit_note(commit)
- commit_sha = commit[:id]
- commit_sha = Commit.truncate_sha(commit_sha)
- commented_on_message(
- "commit #{commit_sha}",
- format_title(commit[:message]))
+ def formatted_title
+ format_title(title)
end
def create_issue_note(issue)
- commented_on_message(
- "issue ##{issue[:iid]}",
- format_title(issue[:title]))
+ ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]]
+ end
+
+ def create_commit_note(commit)
+ commit_sha = Commit.truncate_sha(commit[:id])
+
+ ["commit #{commit_sha}", commit[:message]]
end
def create_merge_note(merge_request)
- commented_on_message(
- "merge request !#{merge_request[:iid]}",
- format_title(merge_request[:title]))
+ ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]]
end
def create_snippet_note(snippet)
- commented_on_message(
- "snippet ##{snippet[:id]}",
- format_title(snippet[:title]))
+ ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]]
end
def description_message
@@ -74,9 +78,5 @@ module ChatMessage
def project_link
link(project_name, project_url)
end
-
- def commented_on_message(target, title)
- @message = "#{user_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{title}*"
- end
end
end
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 210027565a8..4628d9b1a7b 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -1,19 +1,22 @@
module ChatMessage
class PipelineMessage < BaseMessage
- attr_reader :ref_type, :ref, :status, :project_name, :project_url,
- :user_name, :duration, :pipeline_id
+ attr_reader :ref_type
+ attr_reader :ref
+ attr_reader :status
+ attr_reader :duration
+ attr_reader :pipeline_id
def initialize(data)
+ super
+
+ @user_name = data.dig(:user, :name) || 'API'
+
pipeline_attributes = data[:object_attributes]
@ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch'
@ref = pipeline_attributes[:ref]
@status = pipeline_attributes[:status]
@duration = pipeline_attributes[:duration]
@pipeline_id = pipeline_attributes[:id]
-
- @project_name = data[:project][:path_with_namespace]
- @project_url = data[:project][:web_url]
- @user_name = (data[:user] && data[:user][:name]) || 'API'
end
def pretext
@@ -25,17 +28,24 @@ module ChatMessage
end
def attachments
+ return message if markdown
+
[{ text: format(message), color: attachment_color }]
end
+ def activity
+ {
+ title: "Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status}",
+ subtitle: "in #{project_link}",
+ text: "in #{duration} #{time_measure}",
+ image: user_avatar || ''
+ }
+ end
+
private
def message
- "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{'second'.pluralize(duration)}"
- end
-
- def format(string)
- Slack::Notifier::LinkFormatter.format(string)
+ "#{project_link}: Pipeline #{pipeline_link} of #{branch_link} #{ref_type} by #{user_name} #{humanized_status} in #{duration} #{time_measure}"
end
def humanized_status
@@ -74,5 +84,9 @@ module ChatMessage
def pipeline_link
"[##{pipeline_id}](#{pipeline_url})"
end
+
+ def time_measure
+ 'second'.pluralize(duration)
+ end
end
end
diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb
index 2d73b71ec37..c52dd6ef8ef 100644
--- a/app/models/project_services/chat_message/push_message.rb
+++ b/app/models/project_services/chat_message/push_message.rb
@@ -3,33 +3,43 @@ module ChatMessage
attr_reader :after
attr_reader :before
attr_reader :commits
- attr_reader :project_name
- attr_reader :project_url
attr_reader :ref
attr_reader :ref_type
- attr_reader :user_name
def initialize(params)
+ super
+
@after = params[:after]
@before = params[:before]
@commits = params.fetch(:commits, [])
- @project_name = params[:project_name]
- @project_url = params[:project_url]
@ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch'
@ref = Gitlab::Git.ref_name(params[:ref])
- @user_name = params[:user_name]
- end
-
- def pretext
- format(message)
end
def attachments
return [] if new_branch? || removed_branch?
+ return commit_messages if markdown
commit_message_attachments
end
+ def activity
+ action = if new_branch?
+ "created"
+ elsif removed_branch?
+ "removed"
+ else
+ "pushed to"
+ end
+
+ {
+ title: "#{user_name} #{action} #{ref_type}",
+ subtitle: "in #{project_link}",
+ text: compare_link,
+ image: user_avatar
+ }
+ end
+
private
def message
@@ -59,7 +69,7 @@ module ChatMessage
end
def commit_messages
- commits.map { |commit| compose_commit_message(commit) }.join("\n")
+ commits.map { |commit| compose_commit_message(commit) }.join("\n\n")
end
def commit_message_attachments
diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb
index 134083e4504..a139a8ee727 100644
--- a/app/models/project_services/chat_message/wiki_page_message.rb
+++ b/app/models/project_services/chat_message/wiki_page_message.rb
@@ -1,17 +1,12 @@
module ChatMessage
class WikiPageMessage < BaseMessage
- attr_reader :user_name
attr_reader :title
- attr_reader :project_name
- attr_reader :project_url
attr_reader :wiki_page_url
attr_reader :action
attr_reader :description
def initialize(params)
- @user_name = params[:user][:username]
- @project_name = params[:project_name]
- @project_url = params[:project_url]
+ super
obj_attr = params[:object_attributes]
obj_attr = HashWithIndifferentAccess.new(obj_attr)
@@ -29,9 +24,20 @@ module ChatMessage
end
def attachments
+ return description if markdown
+
description_message
end
+ def activity
+ {
+ title: "#{user_name} #{action} #{wiki_page_link}",
+ subtitle: "in #{project_link}",
+ text: title,
+ image: user_avatar
+ }
+ end
+
private
def message
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index 75834103db5..fa782c6fbb7 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -49,10 +49,7 @@ class ChatNotificationService < Service
object_kind = data[:object_kind]
- data = data.merge(
- project_url: project_url,
- project_name: project_name
- )
+ data = custom_data(data)
# WebHook events often have an 'update' event that follows a 'open' or
# 'close' action. Ignore update events for now to prevent duplicate
@@ -68,8 +65,7 @@ class ChatNotificationService < Service
opts[:channel] = channel_name if channel_name
opts[:username] = username if username
- notifier = Slack::Notifier.new(webhook, opts)
- notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback)
+ return false unless notify(message, opts)
true
end
@@ -92,6 +88,18 @@ class ChatNotificationService < Service
private
+ def notify(message, opts)
+ Slack::Notifier.new(webhook, opts).ping(
+ message.pretext,
+ attachments: message.attachments,
+ fallback: message.fallback
+ )
+ end
+
+ def custom_data(data)
+ data.merge(project_url: project_url, project_name: project_name)
+ end
+
def get_message(object_kind, data)
case object_kind
when "push", "tag_push"
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 3b90fd1c2c7..97e997d3899 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -91,7 +91,7 @@ class JiraService < IssueTrackerService
{ type: 'text', name: 'project_key', placeholder: 'Project Key' },
{ type: 'text', name: 'username', placeholder: '' },
{ type: 'password', name: 'password', placeholder: '' },
- { type: 'text', name: 'jira_issue_transition_id', placeholder: '2' }
+ { type: 'text', name: 'jira_issue_transition_id', placeholder: '' }
]
end
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
new file mode 100644
index 00000000000..9b218fd81b4
--- /dev/null
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -0,0 +1,56 @@
+class MicrosoftTeamsService < ChatNotificationService
+ def title
+ 'Microsoft Teams Notification'
+ end
+
+ def description
+ 'Receive event notifications in Microsoft Teams'
+ end
+
+ def self.to_param
+ 'microsoft_teams'
+ end
+
+ def help
+ 'This service sends notifications about projects events to Microsoft Teams channels.<br />
+ To set up this service:
+ <ol>
+ <li><a href="https://msdn.microsoft.com/en-us/microsoft-teams/connectors">Getting started with 365 Office Connectors For Microsoft Teams</a>.</li>
+ <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
+ <li>Select events below to enable notifications.</li>
+ </ol>'
+ end
+
+ def webhook_placeholder
+ 'https://outlook.office.com/webhook/…'
+ end
+
+ def event_field(event)
+ end
+
+ def default_channel_placeholder
+ end
+
+ def default_fields
+ [
+ { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'checkbox', name: 'notify_only_default_branch' },
+ ]
+ end
+
+ private
+
+ def notify(message, opts)
+ MicrosoftTeams::Notifier.new(webhook).ping(
+ title: message.project_name,
+ pretext: message.pretext,
+ activity: message.activity,
+ attachments: message.attachments
+ )
+ end
+
+ def custom_data(data)
+ super(data).merge(markdown: true)
+ end
+end
diff --git a/app/models/protectable_dropdown.rb b/app/models/protectable_dropdown.rb
new file mode 100644
index 00000000000..122fbce257d
--- /dev/null
+++ b/app/models/protectable_dropdown.rb
@@ -0,0 +1,33 @@
+class ProtectableDropdown
+ def initialize(project, ref_type)
+ @project = project
+ @ref_type = ref_type
+ end
+
+ # Tags/branches which are yet to be individually protected
+ def protectable_ref_names
+ @protectable_ref_names ||= ref_names - non_wildcard_protected_ref_names
+ end
+
+ def hash
+ protectable_ref_names.map { |ref_name| { text: ref_name, id: ref_name, title: ref_name } }
+ end
+
+ private
+
+ def refs
+ @project.repository.public_send(@ref_type)
+ end
+
+ def ref_names
+ refs.map(&:name)
+ end
+
+ def protections
+ @project.public_send("protected_#{@ref_type}")
+ end
+
+ def non_wildcard_protected_ref_names
+ protections.reject(&:wildcard?).map(&:name)
+ end
+end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 39e979ef15b..28b7d5ad072 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,9 +1,6 @@
class ProtectedBranch < ActiveRecord::Base
include Gitlab::ShellAdapter
-
- belongs_to :project
- validates :name, presence: true
- validates :project, presence: true
+ include ProtectedRef
has_many :merge_access_levels, dependent: :destroy
has_many :push_access_levels, dependent: :destroy
@@ -14,54 +11,15 @@ class ProtectedBranch < ActiveRecord::Base
accepts_nested_attributes_for :push_access_levels
accepts_nested_attributes_for :merge_access_levels
- def commit
- project.commit(self.name)
- end
-
- # Returns all protected branches that match the given branch name.
- # This realizes all records from the scope built up so far, and does
- # _not_ return a relation.
- #
- # This method optionally takes in a list of `protected_branches` to search
- # through, to avoid calling out to the database.
- def self.matching(branch_name, protected_branches: nil)
- (protected_branches || all).select { |protected_branch| protected_branch.matches?(branch_name) }
- end
-
- # Returns all branches (among the given list of branches [`Gitlab::Git::Branch`])
- # that match the current protected branch.
- def matching(branches)
- branches.select { |branch| self.matches?(branch.name) }
- end
-
- # Checks if the protected branch matches the given branch name.
- def matches?(branch_name)
- return false if self.name.blank?
-
- exact_match?(branch_name) || wildcard_match?(branch_name)
- end
-
- # Checks if this protected branch contains a wildcard
- def wildcard?
- self.name && self.name.include?('*')
- end
-
- protected
-
- def exact_match?(branch_name)
- self.name == branch_name
- end
+ # Check if branch name is marked as protected in the system
+ def self.protected?(project, ref_name)
+ return true if project.empty_repo? && default_branch_protected?
- def wildcard_match?(branch_name)
- wildcard_regex === branch_name
+ self.matching(ref_name, protected_refs: project.protected_branches).present?
end
- def wildcard_regex
- @wildcard_regex ||= begin
- name = self.name.gsub('*', 'STAR_DONT_ESCAPE')
- quoted_name = Regexp.quote(name)
- regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?')
- /\A#{regex_string}\z/
- end
+ def self.default_branch_protected?
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
+ current_application_settings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
end
diff --git a/app/models/protected_ref_matcher.rb b/app/models/protected_ref_matcher.rb
new file mode 100644
index 00000000000..d970f2b01fc
--- /dev/null
+++ b/app/models/protected_ref_matcher.rb
@@ -0,0 +1,54 @@
+class ProtectedRefMatcher
+ def initialize(protected_ref)
+ @protected_ref = protected_ref
+ end
+
+ # Returns all protected refs that match the given ref name.
+ # This checks all records from the scope built up so far, and does
+ # _not_ return a relation.
+ #
+ # This method optionally takes in a list of `protected_refs` to search
+ # through, to avoid calling out to the database.
+ def self.matching(type, ref_name, protected_refs: nil)
+ (protected_refs || type.all).select { |protected_ref| protected_ref.matches?(ref_name) }
+ end
+
+ # Returns all branches/tags (among the given list of refs [`Gitlab::Git::Branch`])
+ # that match the current protected ref.
+ def matching(refs)
+ refs.select { |ref| @protected_ref.matches?(ref.name) }
+ end
+
+ # Checks if the protected ref matches the given ref name.
+ def matches?(ref_name)
+ return false if @protected_ref.name.blank?
+
+ exact_match?(ref_name) || wildcard_match?(ref_name)
+ end
+
+ # Checks if this protected ref contains a wildcard
+ def wildcard?
+ @protected_ref.name && @protected_ref.name.include?('*')
+ end
+
+ protected
+
+ def exact_match?(ref_name)
+ @protected_ref.name == ref_name
+ end
+
+ def wildcard_match?(ref_name)
+ return false unless wildcard?
+
+ wildcard_regex === ref_name
+ end
+
+ def wildcard_regex
+ @wildcard_regex ||= begin
+ name = @protected_ref.name.gsub('*', 'STAR_DONT_ESCAPE')
+ quoted_name = Regexp.quote(name)
+ regex_string = quoted_name.gsub('STAR_DONT_ESCAPE', '.*?')
+ /\A#{regex_string}\z/
+ end
+ end
+end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
new file mode 100644
index 00000000000..83964095516
--- /dev/null
+++ b/app/models/protected_tag.rb
@@ -0,0 +1,14 @@
+class ProtectedTag < ActiveRecord::Base
+ include Gitlab::ShellAdapter
+ include ProtectedRef
+
+ has_many :create_access_levels, dependent: :destroy
+
+ validates :create_access_levels, length: { is: 1, message: "are restricted to a single instance per protected tag." }
+
+ accepts_nested_attributes_for :create_access_levels
+
+ def self.protected?(project, ref_name)
+ self.matching(ref_name, protected_refs: project.protected_tags).present?
+ end
+end
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
new file mode 100644
index 00000000000..c7e1319719d
--- /dev/null
+++ b/app/models/protected_tag/create_access_level.rb
@@ -0,0 +1,21 @@
+class ProtectedTag::CreateAccessLevel < ActiveRecord::Base
+ include ProtectedTagAccess
+
+ validates :access_level, presence: true, inclusion: { in: [Gitlab::Access::MASTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::NO_ACCESS] }
+
+ def self.human_access_levels
+ {
+ Gitlab::Access::MASTER => "Masters",
+ Gitlab::Access::DEVELOPER => "Developers + Masters",
+ Gitlab::Access::NO_ACCESS => "No one"
+ }.with_indifferent_access
+ end
+
+ def check_access(user)
+ return false if access_level == Gitlab::Access::NO_ACCESS
+
+ super
+ end
+end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index dc1c1fab880..2b11ed6128e 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -6,6 +6,8 @@ class Repository
attr_accessor :path_with_namespace, :project
+ delegate :ref_name_for_sha, to: :raw_repository
+
CommitError = Class.new(StandardError)
CreateTreeError = Class.new(StandardError)
@@ -405,8 +407,6 @@ class Repository
# Runs code after a repository has been forked/imported.
def after_import
expire_content_cache
- expire_tags_cache
- expire_branches_cache
end
# Runs code after a new commit has been pushed.
@@ -700,14 +700,6 @@ class Repository
end
end
- def ref_name_for_sha(ref_path, sha)
- args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
-
- # Not found -> ["", 0]
- # Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
- Gitlab::Popen.popen(args, path_to_repo).first.split.last
- end
-
def refs_contains_sha(ref_type, sha)
args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
names = Gitlab::Popen.popen(args, path_to_repo).first
@@ -971,13 +963,15 @@ class Repository
end
def is_ancestor?(ancestor_id, descendant_id)
- Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
- if is_enabled
- raw_repository.is_ancestor?(ancestor_id, descendant_id)
- else
- merge_base_commit(ancestor_id, descendant_id) == ancestor_id
- end
- end
+ # NOTE: This feature is intentionally disabled until
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/30586 is resolved
+ # Gitlab::GitalyClient.migrate(:is_ancestor) do |is_enabled|
+ # if is_enabled
+ # raw_repository.is_ancestor?(ancestor_id, descendant_id)
+ # else
+ merge_base_commit(ancestor_id, descendant_id) == ancestor_id
+ # end
+ # end
end
def empty_repo?
@@ -1162,6 +1156,8 @@ class Repository
@project.repository_storage_path
end
+ delegate :gitaly_channel, :gitaly_repository, to: :raw_repository
+
def initialize_raw_repository
Gitlab::Git::Repository.new(project.repository_storage, path_with_namespace + '.git')
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index f4bcb49b34d..bfaf0eb2fae 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -5,10 +5,11 @@ class SentNotification < ActiveRecord::Base
belongs_to :noteable, polymorphic: true
belongs_to :recipient, class_name: "User"
- validates :project, :recipient, :reply_key, presence: true
- validates :reply_key, uniqueness: true
+ validates :project, :recipient, presence: true
+ validates :reply_key, presence: true, uniqueness: true
validates :noteable_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit?
+ validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true }
validate :note_valid
after_save :keep_around_commit
@@ -22,9 +23,7 @@ class SentNotification < ActiveRecord::Base
find_by(reply_key: reply_key)
end
- def record(noteable, recipient_id, reply_key, attrs = {})
- return unless reply_key
-
+ def record(noteable, recipient_id, reply_key = self.reply_key, attrs = {})
noteable_id = nil
commit_id = nil
if noteable.is_a?(Commit)
@@ -34,23 +33,20 @@ class SentNotification < ActiveRecord::Base
end
attrs.reverse_merge!(
- project: noteable.project,
- noteable_type: noteable.class.name,
- noteable_id: noteable_id,
- commit_id: commit_id,
- recipient_id: recipient_id,
- reply_key: reply_key
+ project: noteable.project,
+ recipient_id: recipient_id,
+ reply_key: reply_key,
+
+ noteable_type: noteable.class.name,
+ noteable_id: noteable_id,
+ commit_id: commit_id,
)
create(attrs)
end
- def record_note(note, recipient_id, reply_key, attrs = {})
- if note.diff_note?
- attrs[:note_type] = note.type
-
- attrs.merge!(note.diff_attributes)
- end
+ def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
+ attrs[:in_reply_to_discussion_id] = note.discussion_id
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -89,31 +85,45 @@ class SentNotification < ActiveRecord::Base
self.reply_key
end
- def note_attributes
- {
- project: self.project,
- author: self.recipient,
- type: self.note_type,
- noteable_type: self.noteable_type,
- noteable_id: self.noteable_id,
- commit_id: self.commit_id,
- line_code: self.line_code,
- position: self.position.to_json
- }
- end
-
- def create_note(note)
- Notes::CreateService.new(
- self.project,
- self.recipient,
- self.note_attributes.merge(note: note)
- ).execute
+ def create_reply(message, dryrun: false)
+ klass = dryrun ? Notes::BuildService : Notes::CreateService
+ klass.new(self.project, self.recipient, reply_params.merge(note: message)).execute
end
private
+ def reply_params
+ attrs = {
+ noteable_type: self.noteable_type,
+ noteable_id: self.noteable_id,
+ commit_id: self.commit_id
+ }
+
+ if self.in_reply_to_discussion_id.present?
+ attrs[:in_reply_to_discussion_id] = self.in_reply_to_discussion_id
+ else
+ # Remove in GitLab 10.0, when we will not support replying to SentNotifications
+ # that don't have `in_reply_to_discussion_id` anymore.
+ attrs.merge!(
+ type: self.note_type,
+
+ # LegacyDiffNote
+ line_code: self.line_code,
+
+ # DiffNote
+ position: self.position.to_json
+ )
+ end
+
+ attrs
+ end
+
def note_valid
- Note.new(note_attributes.merge(note: "Test")).valid?
+ note = create_reply('Test', dryrun: true)
+
+ unless note.valid?
+ self.errors.add(:base, "Note parameters are invalid: #{note.errors.full_messages.to_sentence}")
+ end
end
def keep_around_commit
diff --git a/app/models/service.rb b/app/models/service.rb
index 5a0ec58d193..dc76bf925d3 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -237,6 +237,7 @@ class Service < ActiveRecord::Base
slack_slash_commands
slack
teamcity
+ microsoft_teams
]
if Rails.env.development?
service_names += %w[mock_ci mock_deployment mock_monitoring]
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 30aca62499c..380835707e8 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -2,6 +2,7 @@ class Snippet < ActiveRecord::Base
include Gitlab::VisibilityLevel
include Linguist::BlobHelper
include CacheMarkdownField
+ include Noteable
include Participable
include Referable
include Sortable
diff --git a/app/models/user.rb b/app/models/user.rb
index 95a766f2ede..457ba05fb04 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -89,7 +89,8 @@ class User < ActiveRecord::Base
has_many :subscriptions, dependent: :destroy
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
- has_one :abuse_report, dependent: :destroy
+ has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
+ has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
has_many :spam_logs, dependent: :destroy
has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
@@ -484,6 +485,14 @@ class User < ActiveRecord::Base
Group.member_descendants(id)
end
+ def all_expanded_groups
+ Group.member_hierarchy(id)
+ end
+
+ def expanded_groups_requiring_two_factor_authentication
+ all_expanded_groups.where(require_two_factor_authentication: true)
+ end
+
def nested_groups_projects
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
member_descendants(id)
@@ -546,10 +555,6 @@ class User < ActiveRecord::Base
authorized_projects(Gitlab::Access::REPORTER).non_archived.with_issues_enabled
end
- def is_admin?
- admin
- end
-
def require_ssh_key?
keys.count == 0 && Gitlab::ProtocolAccess.allowed?('ssh')
end
@@ -582,10 +587,6 @@ class User < ActiveRecord::Base
name.split.first unless name.blank?
end
- def cared_merge_requests
- MergeRequest.cared(self)
- end
-
def projects_limit_left
projects_limit - personal_projects.count
end
@@ -955,6 +956,15 @@ class User < ActiveRecord::Base
self.admin = (new_level == 'admin')
end
+ def update_two_factor_requirement
+ periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
+
+ self.require_two_factor_authentication_from_group = periods.any?
+ self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period']
+
+ save
+ end
+
protected
# override, from Devise::Validatable
diff --git a/app/policies/ci/runner_policy.rb b/app/policies/ci/runner_policy.rb
index 7edd383530d..416d93ffe63 100644
--- a/app/policies/ci/runner_policy.rb
+++ b/app/policies/ci/runner_policy.rb
@@ -3,7 +3,7 @@ module Ci
def rules
return unless @user
- can! :assign_runner if @user.is_admin?
+ can! :assign_runner if @user.admin?
return if @subject.is_shared? || @subject.locked?
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index cb72c2b4590..4757ba71680 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -10,6 +10,7 @@ class GlobalPolicy < BasePolicy
can! :access_api
can! :access_git
can! :receive_notifications
+ can! :use_slash_commands
end
end
end
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index cb58c115d54..87398303c68 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -28,6 +28,7 @@ class GroupPolicy < BasePolicy
can! :admin_namespace
can! :admin_group_member
can! :change_visibility_level
+ can! :create_subgroup if @user.can_create_group
end
if globally_viewable && @subject.request_access_enabled && !member
diff --git a/app/presenters/ci/build_presenter.rb b/app/presenters/ci/build_presenter.rb
index ed72ed14d72..c495c3f39bb 100644
--- a/app/presenters/ci/build_presenter.rb
+++ b/app/presenters/ci/build_presenter.rb
@@ -11,5 +11,11 @@ module Ci
def erased_by_name
erased_by.name if erased_by_user?
end
+
+ def status_title
+ if auto_canceled?
+ "Job is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ end
+ end
end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
new file mode 100644
index 00000000000..a542bdd8295
--- /dev/null
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -0,0 +1,11 @@
+module Ci
+ class PipelinePresenter < Gitlab::View::Presenter::Delegated
+ presents :pipeline
+
+ def status_title
+ if auto_canceled?
+ "Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
+ end
+ end
+ end
+end
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index 3f16dd66d54..ad8b4d43e8f 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -69,13 +69,13 @@ class PipelineEntity < Grape::Entity
alias_method :pipeline, :object
def can_retry?
- pipeline.retryable? &&
- can?(request.user, :update_pipeline, pipeline)
+ can?(request.user, :update_pipeline, pipeline) &&
+ pipeline.retryable?
end
def can_cancel?
- pipeline.cancelable? &&
- can?(request.user, :update_pipeline, pipeline)
+ can?(request.user, :update_pipeline, pipeline) &&
+ pipeline.cancelable?
end
def detailed_status
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 7829df9fada..e7a9df8ac4e 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -13,7 +13,15 @@ class PipelineSerializer < BaseSerializer
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
- resource = resource.includes(project: :namespace)
+ resource = resource.preload([
+ :retryable_builds,
+ :cancelable_statuses,
+ :trigger_requests,
+ :project,
+ { pending_builds: :project },
+ { manual_actions: :project },
+ { artifacts: :project }
+ ])
end
if paginated?
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index db82b8f6c30..5e151b0f044 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -17,6 +17,7 @@ module Auth
end
def self.full_access_token(*names)
+ names = names.flatten
registry = Gitlab.config.registry
token = JSONWebToken::RSAToken.new(registry.key)
token.issuer = registry.issuer
@@ -37,13 +38,13 @@ module Auth
private
def authorized_token(*accesses)
- token = JSONWebToken::RSAToken.new(registry.key)
- token.issuer = registry.issuer
- token.audience = params[:service]
- token.subject = current_user.try(:username)
- token.expire_time = self.class.token_expire_at
- token[:access] = accesses.compact
- token
+ JSONWebToken::RSAToken.new(registry.key).tap do |token|
+ token.issuer = registry.issuer
+ token.audience = params[:service]
+ token.subject = current_user.try(:username)
+ token.expire_time = self.class.token_expire_at
+ token[:access] = accesses.compact
+ end
end
def scope
@@ -55,20 +56,43 @@ module Auth
def process_scope(scope)
type, name, actions = scope.split(':', 3)
actions = actions.split(',')
+ path = ContainerRegistry::Path.new(name)
+
return unless type == 'repository'
- process_repository_access(type, name, actions)
+ process_repository_access(type, path, actions)
end
- def process_repository_access(type, name, actions)
- requested_project = Project.find_by_full_path(name)
+ def process_repository_access(type, path, actions)
+ return unless path.valid?
+
+ requested_project = path.repository_project
+
return unless requested_project
actions = actions.select do |action|
can_access?(requested_project, action)
end
- { type: type, name: name, actions: actions } if actions.present?
+ return unless actions.present?
+
+ # At this point user/build is already authenticated.
+ #
+ ensure_container_repository!(path, actions)
+
+ { type: type, name: path.to_s, actions: actions }
+ end
+
+ ##
+ # Because we do not have two way communication with registry yet,
+ # we create a container repository image resource when push to the
+ # registry is successfuly authorized.
+ #
+ def ensure_container_repository!(path, actions)
+ return if path.has_repository?
+ return unless actions.include?('push')
+
+ ContainerRepository.create_from_path!(path)
end
def can_access?(requested_project, requested_action)
@@ -101,6 +125,11 @@ module Auth
can?(current_user, :read_container_image, requested_project)
end
+ ##
+ # We still support legacy pipeline triggers which do not have associated
+ # actor. New permissions model and new triggers are always associated with
+ # an actor, so this should be improved in 10.0 version of GitLab.
+ #
def build_can_push?(requested_project)
# Build can push only to the project from which it originates
has_authentication_ability?(:build_create_container_image) &&
@@ -113,14 +142,11 @@ module Auth
end
def error(code, status:, message: '')
- {
- errors: [{ code: code, message: message }],
- http_status: status
- }
+ { errors: [{ code: code, message: message }], http_status: status }
end
def has_authentication_ability?(capability)
- (@authentication_abilities || []).include?(capability)
+ @authentication_abilities.to_a.include?(capability)
end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 38a85e9fc42..21350be5557 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -53,6 +53,8 @@ module Ci
.execute(pipeline)
end
+ cancel_pending_pipelines if project.auto_cancel_pending_pipelines?
+
pipeline.tap(&:process!)
end
@@ -63,6 +65,22 @@ module Ci
pipeline.git_commit_message =~ /\[(ci[ _-]skip|skip[ _-]ci)\]/i
end
+ def cancel_pending_pipelines
+ Gitlab::OptimisticLocking.retry_lock(auto_cancelable_pipelines) do |cancelables|
+ cancelables.find_each do |cancelable|
+ cancelable.auto_cancel_running(pipeline)
+ end
+ end
+ end
+
+ def auto_cancelable_pipelines
+ project.pipelines
+ .where(ref: pipeline.ref)
+ .where.not(id: pipeline.id)
+ .where.not(sha: project.repository.sha_from_ref(pipeline.ref))
+ .created_or_pending
+ end
+
def commit
@commit ||= project.commit(origin_sha || origin_ref)
end
diff --git a/app/services/ci/expire_pipeline_cache_service.rb b/app/services/ci/expire_pipeline_cache_service.rb
new file mode 100644
index 00000000000..91d9c1d2ba1
--- /dev/null
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -0,0 +1,51 @@
+module Ci
+ class ExpirePipelineCacheService < BaseService
+ attr_reader :pipeline
+
+ def execute(pipeline)
+ @pipeline = pipeline
+ store = Gitlab::EtagCaching::Store.new
+
+ store.touch(project_pipelines_path)
+ store.touch(commit_pipelines_path) if pipeline.commit
+ store.touch(new_merge_request_pipelines_path)
+ merge_requests_pipelines_paths.each { |path| store.touch(path) }
+
+ Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline)
+ end
+
+ private
+
+ def project_pipelines_path
+ Gitlab::Routing.url_helpers.namespace_project_pipelines_path(
+ project.namespace,
+ project,
+ format: :json)
+ end
+
+ def commit_pipelines_path
+ Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path(
+ project.namespace,
+ project,
+ pipeline.commit.id,
+ format: :json)
+ end
+
+ def new_merge_request_pipelines_path
+ Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path(
+ project.namespace,
+ project,
+ format: :json)
+ end
+
+ def merge_requests_pipelines_paths
+ pipeline.merge_requests.collect do |merge_request|
+ Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path(
+ project.namespace,
+ project,
+ merge_request,
+ format: :json)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb
index f72ddbf690c..ecc6173a96a 100644
--- a/app/services/ci/retry_pipeline_service.rb
+++ b/app/services/ci/retry_pipeline_service.rb
@@ -7,9 +7,7 @@ module Ci
raise Gitlab::Access::AccessDeniedError
end
- pipeline.builds.latest.failed_or_canceled.find_each do |build|
- next unless build.retryable?
-
+ pipeline.retryable_builds.find_each do |build|
Ci::RetryBuildService.new(project, current_user)
.reprocess(build)
end
diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb
index 297c7d696c3..910a2a15e5d 100644
--- a/app/services/concerns/issues/resolve_discussions.rb
+++ b/app/services/concerns/issues/resolve_discussions.rb
@@ -21,11 +21,11 @@ module Issues
@discussions_to_resolve ||=
if discussion_to_resolve_id
discussion_or_nil = merge_request_to_resolve_discussions_of
- .find_diff_discussion(discussion_to_resolve_id)
+ .find_discussion(discussion_to_resolve_id)
Array(discussion_or_nil)
else
merge_request_to_resolve_discussions_of
- .resolvable_discussions
+ .discussions_to_be_resolved
end
end
end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 11a045f4c31..38a113caec7 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -11,7 +11,7 @@ class DeleteBranchService < BaseService
return error('Cannot remove HEAD branch', 405)
end
- if project.protected_branch?(branch_name)
+ if ProtectedBranch.protected?(project, branch_name)
return error('Protected branch cant be removed', 405)
end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
index bc7431c89a8..45411c779cc 100644
--- a/app/services/git_push_service.rb
+++ b/app/services/git_push_service.rb
@@ -127,7 +127,7 @@ class GitPushService < BaseService
project.change_head(branch_name)
# Set protection on the default branch if configured
- if current_application_settings.default_branch_protection != PROTECTION_NONE && !@project.protected_branch?(@project.default_branch)
+ if current_application_settings.default_branch_protection != PROTECTION_NONE && !ProtectedBranch.protected?(@project, @project.default_branch)
params = {
name: @project.default_branch,
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 77bced4bd5c..3a4f7b159f1 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -35,14 +35,19 @@ module Issues
end
def item_for_discussion(discussion)
- first_note = discussion.first_note_to_resolve || discussion.first_note
+ first_note_to_resolve = discussion.first_note_to_resolve || discussion.first_note
+
+ is_very_first_note = first_note_to_resolve == discussion.first_note
+ action = is_very_first_note ? "started" : "commented on"
+
+ note_url = Gitlab::UrlBuilder.build(first_note_to_resolve)
+
other_note_count = discussion.notes.size - 1
- note_url = Gitlab::UrlBuilder.build(first_note)
- discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): "
+ discussion_info = "- [ ] #{first_note_to_resolve.author.to_reference} #{action} a [discussion](#{note_url}): "
discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0
- note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call
+ note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note_to_resolve.note).call
spaces = ' ' * 4
quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
new file mode 100644
index 00000000000..ea7cacc956c
--- /dev/null
+++ b/app/services/notes/build_service.rb
@@ -0,0 +1,25 @@
+module Notes
+ class BuildService < ::BaseService
+ def execute
+ in_reply_to_discussion_id = params.delete(:in_reply_to_discussion_id)
+
+ if project && in_reply_to_discussion_id.present?
+ discussion = project.notes.find_discussion(in_reply_to_discussion_id)
+
+ unless discussion
+ note = Note.new
+ note.errors.add(:base, 'Discussion to reply to cannot be found')
+ return note
+ end
+
+ params.merge!(discussion.reply_attributes)
+ end
+
+ note = Note.new(params)
+ note.project = project
+ note.author = current_user
+
+ note
+ end
+ end
+end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index 61d66a26932..f3954f6f8c4 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -1,12 +1,10 @@
module Notes
- class CreateService < BaseService
+ class CreateService < ::BaseService
def execute
merge_request_diff_head_sha = params.delete(:merge_request_diff_head_sha)
- note = Note.new(params)
- note.project = project
- note.author = current_user
- note.system = false
+ note = Notes::BuildService.new(project, current_user, params).execute
+ return note unless note.valid?
# We execute commands (extracted from `params[:note]`) on the noteable
# **before** we save the note because if the note consists of commands
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index a7142d5950e..06d8d143231 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -31,16 +31,16 @@ module Projects
project.team.truncate
project.destroy!
- unless remove_registry_tags
- raise_error('Failed to remove project container registry. Please try again or contact administrator')
+ unless remove_legacy_registry_tags
+ raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
end
unless remove_repository(repo_path)
- raise_error('Failed to remove project repository. Please try again or contact administrator')
+ raise_error('Failed to remove project repository. Please try again or contact administrator.')
end
unless remove_repository(wiki_path)
- raise_error('Failed to remove wiki repository. Please try again or contact administrator')
+ raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
end
end
@@ -68,10 +68,16 @@ module Projects
end
end
- def remove_registry_tags
+ ##
+ # This method makes sure that we correctly remove registry tags
+ # for legacy image repository (when repository path equals project path).
+ #
+ def remove_legacy_registry_tags
return true unless Gitlab.config.registry.enabled
- project.container_registry_repository.delete_tags
+ ContainerRepository.build_root_repository(project).tap do |repository|
+ return repository.has_tags? ? repository.delete_tags! : true
+ end
end
def raise_error(message)
diff --git a/app/services/protected_branches/update_service.rb b/app/services/protected_branches/update_service.rb
index 89d8ba60134..4b3337a5c9d 100644
--- a/app/services/protected_branches/update_service.rb
+++ b/app/services/protected_branches/update_service.rb
@@ -1,13 +1,10 @@
module ProtectedBranches
class UpdateService < BaseService
- attr_reader :protected_branch
-
def execute(protected_branch)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
- @protected_branch = protected_branch
- @protected_branch.update(params)
- @protected_branch
+ protected_branch.update(params)
+ protected_branch
end
end
end
diff --git a/app/services/protected_tags/create_service.rb b/app/services/protected_tags/create_service.rb
new file mode 100644
index 00000000000..faba7865a17
--- /dev/null
+++ b/app/services/protected_tags/create_service.rb
@@ -0,0 +1,11 @@
+module ProtectedTags
+ class CreateService < BaseService
+ attr_reader :protected_tag
+
+ def execute
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+
+ project.protected_tags.create(params)
+ end
+ end
+end
diff --git a/app/services/protected_tags/update_service.rb b/app/services/protected_tags/update_service.rb
new file mode 100644
index 00000000000..aea6a48968d
--- /dev/null
+++ b/app/services/protected_tags/update_service.rb
@@ -0,0 +1,10 @@
+module ProtectedTags
+ class UpdateService < BaseService
+ def execute(protected_tag)
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_project, project)
+
+ protected_tag.update(params)
+ protected_tag
+ end
+ end
+end
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index 595653ea58a..49d45ec9dbd 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -7,6 +7,8 @@ module SlashCommands
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and hash of changes to be applied to a record.
def execute(content, issuable)
+ return [content, {}] unless current_user.can?(:use_slash_commands)
+
@issuable = issuable
@updates = {}
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 35cfcc3682e..c9e25c7aaa2 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -228,12 +228,10 @@ module SystemNoteService
def discussion_continued_in_issue(discussion, project, author, issue)
body = "created #{issue.to_reference} to continue this discussion"
+ note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note_params = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note_params[:type] = note_params.delete(:note_type)
-
- note = Note.create(note_params.merge(system: true))
- note.system_note_metadata = SystemNoteMetadata.new({ action: 'discussion' })
+ note = Note.create(note_attributes.merge(system: true))
+ note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion')
note
end
diff --git a/app/services/users/create_service.rb b/app/services/users/create_service.rb
index a847a71a66a..93ca7b1141a 100644
--- a/app/services/users/create_service.rb
+++ b/app/services/users/create_service.rb
@@ -11,7 +11,7 @@ module Users
user = User.new(build_user_params)
- if current_user&.is_admin?
+ if current_user&.admin?
if params[:reset_password]
@reset_token = user.generate_reset_token
params[:force_random_password] = true
@@ -47,7 +47,7 @@ module Users
private
def can_create_user?
- (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.is_admin?
+ (current_user.nil? && current_application_settings.signup_enabled?) || current_user&.admin?
end
# Allowed params for creating a user (admins only)
@@ -94,7 +94,7 @@ module Users
end
def build_user_params
- if current_user&.is_admin?
+ if current_user&.admin?
user_params = params.slice(*admin_create_params)
user_params[:created_by_id] = current_user&.id
diff --git a/app/services/users/destroy_service.rb b/app/services/users/destroy_service.rb
index a3b32a71a64..ba58b174cc0 100644
--- a/app/services/users/destroy_service.rb
+++ b/app/services/users/destroy_service.rb
@@ -26,7 +26,7 @@ module Users
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
end
- move_issues_to_ghost_user(user)
+ MigrateToGhostUserService.new(user).execute
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
namespace = user.namespace
@@ -35,22 +35,5 @@ module Users
user_data
end
-
- private
-
- def move_issues_to_ghost_user(user)
- # Block the user before moving issues to prevent a data race.
- # If the user creates an issue after `move_issues_to_ghost_user`
- # runs and before the user is destroyed, the destroy will fail with
- # an exception. We block the user so that issues can't be created
- # after `move_issues_to_ghost_user` runs and before the destroy happens.
- user.block
-
- ghost_user = User.ghost
-
- user.issues.update_all(author_id: ghost_user.id)
-
- user.reload
- end
end
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
new file mode 100644
index 00000000000..1e1ed1791ec
--- /dev/null
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -0,0 +1,59 @@
+# When a user is destroyed, some of their associated records are
+# moved to a "Ghost User", to prevent these associated records from
+# being destroyed.
+#
+# For example, all the issues/MRs a user has created are _not_ destroyed
+# when the user is destroyed.
+module Users
+ class MigrateToGhostUserService
+ extend ActiveSupport::Concern
+
+ attr_reader :ghost_user, :user
+
+ def initialize(user)
+ @user = user
+ end
+
+ def execute
+ # Block the user before moving records to prevent a data race.
+ # For example, if the user creates an issue after `migrate_issues`
+ # runs and before the user is destroyed, the destroy will fail with
+ # an exception.
+ user.block
+
+ user.transaction do
+ @ghost_user = User.ghost
+
+ migrate_issues
+ migrate_merge_requests
+ migrate_notes
+ migrate_abuse_reports
+ migrate_award_emoji
+ end
+
+ user.reload
+ end
+
+ private
+
+ def migrate_issues
+ user.issues.update_all(author_id: ghost_user.id)
+ end
+
+ def migrate_merge_requests
+ user.merge_requests.update_all(author_id: ghost_user.id)
+ end
+
+ def migrate_notes
+ user.notes.update_all(author_id: ghost_user.id)
+ end
+
+ def migrate_abuse_reports
+ user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
+ end
+
+ def migrate_award_emoji
+ user.award_emoji.update_all(user_id: ghost_user.id)
+ end
+ end
+end
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index d6ccf0dc92c..d2783ce5b2f 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -38,10 +38,6 @@ class FileUploader < GitlabUploader
File.join(dynamic_path_segment, @secret)
end
- def cache_dir
- File.join(base_dir, 'tmp', @project.path_with_namespace, @secret)
- end
-
def model
project
end
diff --git a/app/validators/cron_timezone_validator.rb b/app/validators/cron_timezone_validator.rb
new file mode 100644
index 00000000000..542c7d006ad
--- /dev/null
+++ b/app/validators/cron_timezone_validator.rb
@@ -0,0 +1,9 @@
+# CronTimezoneValidator
+#
+# Custom validator for CronTimezone.
+class CronTimezoneValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
+ record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid?
+ end
+end
diff --git a/app/validators/cron_validator.rb b/app/validators/cron_validator.rb
new file mode 100644
index 00000000000..981fade47a6
--- /dev/null
+++ b/app/validators/cron_validator.rb
@@ -0,0 +1,9 @@
+# CronValidator
+#
+# Custom validator for Cron.
+class CronValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
+ record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
+ end
+end
diff --git a/app/views/admin/abuse_reports/_abuse_report.html.haml b/app/views/admin/abuse_reports/_abuse_report.html.haml
index 05f3d9a3b50..18c6c559049 100644
--- a/app/views/admin/abuse_reports/_abuse_report.html.haml
+++ b/app/views/admin/abuse_reports/_abuse_report.html.haml
@@ -30,5 +30,5 @@
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-sm btn-block"
- else
.btn.btn-sm.disabled.btn-block
- Already Blocked
+ Already blocked
= link_to 'Remove report', [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml
index 5d51a2b5cbc..f4ba44096d3 100644
--- a/app/views/admin/application_settings/_form.html.haml
+++ b/app/views/admin/application_settings/_form.html.haml
@@ -148,7 +148,7 @@
Sign-in enabled
- if omniauth_enabled? && button_based_providers.any?
.form-group
- = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth Sign-In sources', class: 'control-label col-sm-2'
+ = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2'
.col-sm-10
.btn-group{ data: { toggle: 'buttons' } }
- oauth_providers_checkboxes.each do |source|
@@ -571,6 +571,7 @@
The multiplier can also have a decimal value.
The default value (1) is a reasonable choice for the majority of GitLab
installations. Set to 0 to completely disable polling.
+ = link_to icon('question-circle'), help_page_path('administration/polling')
.form-actions
= f.submit 'Save', class: 'btn btn-save'
diff --git a/app/views/admin/applications/index.html.haml b/app/views/admin/applications/index.html.haml
index b3a3b4c1d45..eb4293c7e37 100644
--- a/app/views/admin/applications/index.html.haml
+++ b/app/views/admin/applications/index.html.haml
@@ -4,7 +4,7 @@
%p.light
System OAuth applications don't belong to any user and can only be managed by admins
%hr
-%p= link_to 'New Application', new_admin_application_path, class: 'btn btn-success'
+%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success'
%table.table.table-striped
%thead
%tr
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index ebca9beb035..8c9fdc9ae42 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -125,7 +125,7 @@
= link_to admin_projects_path do
%h1= number_with_delimiter(Project.cached_count)
%hr
- = link_to('New Project', new_project_path, class: "btn btn-new")
+ = link_to('New project', new_project_path, class: "btn btn-new")
.col-sm-4
.light-well.well-centered
%h4 Users
@@ -133,7 +133,7 @@
= link_to admin_users_path do
%h1= number_with_delimiter(User.count)
%hr
- = link_to 'New User', new_admin_user_path, class: "btn btn-new"
+ = link_to 'New user', new_admin_user_path, class: "btn btn-new"
.col-sm-4
.light-well.well-centered
%h4 Groups
@@ -141,7 +141,7 @@
= link_to admin_groups_path do
%h1= number_with_delimiter(Group.count)
%hr
- = link_to 'New Group', new_admin_group_path, class: "btn btn-new"
+ = link_to 'New group', new_admin_group_path, class: "btn btn-new"
.row.prepend-top-10
.col-md-4
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 7b71bb5b287..007da8c1d29 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -3,7 +3,7 @@
%h3.page-title.deploy-keys-title
Public deploy keys (#{@deploy_keys.count})
.pull-right
- = link_to 'New Deploy Key', new_admin_deploy_key_path, class: 'btn btn-new btn-sm btn-inverted'
+ = link_to 'New deploy key', new_admin_deploy_key_path, class: 'btn btn-new btn-sm btn-inverted'
- if @deploy_keys.any?
.table-holder.deploy-keys-list
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 589f4557b52..d9f05003904 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -13,7 +13,7 @@
.col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f
- = render 'groups/group_lfs_settings', f: f
+ = render 'groups/group_admin_settings', f: f
- if @group.new_record?
.form-group
diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml
index 07775247cfd..e5f380c78e2 100644
--- a/app/views/admin/groups/index.html.haml
+++ b/app/views/admin/groups/index.html.haml
@@ -30,7 +30,7 @@
= link_to admin_groups_path(sort: sort_value_largest_group, name: project_name) do
= sort_title_largest_group
= link_to new_admin_group_path, class: "btn btn-new" do
- New Group
+ New group
%ul.content-list
= render @groups
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index 30b3fabdd7e..9149b8e7fb9 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -116,7 +116,7 @@
group members
%span.badge= @group.members.size
.pull-right
- = link_to icon('pencil-square-o', text: 'Manage Access'), polymorphic_url([@group, :members]), class: "btn btn-xs"
+ = link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@group, :members]), class: "btn btn-xs"
%ul.well-list.group-users-list.content-list
= render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false }
.panel-footer
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index e79303240f0..6a208d76a38 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -13,7 +13,7 @@
= button_to reset_health_check_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
data: { confirm: 'Are you sure you want to reset the health check token?' } do
- = icon('refresh')
+ = icon('spinner')
Reset health check access token
%p.light
Health information can be retrieved as plain text, JSON, or XML using:
diff --git a/app/views/admin/hooks/index.html.haml b/app/views/admin/hooks/index.html.haml
index 551edf14361..d9c7948763a 100644
--- a/app/views/admin/hooks/index.html.haml
+++ b/app/views/admin/hooks/index.html.haml
@@ -51,7 +51,7 @@
= f.check_box :enable_ssl_verification
%strong Enable SSL verification
.form-actions
- = f.submit "Add System Hook", class: "btn btn-create"
+ = f.submit "Add system hook", class: "btn btn-create"
%hr
- if @hooks.any?
@@ -62,7 +62,7 @@
- @hooks.each do |hook|
%li
.controls
- = link_to 'Test Hook', admin_hook_test_path(hook), class: "btn btn-sm"
+ = link_to 'Test hook', admin_hook_test_path(hook), class: "btn btn-sm"
= link_to 'Remove', admin_hook_path(hook), data: { confirm: 'Are you sure?' }, method: :delete, class: "btn btn-remove btn-sm"
.monospace= hook.url
%div
diff --git a/app/views/admin/identities/index.html.haml b/app/views/admin/identities/index.html.haml
index 741d111fb7d..ff67e59cdac 100644
--- a/app/views/admin/identities/index.html.haml
+++ b/app/views/admin/identities/index.html.haml
@@ -1,7 +1,7 @@
- page_title "Identities", @user.name, "Users"
= render 'admin/users/head'
-= link_to 'New Identity', new_admin_user_identity_path, class: 'pull-right btn btn-new'
+= link_to 'New identity', new_admin_user_identity_path, class: 'pull-right btn btn-new'
- if @identities.present?
.table-holder
%table.table
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 2967da6e692..08a8f627113 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -159,7 +159,7 @@
%span.badge= @group_members.size
.pull-right
= link_to admin_group_path(@group), class: 'btn btn-xs' do
- = icon('pencil-square-o', text: 'Manage Access')
+ = icon('pencil-square-o', text: 'Manage access')
%ul.well-list.content-list
= render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false }
.panel-footer
@@ -173,7 +173,7 @@
project members
%span.badge= @project.users.size
.pull-right
- = link_to icon('pencil-square-o', text: 'Manage Access'), polymorphic_url([@project, :members]), class: "btn btn-xs"
+ = link_to icon('pencil-square-o', text: 'Manage access'), polymorphic_url([@project, :members]), class: "btn btn-xs"
%ul.well-list.project_members.content-list
= render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false }
.panel-footer
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index 7d26864d0f3..f118804cace 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -21,7 +21,7 @@
= button_to reset_runners_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
data: { confirm: 'Are you sure you want to reset registration token?' } do
- = icon('refresh')
+ = icon('spinner')
Reset runners registration token
.bs-callout
diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml
index 33f6d847782..ea6a0c4fb77 100644
--- a/app/views/admin/spam_logs/_spam_log.html.haml
+++ b/app/views/admin/spam_logs/_spam_log.html.haml
@@ -35,5 +35,5 @@
= link_to 'Block user', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: "btn btn-xs"
- else
.btn.btn-xs.disabled
- Already Blocked
+ Already blocked
= link_to 'Remove log', [:admin, spam_log], remote: true, method: :delete, class: "btn btn-xs btn-close js-remove-tr"
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index a756cb7243a..8862455688f 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -37,6 +37,6 @@
- if user.can_be_removed? && can?(current_user, :destroy_user, @user)
%li.divider
%li
- = link_to 'Delete User', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
+ = link_to 'Delete user', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Consider cancelling this deletion and blocking the user instead. Are you sure?" },
class: 'btn btn-remove btn-block',
method: :delete
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 298cf0fa950..c7cd86527d3 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -33,7 +33,7 @@
= sort_title_recently_updated
= link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do
= sort_title_oldest_updated
- = link_to 'New User', new_admin_user_path, class: 'btn btn-new btn-search'
+ = link_to 'New user', new_admin_user_path, class: 'btn btn-new btn-search'
.nav-block
%ul.nav-links.wide.scrolling-tabs.white.scrolling-tabs
diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml
index c00c7f7407e..39c7fb0eba2 100644
--- a/app/views/ci/status/_badge.html.haml
+++ b/app/views/ci/status/_badge.html.haml
@@ -1,12 +1,13 @@
- status = local_assigns.fetch(:status)
-- link = local_assigns.fetch(:link, true)
-- css_classes = "ci-status ci-#{status.group}"
+- link = local_assigns.fetch(:link, true)
+- title = local_assigns.fetch(:title, nil)
+- css_classes = "ci-status ci-#{status.group} #{'has-tooltip' if title.present?}"
- if link && status.has_details?
- = link_to status.details_path, class: css_classes do
+ = link_to status.details_path, class: css_classes, title: title do
= custom_icon(status.icon)
= status.text
- else
- %span{ class: css_classes }
+ %span{ class: css_classes, title: title }
= custom_icon(status.icon)
= status.text
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 13eaba41f4c..0e848386ebb 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -11,4 +11,4 @@
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
= link_to new_group_path, class: "btn btn-new" do
- New Group
+ New group
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 4679b9549d1..64b737ee886 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -19,4 +19,4 @@
= render 'shared/projects/dropdown'
- if current_user.can_create_project?
= link_to new_project_path, class: 'btn btn-new' do
- New Project
+ New project
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index 10867140d4f..faa68468043 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -8,7 +8,7 @@
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
= icon('rss')
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
= render 'shared/issuable/filter', type: :issues
= render 'shared/issues'
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index e64c78c4cb8..12966c01950 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -4,7 +4,7 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New Merge Request"
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request"
= render 'shared/issuable/filter', type: :merge_requests
= render 'shared/merge_requests'
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index 505b475f55b..664ec618b79 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -5,7 +5,7 @@
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls
- = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New Milestone', include_groups: true
+ = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true
.milestones
%ul.content-list
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index ee452add394..e6d307e5568 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -3,4 +3,4 @@
%td.notes_line{ colspan: 2 }
%td.notes_content
.content{ class: ('hide' unless expanded) }
- = render "discussions/notes", discussion: discussion
+ = render partial: "discussions/notes", collection: discussions, as: :discussion
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 94408b92374..549364761e6 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -7,7 +7,7 @@
.diff-content.code.js-syntax-highlight
%table
- - discussions = { discussion.original_line_code => discussion }
+ - discussions = { discussion.original_line_code => [discussion] }
= render partial: "projects/diffs/line",
collection: discussion.truncated_diff_lines,
as: :line,
diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml
index 2d78c55211e..8440fb3d785 100644
--- a/app/views/discussions/_discussion.html.haml
+++ b/app/views/discussions/_discussion.html.haml
@@ -5,7 +5,7 @@
= link_to user_path(discussion.author) do
= image_tag avatar_icon(discussion.author), class: "avatar s40"
.timeline-content
- .discussion.js-toggle-container{ class: discussion.id, data: { discussion_id: discussion.id } }
+ .discussion.js-toggle-container{ data: { discussion_id: discussion.id } }
.discussion-header
.discussion-actions
%button.note-action-button.discussion-toggle-button.js-toggle-button{ type: "button" }
@@ -18,21 +18,24 @@
.inline.discussion-headline-light
= discussion.author.to_reference
- started a discussion on
+ started a discussion
- - if discussion.for_commit?
+ - url = discussion_diff_path(discussion)
+ - if discussion.for_commit? && @noteable != discussion.noteable
+ on
- commit = discussion.noteable
- if commit
commit
- = link_to commit.short_id, namespace_project_commit_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code), class: 'monospace'
+ = link_to commit.short_id, url, class: 'monospace'
- else
a deleted commit
- - else
- - if discussion.active?
- = link_to diffs_namespace_project_merge_request_path(discussion.project.namespace, discussion.project, discussion.noteable, anchor: discussion.line_code) do
+ - elsif discussion.diff_discussion?
+ on
+ = conditional_link_to url.present?, url do
+ - if discussion.active?
the diff
- - else
- an outdated diff
+ - else
+ an outdated diff
= time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago")
= render "discussions/headline", discussion: discussion
diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml
index 2789391819c..34789808f10 100644
--- a/app/views/discussions/_notes.html.haml
+++ b/app/views/discussions/_notes.html.haml
@@ -1,18 +1,20 @@
-%ul.notes{ data: { discussion_id: discussion.id } }
- = render partial: "projects/notes/note", collection: discussion.notes, as: :note
+.discussion-notes
+ %ul.notes{ data: { discussion_id: discussion.id } }
+ = render partial: "projects/notes/note", collection: discussion.notes, as: :note
-- if current_user
- .discussion-reply-holder
- - if discussion.diff_discussion?
- - line_type = local_assigns.fetch(:line_type, nil)
+ - if current_user
+ .discussion-reply-holder
+ - if discussion.potentially_resolvable?
+ - line_type = local_assigns.fetch(:line_type, nil)
+
+ .btn-group-justified.discussion-with-resolve-btn{ role: "group" }
+ .btn-group{ role: "group" }
+ = link_to_reply_discussion(discussion, line_type)
+
+ = render "discussions/resolve_all", discussion: discussion
- .btn-group-justified.discussion-with-resolve-btn{ role: "group" }
- .btn-group{ role: "group" }
- = link_to_reply_discussion(discussion, line_type)
- = render "discussions/resolve_all", discussion: discussion
- - if discussion.for_merge_request?
.btn-group.discussion-actions
= render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable
= render "discussions/jump_to_next", discussion: discussion
- - else
- = link_to_reply_discussion(discussion)
+ - else
+ = link_to_reply_discussion(discussion)
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 3a19e021643..253cd336882 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -1,20 +1,20 @@
-- expanded = discussion_left.try(:expanded?) || discussion_right.try(:expanded?)
+- expanded = [*discussions_left, *discussions_right].any?(&:expanded?)
%tr.notes_holder{ class: ('hide' unless expanded) }
- - if discussion_left
+ - if discussions_left
%td.notes_line.old
%td.notes_content.parallel.old
- .content{ class: ('hide' unless discussion_left.expanded?) }
- = render "discussions/notes", discussion: discussion_left, line_type: 'old'
+ .content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
+ = render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old'
- else
%td.notes_line.old= ("")
%td.notes_content.parallel.old
.content
- - if discussion_right
+ - if discussions_right
%td.notes_line.new
%td.notes_content.parallel.new
- .content{ class: ('hide' unless discussion_right.expanded?) }
- = render "discussions/notes", discussion: discussion_right, line_type: 'new'
+ .content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
+ = render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new'
- else
%td.notes_line.new= ("")
%td.notes_content.parallel.new
diff --git a/app/views/discussions/_resolve_all.html.haml b/app/views/discussions/_resolve_all.html.haml
index e30ee1b0e05..689a22acd27 100644
--- a/app/views/discussions/_resolve_all.html.haml
+++ b/app/views/discussions/_resolve_all.html.haml
@@ -1,9 +1,8 @@
-- if discussion.for_merge_request?
- %resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
- ":merge-request-id" => discussion.noteable.iid,
- ":can-resolve" => discussion.can_resolve?(current_user),
- "inline-template" => true }
- .btn-group{ role: "group", "v-if" => "showButton" }
- %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" }
- = icon("spinner spin", "v-show" => "loading")
- {{ buttonText }}
+%resolve-discussion-btn{ ":discussion-id" => "'#{discussion.id}'",
+ ":merge-request-id" => discussion.noteable.iid,
+ ":can-resolve" => discussion.can_resolve?(current_user),
+ "inline-template" => true }
+ .btn-group{ role: "group", "v-if" => "showButton" }
+ %button.btn.btn-default{ type: "button", "@click" => "resolve", ":disabled" => "loading", "v-cloak" => "true" }
+ = icon("spinner spin", "v-show" => "loading")
+ {{ buttonText }}
diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml
index a0bd14df209..53a33adc14d 100644
--- a/app/views/events/_event.html.haml
+++ b/app/views/events/_event.html.haml
@@ -3,8 +3,6 @@
.event-item-timestamp
#{time_ago_with_tooltip(event.created_at)}
- = author_avatar(event, size: 40)
-
- if event.created_project?
= render "events/event/created_project", event: event
- elsif event.push?
diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml
index a1a282178e7..1584695a62b 100644
--- a/app/views/events/_event_last_push.html.haml
+++ b/app/views/events/_event_last_push.html.haml
@@ -10,5 +10,5 @@
#{time_ago_with_tooltip(event.created_at)}
.pull-right
- = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do
- Create Merge Request
+ = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
+ Create merge request
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index 2fb6b5647da..01e72862114 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -1,3 +1,5 @@
+= icon_for_profile_event(event)
+
.event-title
%span.author_name= link_to_author event
%span{ class: event.action_name }
diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml
index 80cf2344fe1..d8e59be57bb 100644
--- a/app/views/events/event/_created_project.html.haml
+++ b/app/views/events/event/_created_project.html.haml
@@ -1,3 +1,5 @@
+= icon_for_profile_event(event)
+
.event-title
%span.author_name= link_to_author event
%span{ class: event.action_name }
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index 64b5a733b77..df4b9562215 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -1,3 +1,5 @@
+= icon_for_profile_event(event)
+
.event-title
%span.author_name= link_to_author event
= event.action_name
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index efd13aabf20..c0943100ae3 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -1,5 +1,7 @@
- project = event.project
+= icon_for_profile_event(event)
+
.event-title
%span.author_name= link_to_author event
%span.pushed #{event.action_name} #{event.ref_type}
@@ -48,4 +50,3 @@
.event-body
%ul.well-list.event_commits
= render "events/commit", commit: last_commit, project: project, event: event
-
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
new file mode 100644
index 00000000000..2ace1e2dd1e
--- /dev/null
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -0,0 +1,28 @@
+- if current_user.admin?
+ .form-group
+ = f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
+ .col-sm-10
+ .checkbox
+ = f.label :lfs_enabled do
+ = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
+ %strong
+ Allow projects within this group to use Git LFS
+ = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
+ %br/
+ %span.descr This setting can be overridden in each project.
+
+- if can? current_user, :admin_group, @group
+ .form-group
+ = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
+ .col-sm-10
+ .checkbox
+ = f.label :require_two_factor_authentication do
+ = f.check_box :require_two_factor_authentication
+ %strong
+ Require all users in this group to setup Two-factor authentication
+ = link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
+ .form-group
+ .col-sm-offset-2.col-sm-10
+ .checkbox
+ = f.text_field :two_factor_grace_period, class: 'form-control'
+ .help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
diff --git a/app/views/groups/_group_lfs_settings.html.haml b/app/views/groups/_group_lfs_settings.html.haml
deleted file mode 100644
index 3c622ca5c3c..00000000000
--- a/app/views/groups/_group_lfs_settings.html.haml
+++ /dev/null
@@ -1,11 +0,0 @@
-- if current_user.admin?
- .form-group
- .col-sm-offset-2.col-sm-10
- .checkbox
- = f.label :lfs_enabled do
- = f.check_box :lfs_enabled, checked: @group.lfs_enabled?
- %strong
- Allow projects within this group to use Git LFS
- = link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
- %br/
- %span.descr This setting can be overridden in each project.
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 80a77dab97f..7d5add3cc1c 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -27,7 +27,7 @@
.col-sm-offset-2.col-sm-10
= render 'shared/allow_request_access', form: f
- = render 'group_lfs_settings', f: f
+ = render 'group_admin_settings', f: f
.form-group
%hr
@@ -51,4 +51,4 @@
%strong Removed group can not be restored!
.form-actions
- = link_to 'Remove Group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove"
+ = link_to 'Remove group', @group, data: {confirm: 'Removed group can not be restored! Are you sure?'}, method: :delete, class: "btn btn-remove"
diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml
index f4c17dc2d16..182dbe2f98a 100644
--- a/app/views/groups/issues.html.haml
+++ b/app/views/groups/issues.html.haml
@@ -11,7 +11,7 @@
= icon('rss')
%span.icon-label
Subscribe
- = render 'shared/new_project_item_select', path: 'issues/new', label: "New Issue"
+ = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue"
= render 'shared/issuable/filter', type: :issues
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index 6893168f039..f91bee0b610 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -7,7 +7,7 @@
.nav-controls
- if can?(current_user, :admin_milestones, @group)
= link_to new_group_milestone_path(@group), class: "btn btn-new" do
- New Milestone
+ New milestone
.row-content-block
Only milestones from
diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml
index 63cadfca530..8d3aa4d1a74 100644
--- a/app/views/groups/milestones/new.html.haml
+++ b/app/views/groups/milestones/new.html.haml
@@ -39,5 +39,5 @@
= render "shared/milestones/form_dates", f: f
.form-actions
- = f.submit 'Create Milestone', class: "btn-create btn"
+ = f.submit 'Create milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml
index 83bdd654f27..62ad47972b9 100644
--- a/app/views/groups/projects.html.haml
+++ b/app/views/groups/projects.html.haml
@@ -7,7 +7,7 @@
- if can? current_user, :admin_group, @group
.controls
= link_to new_project_path(namespace_id: @group.id), class: "btn btn-sm btn-success" do
- New Project
+ New project
%ul.well-list
- @projects.each do |project|
%li
diff --git a/app/views/groups/subgroups.html.haml b/app/views/groups/subgroups.html.haml
index be809083139..8f0724c0677 100644
--- a/app/views/groups/subgroups.html.haml
+++ b/app/views/groups/subgroups.html.haml
@@ -9,7 +9,7 @@
.nav-controls
= form_tag request.path, method: :get do |f|
= search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name', class: 'form-control', spellcheck: false
- - if can? current_user, :admin_group, @group
+ - if can?(current_user, :create_subgroup, @group)
= link_to new_group_path(parent_id: @group.id), class: 'btn btn-new pull-right' do
New Subgroup
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 8e6da3fad90..700c5e61a14 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -17,6 +17,10 @@
%th Global Shortcuts
%tr
%td.shortcut
+ .key n
+ %td Main Navigation
+ %tr
+ %td.shortcut
.key s
%td Focus Search
%tr
@@ -39,24 +43,46 @@
.key
%i.fa.fa-arrow-up
%td Edit last comment (when focused on an empty textarea)
- %tbody
%tr
- %th
- %th Project Files browsing
+ %td.shortcut
+ .key shift t
+ %td
+ Go to todos
%tr
%td.shortcut
- .key
- %i.fa.fa-arrow-up
- %td Move selection up
+ .key shift a
+ %td
+ Go to the activity feed
%tr
%td.shortcut
- .key
- %i.fa.fa-arrow-down
- %td Move selection down
+ .key shift p
+ %td
+ Go to projects
%tr
%td.shortcut
- .key enter
- %td Open Selection
+ .key shift i
+ %td
+ Go to issues
+ %tr
+ %td.shortcut
+ .key shift m
+ %td
+ Go to merge requests
+ %tr
+ %td.shortcut
+ .key shift g
+ %td
+ Go to groups
+ %tr
+ %td.shortcut
+ .key shift l
+ %td
+ Go to milestones
+ %tr
+ %td.shortcut
+ .key shift s
+ %td
+ Go to snippets
%tbody
%tr
%th
@@ -79,51 +105,8 @@
%td.shortcut
.key esc
%td Go back
- %tbody
- %tr
- %th
- %th Project File
- %tr
- %td.shortcut
- .key y
- %td Go to file permalink
-
.col-lg-4
%table.shortcut-mappings
- %tbody.hidden-shortcut.project{ style: 'display:none' }
- %tr
- %th
- %th Global Dashboard
- %tr
- %td.shortcut
- .key g
- .key a
- %td
- Go to the activity feed
- %tr
- %td.shortcut
- .key g
- .key p
- %td
- Go to projects
- %tr
- %td.shortcut
- .key g
- .key i
- %td
- Go to issues
- %tr
- %td.shortcut
- .key g
- .key m
- %td
- Go to merge requests
- %tr
- %td.shortcut
- .key g
- .key t
- %td
- Go to todos
%tbody
%tr
%th
@@ -155,7 +138,7 @@
%tr
%td.shortcut
.key g
- .key b
+ .key j
%td
Go to jobs
%tr
@@ -167,7 +150,7 @@
%tr
%td.shortcut
.key g
- .key g
+ .key d
%td
Go to repository charts
%tr
@@ -179,7 +162,7 @@
%tr
%td.shortcut
.key g
- .key l
+ .key b
%td
Go to issue boards
%tr
@@ -196,12 +179,45 @@
Go to snippets
%tr
%td.shortcut
+ .key g
+ .key w
+ %td
+ Go to wiki
+ %tr
+ %td.shortcut
.key t
%td Go to finding file
%tr
%td.shortcut
.key i
%td New issue
+
+ %tbody
+ %tr
+ %th
+ %th Project Files browsing
+ %tr
+ %td.shortcut
+ .key
+ %i.fa.fa-arrow-up
+ %td Move selection up
+ %tr
+ %td.shortcut
+ .key
+ %i.fa.fa-arrow-down
+ %td Move selection down
+ %tr
+ %td.shortcut
+ .key enter
+ %td Open Selection
+ %tbody
+ %tr
+ %th
+ %th Project File
+ %tr
+ %td.shortcut
+ .key y
+ %td Go to file permalink
.col-lg-4
%table.shortcut-mappings
%tbody.hidden-shortcut.network{ style: 'display:none' }
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 1fb2c6271ad..615dd56afbd 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -225,7 +225,7 @@
%ul.dropdown-menu
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
.dropdown.inline.pull-right
%button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } }
Dropdown
@@ -233,7 +233,7 @@
%ul.dropdown-menu.dropdown-menu-align-right
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
.example
%div
.dropdown.inline
@@ -243,7 +243,7 @@
%ul.dropdown-menu.dropdown-menu-selectable
%li
%a.is-active{ href: "#" }
- Dropdown Option
+ Dropdown option
.example
%div
.dropdown.inline
@@ -252,7 +252,7 @@
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-selectable
.dropdown-title
- %span Dropdown Title
+ %span Dropdown title
%button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
= icon('times')
.dropdown-input
@@ -262,26 +262,26 @@
%ul
%li
%a.is-active{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li.divider
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
.dropdown-footer
%strong Tip:
If an author is not a member of this project, you can still filter by his name while using the search field.
@@ -291,7 +291,7 @@
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading
.dropdown-title
- %span Dropdown Title
+ %span Dropdown title
%button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
= icon('times')
.dropdown-input
@@ -301,26 +301,26 @@
%ul
%li
%a.is-active{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li.divider
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
%li
%a{ href: "#" }
- Dropdown Option
+ Dropdown option
.dropdown-footer
%strong Tip:
If an author is not a member of this project, you can still filter by his name while using the search field.
@@ -335,7 +335,7 @@
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user
.dropdown-title
- %span Dropdown Title
+ %span Dropdown title
%button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
= icon('times')
.dropdown-input
@@ -362,7 +362,7 @@
.dropdown-title
%button.dropdown-title-button.dropdown-menu-back{ aria: { label: "Go back" } }
= icon('arrow-left')
- %span Dropdown Title
+ %span Dropdown title
%button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } }
= icon('times')
.dropdown-input
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 4c6af0b7908..9c2da3a3eec 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -9,7 +9,7 @@
To import a GitHub project, you first need to authorize GitLab to access
the list of your GitHub repositories:
- = link_to 'List Your GitHub Repositories', status_import_github_path, class: 'btn btn-success'
+ = link_to 'List your GitHub repositories', status_import_github_path, class: 'btn btn-success'
%hr
@@ -28,7 +28,7 @@
= form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do
.form-group
= text_field_tag :personal_access_token, '', class: 'form-control', placeholder: "Personal Access Token", size: 40
- = submit_tag 'List Your GitHub Repositories', class: 'btn btn-success'
+ = submit_tag 'List your GitHub repositories', class: 'btn btn-success'
- unless github_import_configured?
%hr
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index a611481a0a4..19473b6ab27 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -28,9 +28,9 @@
= stylesheet_link_tag "application", media: "all"
= stylesheet_link_tag "print", media: "print"
- = javascript_include_tag(*webpack_asset_paths("runtime"))
- = javascript_include_tag(*webpack_asset_paths("common"))
- = javascript_include_tag(*webpack_asset_paths("main"))
+ = webpack_bundle_tag "runtime"
+ = webpack_bundle_tag "common"
+ = webpack_bundle_tag "main"
- if content_for?(:page_specific_javascripts)
= yield :page_specific_javascripts
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index 23abf6897d4..a9893dea68f 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -29,11 +29,11 @@
- if current_user
- if session[:impersonator_id]
%li.impersonation
- = link_to admin_impersonation_path, method: :delete, title: "Stop Impersonation", aria: { label: 'Stop Impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
+ = link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret fw')
- - if current_user.is_admin?
+ - if current_user.admin?
%li
- = link_to admin_root_path, title: 'Admin Area', aria: { label: "Admin Area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
- if current_user.can_create_project?
%li
@@ -47,17 +47,19 @@
%li
= link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('hashtag fw')
- %span.badge.issues-count
- = number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened))
+ - issues_count = cached_assigned_issuables_count(current_user, :issues, :opened)
+ %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
+ = number_with_delimiter(issues_count)
%li
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- %span.badge.merge-requests-count
- = number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened))
+ - merge_requests_count = cached_assigned_issuables_count(current_user, :merge_requests, :opened)
+ %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
+ = number_with_delimiter(merge_requests_count)
%li
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('check-circle fw')
- %span.badge.todos-count
+ %span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
%li.header-user.dropdown
= link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
new file mode 100644
index 00000000000..198f30a1dc4
--- /dev/null
+++ b/app/views/layouts/mailer.text.erb
@@ -0,0 +1,4 @@
+<%= yield -%>
+
+---
+You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml
deleted file mode 100644
index 6a9c6ced9cc..00000000000
--- a/app/views/layouts/mailer.text.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-= yield
-
-You're receiving this email because of your account on #{Gitlab.config.gitlab.host}.
-Manage all notifications: #{profile_notifications_url}
-Help: #{help_url}
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 15285ee32a3..444ecc414c0 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,10 +1,18 @@
%ul
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ P
%span
Projects
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ A
%span
Activity
- if koding_enabled?
@@ -13,25 +21,45 @@
%span
Koding
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to dashboard_groups_path, title: 'Groups' do
+ = link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ G
%span
Groups
= nav_link(controller: 'dashboard/milestones') do
- = link_to dashboard_milestones_path, title: 'Milestones' do
+ = link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ L
%span
Milestones
= nav_link(path: 'dashboard#issues') do
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ I
%span
Issues
.badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :issues, :opened))
= nav_link(path: 'dashboard#merge_requests') do
= link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ M
%span
Merge Requests
.badge= number_with_delimiter(cached_assigned_issuables_count(current_user, :merge_requests, :opened))
= nav_link(controller: 'dashboard/snippets') do
- = link_to dashboard_snippets_path, title: 'Snippets' do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ S
%span
Snippets
%li.divider
diff --git a/app/views/layouts/nav/_explore.html.haml b/app/views/layouts/nav/_explore.html.haml
index 3a1fcd00e9c..0cb367452f7 100644
--- a/app/views/layouts/nav/_explore.html.haml
+++ b/app/views/layouts/nav/_explore.html.haml
@@ -1,16 +1,29 @@
%ul
= nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
- = link_to explore_root_path, title: 'Projects' do
+ = link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ P
%span
Projects
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
- = link_to explore_groups_path, title: 'Groups' do
+ = link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ G
%span
Groups
= nav_link(controller: :snippets) do
- = link_to explore_snippets_path, title: 'Snippets' do
+ = link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
+ .shortcut-mappings
+ .key
+ = icon('arrow-up', 'aria-label' => 'hidden')
+ S
%span
Snippets
+ %li.divider
= nav_link(controller: :help) do
= link_to help_path, title: 'Help' do
%span
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 76268c1b705..40bf45cece7 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -25,8 +25,8 @@
- if @labels_url
adjust your #{link_to 'label subscriptions', @labels_url}.
- else
- - if @sent_notification_url
- = link_to "unsubscribe", @sent_notification_url
+ - if @unsubscribe_url
+ = link_to "unsubscribe", @unsubscribe_url
from this thread or
adjust your notification settings.
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
new file mode 100644
index 00000000000..b4ce02eead8
--- /dev/null
+++ b/app/views/layouts/notify.text.erb
@@ -0,0 +1,12 @@
+<%= yield -%>
+
+---
+<% if @target_url -%>
+<% if @reply_by_email -%>
+<%= "Reply to this email directly or view it on GitLab: #{@target_url}" -%>
+<% else -%>
+<%= "View it on GitLab: #{@target_url}" -%>
+<% end -%>
+<% end -%>
+
+You're receiving this email because of your account on <%= Gitlab.config.gitlab.host %>.
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
new file mode 100644
index 00000000000..a80518f7986
--- /dev/null
+++ b/app/views/notify/_note_email.html.haml
@@ -0,0 +1,37 @@
+- discussion = @note.discussion if @note.part_of_discussion?
+- if discussion
+ %p.details
+ = succeed ':' do
+ = link_to @note.author_name, user_url(@note.author)
+
+ - if discussion.diff_discussion?
+ - if discussion.new_discussion?
+ started a new discussion
+ - else
+ commented on a discussion
+
+ on #{link_to discussion.file_path, @target_url}
+ - else
+ - if discussion.new_discussion?
+ started a new discussion
+ - else
+ commented on a #{link_to 'discussion', @target_url}
+
+- elsif current_application_settings.email_author_in_body
+ %p.details
+ #{link_to @note.author_name, user_url(@note.author)} commented:
+
+- if discussion&.diff_discussion?
+ = content_for :head do
+ = stylesheet_link_tag 'mailers/highlighted_diff_email'
+
+ %table
+ = render partial: "projects/diffs/line",
+ collection: discussion.truncated_diff_lines,
+ as: :line,
+ locals: { diff_file: discussion.diff_file,
+ plain: true,
+ email: true }
+
+%div
+ = markdown(@note.note, pipeline: :email, author: @note.author)
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
new file mode 100644
index 00000000000..cb2e7fab6d5
--- /dev/null
+++ b/app/views/notify/_note_email.text.erb
@@ -0,0 +1,26 @@
+<% discussion = @note.discussion if @note.part_of_discussion? -%>
+<% if discussion && !discussion.individual_note? -%>
+<%= @note.author_name -%>
+<% if discussion.new_discussion? -%>
+<%= " started a new discussion" -%>
+<% else -%>
+<%= " commented on a discussion" -%>
+<% end -%>
+<% if discussion.diff_discussion? -%>
+<%= " on #{discussion.file_path}" -%>
+<% end -%>
+<%= ":" -%>
+
+
+<% elsif current_application_settings.email_author_in_body -%>
+<%= "#{@note.author_name} commented:" -%>
+
+
+<% end -%>
+<% if discussion&.diff_discussion? -%>
+<% discussion.truncated_diff_lines(highlight: false).each do |line| -%>
+<%= "> #{line.text}\n" -%>
+<% end -%>
+
+<% end -%>
+<%= @note.note -%>
diff --git a/app/views/notify/_note_message.html.haml b/app/views/notify/_note_message.html.haml
deleted file mode 100644
index e9c66170877..00000000000
--- a/app/views/notify/_note_message.html.haml
+++ /dev/null
@@ -1,5 +0,0 @@
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @note.author_name, user_url(@note.author)} wrote:
-%div
- = markdown(@note.note, pipeline: :email, author: @note.author)
diff --git a/app/views/notify/_note_message.text.erb b/app/views/notify/_note_message.text.erb
deleted file mode 100644
index f82cbc9a3fc..00000000000
--- a/app/views/notify/_note_message.text.erb
+++ /dev/null
@@ -1,5 +0,0 @@
-<% if current_application_settings.email_author_in_body %>
- <%= @note.author_name %> wrote:
-<% end -%>
-
-<%= @note.note %>
diff --git a/app/views/notify/_note_mr_or_commit_email.html.haml b/app/views/notify/_note_mr_or_commit_email.html.haml
deleted file mode 100644
index edf8dfe7e9e..00000000000
--- a/app/views/notify/_note_mr_or_commit_email.html.haml
+++ /dev/null
@@ -1,18 +0,0 @@
-= content_for :head do
- = stylesheet_link_tag 'mailers/highlighted_diff_email'
-
-New comment
-
-- if @discussion && @discussion.diff_file
- on
- = link_to @note.diff_file.file_path, @target_url, class: 'details'
- \:
- %table
- = render partial: "projects/diffs/line",
- collection: @discussion.truncated_diff_lines,
- as: :line,
- locals: { diff_file: @note.diff_file,
- plain: true,
- email: true }
-
-= render 'note_message'
diff --git a/app/views/notify/_note_mr_or_commit_email.text.erb b/app/views/notify/_note_mr_or_commit_email.text.erb
deleted file mode 100644
index b4fcdf6b1e9..00000000000
--- a/app/views/notify/_note_mr_or_commit_email.text.erb
+++ /dev/null
@@ -1,8 +0,0 @@
-<% if @discussion && @discussion.diff_file -%>
- on <%= @note.diff_file.file_path -%>
-<% end -%>:
-
-<%= url %>
-
-<%= render 'simple_diff' if @discussion -%>
-<%= render 'note_message' %>
diff --git a/app/views/notify/_simple_diff.text.erb b/app/views/notify/_simple_diff.text.erb
deleted file mode 100644
index c28d1cc34d3..00000000000
--- a/app/views/notify/_simple_diff.text.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-<% @discussion.truncated_diff_lines(highlight: false).each do |line| %>
-> <%= line.text %>
-<% end %>
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index d1855568215..c762578971a 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -1,9 +1,11 @@
- if current_application_settings.email_author_in_body
- %div
- #{link_to @issue.author_name, user_url(@issue.author)} wrote:
-- if @issue.description
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
+ %p.details
+ #{link_to @issue.author_name, user_url(@issue.author)} created an issue:
- if @issue.assignee_id.present?
%p
Assignee: #{@issue.assignee_name}
+
+- if @issue.description
+ %div
+ = markdown(@issue.description, pipeline: :email, author: @issue.author)
diff --git a/app/views/notify/new_mention_in_issue_email.html.haml b/app/views/notify/new_mention_in_issue_email.html.haml
index 02f21baa368..6b45ac265f7 100644
--- a/app/views/notify/new_mention_in_issue_email.html.haml
+++ b/app/views/notify/new_mention_in_issue_email.html.haml
@@ -1,12 +1,4 @@
%p
You have been mentioned in an issue.
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @issue.author_name, user_url(@issue.author)} wrote:
-- if @issue.description
- = markdown(@issue.description, pipeline: :email, author: @issue.author)
-
-- if @issue.assignee_id.present?
- %p
- Assignee: #{@issue.assignee_name}
+= render template: 'notify/new_issue_email'
diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml
index cbd434be02a..b061f9c106e 100644
--- a/app/views/notify/new_mention_in_merge_request_email.html.haml
+++ b/app/views/notify/new_mention_in_merge_request_email.html.haml
@@ -1,15 +1,4 @@
%p
You have been mentioned in Merge Request #{@merge_request.to_reference}
-- if current_application_settings.email_author_in_body
- %div
- #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote:
-%p.details
- != merge_path_description(@merge_request, '&rarr;')
-
-- if @merge_request.assignee_id.present?
- %p
- Assignee: #{@merge_request.author_name} &rarr; #{@merge_request.assignee_name}
-
-- if @merge_request.description
- = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
+= render template: 'notify/new_merge_request_email'
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 8890b300f7d..951c96bdb9c 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -1,12 +1,14 @@
- if current_application_settings.email_author_in_body
- %div
- #{link_to @merge_request.author_name, user_url(@merge_request.author)} wrote:
+ %p.details
+ #{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
+
%p.details
!= merge_path_description(@merge_request, '&rarr;')
- if @merge_request.assignee_id.present?
%p
- Assignee: #{@merge_request.author_name} &rarr; #{@merge_request.assignee_name}
+ Assignee: #{@merge_request.assignee_name}
- if @merge_request.description
- = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
+ %div
+ = markdown(@merge_request.description, pipeline: :email, author: @merge_request.author)
diff --git a/app/views/notify/note_commit_email.html.haml b/app/views/notify/note_commit_email.html.haml
index 0a650e3b2ca..5e69f01a486 100644
--- a/app/views/notify/note_commit_email.html.haml
+++ b/app/views/notify/note_commit_email.html.haml
@@ -1,2 +1 @@
-%p.details
- = render 'note_mr_or_commit_email'
+= render 'note_email'
diff --git a/app/views/notify/note_commit_email.text.erb b/app/views/notify/note_commit_email.text.erb
index 6aa085a172e..413d9e6e9ac 100644
--- a/app/views/notify/note_commit_email.text.erb
+++ b/app/views/notify/note_commit_email.text.erb
@@ -1,2 +1 @@
-New comment for Commit <%= @commit.short_id -%>
-<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url } %>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_issue_email.html.haml b/app/views/notify/note_issue_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_issue_email.html.haml
+++ b/app/views/notify/note_issue_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_issue_email.text.erb b/app/views/notify/note_issue_email.text.erb
index e33cbcd70f2..413d9e6e9ac 100644
--- a/app/views/notify/note_issue_email.text.erb
+++ b/app/views/notify/note_issue_email.text.erb
@@ -1,9 +1 @@
-New comment for Issue <%= @issue.iid %>
-
-<%= url_for(namespace_project_issue_url(@issue.project.namespace, @issue.project, @issue, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
-
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_merge_request_email.html.haml b/app/views/notify/note_merge_request_email.html.haml
index 0a650e3b2ca..5e69f01a486 100644
--- a/app/views/notify/note_merge_request_email.html.haml
+++ b/app/views/notify/note_merge_request_email.html.haml
@@ -1,2 +1 @@
-%p.details
- = render 'note_mr_or_commit_email'
+= render 'note_email'
diff --git a/app/views/notify/note_merge_request_email.text.erb b/app/views/notify/note_merge_request_email.text.erb
index 2ce64c494cf..413d9e6e9ac 100644
--- a/app/views/notify/note_merge_request_email.text.erb
+++ b/app/views/notify/note_merge_request_email.text.erb
@@ -1,2 +1 @@
-New comment for Merge Request <%= @merge_request.to_reference -%>
-<%= render partial: 'note_mr_or_commit_email', locals: { url: @target_url }%>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_personal_snippet_email.html.haml b/app/views/notify/note_personal_snippet_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_personal_snippet_email.html.haml
+++ b/app/views/notify/note_personal_snippet_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_personal_snippet_email.text.erb b/app/views/notify/note_personal_snippet_email.text.erb
index b2a8809a23b..413d9e6e9ac 100644
--- a/app/views/notify/note_personal_snippet_email.text.erb
+++ b/app/views/notify/note_personal_snippet_email.text.erb
@@ -1,8 +1 @@
-New comment for Snippet <%= @snippet.id %>
-
-<%= url_for(snippet_url(@snippet, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
+<%= render 'note_email' %>
diff --git a/app/views/notify/note_snippet_email.html.haml b/app/views/notify/note_snippet_email.html.haml
index 2fa2f784661..5e69f01a486 100644
--- a/app/views/notify/note_snippet_email.html.haml
+++ b/app/views/notify/note_snippet_email.html.haml
@@ -1 +1 @@
-= render 'note_message'
+= render 'note_email'
diff --git a/app/views/notify/note_snippet_email.text.erb b/app/views/notify/note_snippet_email.text.erb
index 4d5a406f4b0..413d9e6e9ac 100644
--- a/app/views/notify/note_snippet_email.text.erb
+++ b/app/views/notify/note_snippet_email.text.erb
@@ -1,8 +1 @@
-New comment for Snippet <%= @snippet.id %>
-
-<%= url_for(namespace_project_snippet_url(@snippet.project.namespace, @snippet.project, @snippet, anchor: "note_#{@note.id}")) %>
-
-
-Author: <%= @note.author_name %>
-
-<%= @note.note %>
+<%= render 'note_email' %>
diff --git a/app/views/notify/pipeline_failed_email.html.haml b/app/views/notify/pipeline_failed_email.html.haml
index 4beb6fcee5d..a83faa839df 100644
--- a/app/views/notify/pipeline_failed_email.html.haml
+++ b/app/views/notify/pipeline_failed_email.html.haml
@@ -137,6 +137,6 @@
- if build.has_trace?
%td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" }
%pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" }
- = build.trace_html(last_lines: 10).html_safe
+ = build.trace.html(last_lines: 10).html_safe
- else
%td{ colspan: "2" }
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index c1a4ea40cf5..294238eee51 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
Stage: <%= build.stage %>
Name: <%= build.name %>
<% if build.has_trace? -%>
-Trace: <%= build.trace_with_state(last_lines: 10)[:text] %>
+Trace: <%= build.trace.raw(last_lines: 10) %>
<% end -%>
<% end -%>
diff --git a/app/views/notify/project_was_exported_email.html.haml b/app/views/notify/project_was_exported_email.html.haml
index 76440926a2b..3def26342a1 100644
--- a/app/views/notify/project_was_exported_email.html.haml
+++ b/app/views/notify/project_was_exported_email.html.haml
@@ -2,7 +2,7 @@
Project #{@project.name} was exported successfully.
%p
The project export can be downloaded from:
- = link_to download_export_namespace_project_url(@project.namespace, @project), rel: 'nofollow', download: '', do
+ = link_to download_export_namespace_project_url(@project.namespace, @project), rel: 'nofollow', download: '' do
= @project.name_with_namespace + " export"
%p
The download link will expire in 24 hours.
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index 5ce2220c907..d843cacd52d 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -49,14 +49,14 @@
%p
Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
- if current_user.two_factor_enabled?
- = link_to 'Manage Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-info'
+ = link_to 'Manage two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-info'
= link_to 'Disable', profile_two_factor_auth_path,
method: :delete,
data: { confirm: "Are you sure? This will invalidate your registered applications and U2F devices." },
class: 'btn btn-danger'
- else
.append-bottom-10
- = link_to 'Enable Two-Factor Authentication', profile_two_factor_auth_path, class: 'btn btn-success'
+ = link_to 'Enable two-factor authentication', profile_two_factor_auth_path, class: 'btn btn-success'
%hr
- if button_based_providers.any?
diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml
index dc499be885b..f5a323dbaf8 100644
--- a/app/views/profiles/emails/index.html.haml
+++ b/app/views/profiles/emails/index.html.haml
@@ -33,17 +33,17 @@
%li
= @primary
%span.pull-right
- %span.label.label-success Primary Email
+ %span.label.label-success Primary email
- if @primary === current_user.public_email
- %span.label.label-info Public Email
+ %span.label.label-info Public email
- if @primary === current_user.notification_email
- %span.label.label-info Notification Email
+ %span.label.label-info Notification email
- @emails.each do |email|
%li
= email.email
%span.pull-right
- if email.email === current_user.public_email
- %span.label.label-info Public Email
+ %span.label.label-info Public email
- if email.email === current_user.notification_email
- %span.label.label-info Notification Email
+ %span.label.label-info Notification email
= link_to 'Remove', profile_email_path(email), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-warning prepend-left-10'
diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml
index 0645ecad496..c852107e69a 100644
--- a/app/views/profiles/personal_access_tokens/index.html.haml
+++ b/app/views/profiles/personal_access_tokens/index.html.haml
@@ -19,7 +19,7 @@
Your New Personal Access Token
.form-group
= text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control", 'aria-describedby' => "created-personal-access-token-help-block"
- = clipboard_button(clipboard_text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
+ = clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left")
%span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again.
%hr
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 7ade5f00d47..0ff05098cd7 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -44,7 +44,7 @@
= label_tag :pin_code, nil, class: "label-light"
= text_field_tag :pin_code, nil, class: "form-control", required: true
.prepend-top-default
- = submit_tag 'Register with Two-Factor App', class: 'btn btn-success'
+ = submit_tag 'Register with two-factor app', class: 'btn btn-success'
%hr
diff --git a/app/views/projects/_commit_button.html.haml b/app/views/projects/_commit_button.html.haml
index 640612ca433..b55dc3dce5c 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-create'
+ = button_tag 'Commit changes', class: 'btn commit-btn js-commit-button btn-create'
= link_to 'Cancel', cancel_path,
class: 'btn btn-cancel', data: {confirm: leave_edit_message}
diff --git a/app/views/projects/_find_file_link.html.haml b/app/views/projects/_find_file_link.html.haml
index dbb33090670..3feb11645a0 100644
--- a/app/views/projects/_find_file_link.html.haml
+++ b/app/views/projects/_find_file_link.html.haml
@@ -1,3 +1,3 @@
= link_to namespace_project_find_file_path(@project.namespace, @project, @ref), class: 'btn btn-grouped shortcuts-find-file', rel: 'nofollow' do
= icon('search')
- %span Find File
+ %span Find file
diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml
index a08436715d2..768bc1fb323 100644
--- a/app/views/projects/_last_push.html.haml
+++ b/app/views/projects/_last_push.html.haml
@@ -10,9 +10,9 @@
- if @project && event.project != @project
%span at
%strong= link_to_project event.project
- = clipboard_button(clipboard_text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
+ = clipboard_button(text: event.ref_name, class: 'btn-clipboard btn-transparent', title: 'Copy branch to clipboard')
#{time_ago_with_tooltip(event.created_at)}
.pull-right
- = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do
- Create Merge Request
+ = link_to new_mr_path_from_push_event(event), title: "New merge request", class: "btn btn-info btn-sm" do
+ Create merge request
diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml
index 4ad77b6266d..35885b2c7b4 100644
--- a/app/views/projects/blame/show.html.haml
+++ b/app/views/projects/blame/show.html.haml
@@ -7,7 +7,7 @@
#blob-content-holder.tree-holder
.file-holder
- = render "projects/blob/header", blob: @blob
+ = render "projects/blob/header", blob: @blob, blame: true
.table-responsive.file-content.blame.code.js-syntax-highlight
%table
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index 2b2ee6ed987..9aafff343f0 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -25,4 +25,10 @@
#blob-content-holder.blob-content-holder
%article.file-holder
= render "projects/blob/header", blob: blob
- = render blob.to_partial_path(@project), blob: blob
+
+ - if blob.empty?
+ .file-content.code
+ .nothing-here-block
+ Empty file
+ - else
+ = render blob.to_partial_path(@project), blob: blob
diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml
index deeeae3d64a..7a4a293548c 100644
--- a/app/views/projects/blob/_header.html.haml
+++ b/app/views/projects/blob/_header.html.haml
@@ -1,3 +1,4 @@
+- blame = local_assigns.fetch(:blame, false)
.js-file-title.file-title-flex-parent
.file-header-content
= blob_icon blob.mode, blob.name
@@ -12,15 +13,15 @@
.file-actions.hidden-xs
.btn-group{ role: "group" }<
- = copy_blob_content_button(blob) if blob_text_viewable?(blob)
+ = copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob)
= open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id))
= view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }<
-# only show normal/blame view links for text files
- if blob_text_viewable?(blob)
- - if current_page? namespace_project_blame_path(@project.namespace, @project, @id)
- = link_to 'Normal View', namespace_project_blob_path(@project.namespace, @project, @id),
+ - if blame
+ = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
class: 'btn btn-sm'
- else
= link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id),
@@ -32,8 +33,15 @@
= link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
- - if current_user
- .btn-group{ role: "group" }<
- = edit_blob_link if blob_text_viewable?(blob)
+ .btn-group{ role: "group" }<
+ = edit_blob_link if blob_text_viewable?(blob)
+ - if current_user
= replace_blob_link
= delete_blob_link
+- if current_user
+ .js-file-fork-suggestion-section.file-fork-suggestion.hidden
+ %span.file-fork-suggestion-note
+ You don't have permission to edit this file. Try forking this project to edit the file.
+ = link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new'
+ %button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' }
+ Cancel
diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml
index ea3cecb86a9..73877d730f5 100644
--- a/app/views/projects/blob/_image.html.haml
+++ b/app/views/projects/blob/_image.html.haml
@@ -1,15 +1,2 @@
.file-content.image_file
- - if blob.svg?
- - if blob.size_within_svg_limits?
- -# We need to scrub SVG but we cannot do so in the RawController: it would
- -# be wrong/strange if RawController modified the data.
- - blob.load_all_data!(@repository)
- - blob = sanitize_svg(blob)
- %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: "#{blob.name}" }
- - else
- .nothing-here-block
- The SVG could not be displayed as it is too large, you can
- #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')}
- instead.
- - else
- %img{ src: namespace_project_raw_path(@project.namespace, @project, tree_join(@commit.id, blob.path)), alt: "#{blob.name}" }
+ %img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name }
diff --git a/app/views/projects/blob/_markup.html.haml b/app/views/projects/blob/_markup.html.haml
new file mode 100644
index 00000000000..4ee4b03ff04
--- /dev/null
+++ b/app/views/projects/blob/_markup.html.haml
@@ -0,0 +1,4 @@
+- blob.load_all_data!(@repository)
+
+.file-content.wiki
+ = render_markup(blob.name, blob.data)
diff --git a/app/views/projects/blob/_svg.html.haml b/app/views/projects/blob/_svg.html.haml
new file mode 100644
index 00000000000..93be58fc658
--- /dev/null
+++ b/app/views/projects/blob/_svg.html.haml
@@ -0,0 +1,9 @@
+- if blob.size_within_svg_limits?
+ -# We need to scrub SVG but we cannot do so in the RawController: it would
+ -# be wrong/strange if RawController modified the data.
+ - blob.load_all_data!(@repository)
+ - blob = sanitize_svg(blob)
+ .file-content.image_file
+ %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name }
+- else
+ = render 'too_large'
diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml
index d52733d2bd6..2a178325041 100644
--- a/app/views/projects/blob/_template_selectors.html.haml
+++ b/app/views/projects/blob/_template_selectors.html.haml
@@ -5,7 +5,7 @@
.template-type-selector.js-template-type-selector-wrap.hidden
= dropdown_tag("Choose type", options: { toggle_class: 'btn js-template-type-selector', title: "Choose a template type" } )
.license-selector.js-license-selector-wrap.js-template-selector-wrap.hidden
- = dropdown_tag("Apply a License template", options: { toggle_class: 'btn js-license-selector', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } )
+ = dropdown_tag("Apply a license template", options: { toggle_class: 'btn js-license-selector', title: "Apply a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } )
.gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden
= dropdown_tag("Apply a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Apply a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } )
.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden
diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml
index 7b16d266982..20638f6961d 100644
--- a/app/views/projects/blob/_text.html.haml
+++ b/app/views/projects/blob/_text.html.haml
@@ -1,19 +1,2 @@
-- if blob.only_display_raw?
- .file-content.code
- .nothing-here-block
- File too large, you can
- = succeed '.' do
- = link_to 'view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer'
-
-- else
- - blob.load_all_data!(@repository)
-
- - if blob.empty?
- .file-content.code
- .nothing-here-block Empty file
- - else
- - if markup?(blob.name)
- .file-content.wiki
- = render_markup(blob.name, blob.data)
- - else
- = render 'shared/file_highlight', blob: blob, repository: @repository
+- blob.load_all_data!(@repository)
+= render 'shared/file_highlight', blob: blob, repository: @repository
diff --git a/app/views/projects/blob/_too_large.html.haml b/app/views/projects/blob/_too_large.html.haml
new file mode 100644
index 00000000000..a505f87df40
--- /dev/null
+++ b/app/views/projects/blob/_too_large.html.haml
@@ -0,0 +1,5 @@
+.file-content.code
+ .nothing-here-block
+ The file could not be displayed as it is too large, you can
+ #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')}
+ instead.
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 9eb610ba9c0..0f9ef3eded3 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -15,13 +15,13 @@
%span.label.label-info.has-tooltip{ title: "Merged into #{@repository.root_ref}" }
merged
- - if @project.protected_branch? branch.name
+ - if protected_branch?(@project, branch)
%span.label.label-success
protected
.controls.hidden-xs<
- if merge_project && create_mr_button?(@repository.root_ref, branch.name)
= link_to create_mr_path(@repository.root_ref, branch.name), class: 'btn btn-default' do
- Merge Request
+ Merge request
- if branch.name != @repository.root_ref
= link_to namespace_project_compare_index_path(@project.namespace, @project, from: @repository.root_ref, to: branch.name), class: "btn btn-default #{'prepend-left-10' unless merge_project}", method: :post, title: "Compare" do
diff --git a/app/views/projects/builds/_header.html.haml b/app/views/projects/builds/_header.html.haml
index 7eb17e887e7..104db85809c 100644
--- a/app/views/projects/builds/_header.html.haml
+++ b/app/views/projects/builds/_header.html.haml
@@ -1,14 +1,16 @@
+- pipeline = @build.pipeline
+
.content-block.build-header.top-area
.header-content
- = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false
+ = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title
Job
%strong.js-build-id ##{@build.id}
in pipeline
- = link_to pipeline_path(@build.pipeline) do
- %strong ##{@build.pipeline.id}
+ = link_to pipeline_path(pipeline) do
+ %strong ##{pipeline.id}
for commit
- = link_to namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha) do
- %strong= @build.pipeline.short_sha
+ = link_to namespace_project_commit_path(@project.namespace, @project, pipeline.sha) do
+ %strong= pipeline.short_sha
from
= link_to namespace_project_commits_path(@project.namespace, @project, @build.ref) do
%code
diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml
index 6f45d5b0689..c4159ce1a36 100644
--- a/app/views/projects/builds/_sidebar.html.haml
+++ b/app/views/projects/builds/_sidebar.html.haml
@@ -68,7 +68,7 @@
- elsif @build.runner
\##{@build.runner.id}
.btn-group.btn-group-justified{ role: :group }
- - if @build.has_trace_file?
+ - if @build.has_trace?
= link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
- if @build.active?
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
@@ -136,7 +136,7 @@
- else
= build.id
- if build.retried?
- %i.fa.fa-refresh.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
+ %i.fa.fa-spinner.has-tooltip{ data: { container: 'body', placement: 'bottom' }, title: 'Job was retried' }
:javascript
new Sidebar();
diff --git a/app/views/projects/builds/_table.html.haml b/app/views/projects/builds/_table.html.haml
index acfdb250aff..82806f022ee 100644
--- a/app/views/projects/builds/_table.html.haml
+++ b/app/views/projects/builds/_table.html.haml
@@ -20,6 +20,6 @@
%th Coverage
%th
- = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin }
+ = render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
= paginate builds, theme: 'gitlab'
diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml
index 5ffc0e20d10..65162aacda1 100644
--- a/app/views/projects/builds/index.html.haml
+++ b/app/views/projects/builds/index.html.haml
@@ -17,7 +17,7 @@
= link_to 'Get started with CI/CD Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
= link_to ci_lint_path, class: 'btn btn-default' do
- %span CI Lint
+ %span CI lint
.content-list.builds-content-list
= render "table", builds: @builds, project: @project
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index d5fe771613c..0faad57a312 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -71,6 +71,11 @@
= custom_icon('scroll_down_hover_active')
#up-build-trace
%pre.build-trace#build-trace
+ .js-truncated-info.truncated-info.hidden
+ %span<
+ Showing last
+ %span.js-truncated-info-size><
+ KiB of log
%code.bash.js-build-output
.build-loader-animation.js-build-refresh
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 769640c4842..3e1c8f25dea 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -1,109 +1,110 @@
+- job = build.present(current_user: current_user)
+- pipeline = job.pipeline
- admin = local_assigns.fetch(:admin, false)
- ref = local_assigns.fetch(:ref, nil)
- commit_sha = local_assigns.fetch(:commit_sha, nil)
- retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
-- coverage = local_assigns.fetch(:coverage, false)
- allow_retry = local_assigns.fetch(:allow_retry, false)
%tr.build.commit{ class: ('retried' if retried) }
%td.status
- = render "ci/status/badge", status: build.detailed_status(current_user)
+ = render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
%td.branch-commit
- - if can?(current_user, :read_build, build)
- = link_to namespace_project_build_url(build.project.namespace, build.project, build) do
- %span.build-link ##{build.id}
+ - if can?(current_user, :read_build, job)
+ = link_to namespace_project_build_url(job.project.namespace, job.project, job) do
+ %span.build-link ##{job.id}
- else
- %span.build-link ##{build.id}
+ %span.build-link ##{job.id}
- if ref
- - if build.ref
+ - if job.ref
.icon-container
- = build.tag? ? icon('tag') : icon('code-fork')
- = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref), class: "monospace branch-name"
+ = job.tag? ? icon('tag') : icon('code-fork')
+ = link_to job.ref, namespace_project_commits_path(job.project.namespace, job.project, job.ref), class: "monospace branch-name"
- else
.light none
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "commit-id monospace"
+ = link_to job.short_sha, namespace_project_commit_path(job.project.namespace, job.project, job.sha), class: "commit-id monospace"
- - if build.stuck?
+ - if job.stuck?
= icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
- if retried
- = icon('refresh', class: 'text-warning has-tooltip', title: 'Job was retried')
+ = icon('spinner', class: 'text-warning has-tooltip', title: 'Job was retried')
.label-container
- - if build.tags.any?
- - build.tags.each do |tag|
+ - if job.tags.any?
+ - job.tags.each do |tag|
%span.label.label-primary
= tag
- - if build.try(:trigger_request)
+ - if job.try(:trigger_request)
%span.label.label-info triggered
- - if build.try(:allow_failure)
+ - if job.try(:allow_failure)
%span.label.label-danger allowed to fail
- - if build.action?
+ - if job.action?
%span.label.label-info manual
- if pipeline_link
%td
- = link_to pipeline_path(build.pipeline) do
- %span.pipeline-id ##{build.pipeline.id}
+ = link_to pipeline_path(pipeline) do
+ %span.pipeline-id ##{pipeline.id}
%span by
- - if build.pipeline.user
- = user_avatar(user: build.pipeline.user, size: 20)
+ - if pipeline.user
+ = user_avatar(user: pipeline.user, size: 20)
- else
%span.monospace API
- if admin
%td
- - if build.project
- = link_to build.project.name_with_namespace, admin_namespace_project_path(build.project.namespace, build.project)
+ - if job.project
+ = link_to job.project.name_with_namespace, admin_namespace_project_path(job.project.namespace, job.project)
%td
- - if build.try(:runner)
- = runner_link(build.runner)
+ - if job.try(:runner)
+ = runner_link(job.runner)
- else
.light none
- if stage
%td
- = build.stage
+ = job.stage
%td
- = build.name
+ = job.name
%td
- - if build.duration
+ - if job.duration
%p.duration
= custom_icon("icon_timer")
- = duration_in_numbers(build.duration)
+ = duration_in_numbers(job.duration)
- - if build.finished_at
+ - if job.finished_at
%p.finished-at
= icon("calendar")
- %span= time_ago_with_tooltip(build.finished_at)
+ %span= time_ago_with_tooltip(job.finished_at)
%td.coverage
- - if coverage && build.try(:coverage)
- #{build.coverage}%
+ - if job.try(:coverage)
+ #{job.coverage}%
%td
.pull-right
- - if can?(current_user, :read_build, build) && build.artifacts?
- = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
+ - if can?(current_user, :read_build, job) && job.artifacts?
+ = link_to download_namespace_project_build_artifacts_path(job.project.namespace, job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
= icon('download')
- - if can?(current_user, :update_build, build)
- - if build.active?
- = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
+ - if can?(current_user, :update_build, job)
+ - if job.active?
+ = link_to cancel_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Cancel', class: 'btn btn-build' do
= icon('remove', class: 'cred')
- elsif allow_retry
- - if build.playable? && !admin && can?(current_user, :play_build, build)
- = link_to play_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
+ - if job.playable? && !admin && can?(current_user, :play_build, jop)
+ = link_to play_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
= custom_icon('icon_play')
- - elsif build.retryable?
- = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
+ - elsif job.retryable?
+ = link_to retry_namespace_project_build_path(job.project.namespace, job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
= icon('repeat')
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index a0a292d0508..f604d6e5fbb 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -1,7 +1,7 @@
.page-content-header
.header-main-content
%strong Commit #{@commit.short_id}
- = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
%span.hidden-xs authored
#{time_ago_with_tooltip(@commit.authored_date)}
%span by
@@ -20,7 +20,7 @@
= icon('comment')
= @notes_count
= link_to namespace_project_tree_path(@project.namespace, @project, @commit), class: "btn btn-default append-right-10 hidden-xs hidden-sm" do
- Browse Files
+ Browse files
.dropdown.inline
%a.btn.btn-default.dropdown-toggle{ data: { toggle: "dropdown" } }
%span Options
diff --git a/app/views/projects/commit/_pipeline.html.haml b/app/views/projects/commit/_pipeline.html.haml
index c2b32a22170..3ee85723ebe 100644
--- a/app/views/projects/commit/_pipeline.html.haml
+++ b/app/views/projects/commit/_pipeline.html.haml
@@ -47,7 +47,6 @@
%th Job ID
%th Name
%th
- - if pipeline.project.build_coverage_enabled?
- %th Coverage
+ %th Coverage
%th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index d5fc283aa8d..0d11da2451a 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -10,6 +10,7 @@
- else
.block-connector
= render "projects/diffs/diffs", diffs: @diffs, environment: @environment
+
= render "projects/notes/notes_with_form"
- if can_collaborate_with_project?
- %w(revert cherry-pick).each do |type|
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 4b1ff75541a..8f32d2b72e5 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -37,6 +37,6 @@
.commit-actions.flex-row.hidden-xs
- if commit.status(ref)
= render_commit_status(commit, ref: ref)
- = clipboard_button(clipboard_text: commit.id, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: commit.id, title: "Copy commit SHA to clipboard")
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent"
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 38dbf2ac10b..c1c2fb3d299 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -18,16 +18,16 @@
.block-controls.hidden-xs.hidden-sm
- if @merge_request.present?
.control
- = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn'
+ = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn'
- elsif create_mr_button?(@repository.root_ref, @ref)
.control
- = link_to "Create Merge Request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
+ = link_to "Create merge request", create_mr_path(@repository.root_ref, @ref), class: 'btn btn-success'
.control
= form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do
= search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false }
.control
- = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: "Commits Feed", class: 'btn' do
+ = link_to namespace_project_commits_path(@project.namespace, @project, @ref, rss_url_options), title: "Commits feed", class: 'btn' do
= icon("rss")
%div{ id: dom_id(@project) }
diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml
index 08236216421..0f080b6acee 100644
--- a/app/views/projects/compare/_form.html.haml
+++ b/app/views/projects/compare/_form.html.haml
@@ -21,6 +21,6 @@
&nbsp;
= button_tag "Compare", class: "btn btn-create commits-compare-btn"
- if @merge_request.present?
- = link_to "View Open Merge Request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn'
+ = link_to "View open merge request", namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'prepend-left-10 btn'
- elsif create_mr_button?
- = link_to "Create Merge Request", create_mr_path, class: 'prepend-left-10 btn'
+ = link_to "Create merge request", create_mr_path, class: 'prepend-left-10 btn'
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index c09c7b87e24..3e426ee9e7d 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -4,7 +4,7 @@
- type = line.type
- line_code = diff_file.line_code(line)
- if discussions && !line.meta?
- - discussion = discussions[line_code]
+ - line_discussions = discussions[line_code]
%tr.line_holder{ class: type, id: (line_code unless plain) }
- case type
- when 'match'
@@ -20,6 +20,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
+ - discussion = line_discussions.try(:first)
- if discussion && discussion.resolvable? && !plain
%diff-note-avatars{ "discussion-id" => discussion.id }
%td.new_line.diff-line-num{ class: type, data: { linenumber: line.new_pos } }
@@ -34,6 +35,6 @@
- else
= diff_line_content(line.text)
-- if discussion
- - discussion_expanded = local_assigns.fetch(:discussion_expanded, discussion.expanded?)
- = render "discussions/diff_discussion", discussion: discussion, expanded: discussion_expanded
+- if line_discussions
+ - discussion_expanded = local_assigns.fetch(:discussion_expanded, line_discussions.any?(&:expanded?))
+ = render "discussions/diff_discussion", discussions: line_discussions, expanded: discussion_expanded
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index b7346f27ddb..45c95f7ab6a 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -5,8 +5,7 @@
- left = line[:left]
- right = line[:right]
- last_line = right.new_pos if right
- - unless @diff_notes_disabled
- - discussion_left, discussion_right = parallel_diff_discussions(left, right, diff_file)
+ - discussions_left, discussions_right = parallel_diff_discussions(left, right, diff_file)
%tr.line_holder.parallel
- if left
- case left.type
@@ -20,6 +19,7 @@
- left_position = diff_file.position(left)
%td.old_line.diff-line-num.js-avatar-container{ id: left_line_code, class: left.type, data: { linenumber: left.old_pos } }
%a{ href: "##{left_line_code}", data: { linenumber: left.old_pos } }
+ - discussion_left = discussions_left.try(:first)
- if discussion_left && discussion_left.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_left.id }
%td.line_content.parallel.noteable_line{ class: left.type, data: diff_view_line_data(left_line_code, left_position, 'old') }= diff_line_content(left.text)
@@ -39,6 +39,7 @@
- right_position = diff_file.position(right)
%td.new_line.diff-line-num.js-avatar-container{ id: right_line_code, class: right.type, data: { linenumber: right.new_pos } }
%a{ href: "##{right_line_code}", data: { linenumber: right.new_pos } }
+ - discussion_right = discussions_right.try(:first)
- if discussion_right && discussion_right.resolvable?
%diff-note-avatars{ "discussion-id" => discussion_right.id }
%td.line_content.parallel.noteable_line{ class: right.type, data: diff_view_line_data(right_line_code, right_position, 'new') }= diff_line_content(right.text)
@@ -46,8 +47,8 @@
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel
- - if discussion_left || discussion_right
- = render "discussions/parallel_diff_discussion", discussion_left: discussion_left, discussion_right: discussion_right
+ - if discussions_left || discussions_right
+ = render "discussions/parallel_diff_discussion", discussions_left: discussions_left, discussions_right: discussions_right
- if !diff_file.new_file && !diff_file.deleted_file && diff_file.diff_lines.any?
- last_line = diff_file.diff_lines.last
- if last_line.new_pos < total_lines
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index ebd1a914ee7..5f3968b6709 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -4,11 +4,10 @@
%a.show-suppressed-diff.js-show-suppressed-diff Changes suppressed. Click to show.
%table.text-file.code.js-syntax-highlight{ data: diff_view_data, class: too_big ? 'hide' : '' }
- - discussions = @grouped_diff_discussions unless @diff_notes_disabled
= render partial: "projects/diffs/line",
collection: diff_file.highlighted_diff_lines,
as: :line,
- locals: { diff_file: diff_file, discussions: discussions }
+ locals: { diff_file: diff_file, discussions: @grouped_diff_discussions }
- if !diff_file.new_file && !diff_file.deleted_file && diff_file.highlighted_diff_lines.any?
- last_line = diff_file.highlighted_diff_lines.last
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index 4b101447bc0..f7e3733ba0b 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -8,7 +8,4 @@
#environments-folder-list-view{ data: { "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
- "css-class" => container_class,
- "commit-icon-svg" => custom_icon("icon_commit"),
- "terminal-icon-svg" => custom_icon("icon_terminal"),
- "play-icon-svg" => custom_icon("icon_play") } }
+ "css-class" => container_class } }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index ff6aaebda22..7315e671056 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -8,9 +8,9 @@
%h3.page-title= @environment.name
.col-md-5
.nav-controls
- = render 'projects/environments/metrics_button', environment: @environment
= 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_namespace_project_environment_path(@project.namespace, @project, @environment), class: 'btn'
- if can?(current_user, :create_deployment, @environment) && @environment.can_stop?
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index 98d81308407..524b77783ef 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -22,4 +22,4 @@
%p
= link_to new_namespace_project_fork_path(@project.namespace, @project), title: "Fork", class: "btn" do
%i.fa.fa-code-fork
- Try to Fork again
+ Try to fork again
diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
index 07fb80750d6..f458646522c 100644
--- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
+++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml
@@ -4,7 +4,6 @@
- retried = local_assigns.fetch(:retried, false)
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
-- coverage = local_assigns.fetch(:coverage, false)
%tr.generic_commit_status{ class: ('retried' if retried) }
%td.status
@@ -80,7 +79,7 @@
%span= time_ago_with_tooltip(generic_commit_status.finished_at)
%td.coverage
- - if coverage && generic_commit_status.try(:coverage)
+ - if generic_commit_status.try(:coverage)
#{generic_commit_status.coverage}%
%td
diff --git a/app/views/projects/issues/_issue_by_email.html.haml b/app/views/projects/issues/_issue_by_email.html.haml
index d2038a2be68..da65157a10b 100644
--- a/app/views/projects/issues/_issue_by_email.html.haml
+++ b/app/views/projects/issues/_issue_by_email.html.haml
@@ -16,7 +16,7 @@
.email-modal-input-group.input-group
= text_field_tag :issue_email, email, class: "monospace js-select-on-focus form-control", readonly: true
.input-group-btn
- = clipboard_button(clipboard_target: '#issue_email')
+ = clipboard_button(target: '#issue_email')
%p
The subject will be used as the title of the new issue, and the message will be the description.
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index f3a429d12d9..4ac0bc1d028 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -24,9 +24,9 @@
issue: { assignee_id: issues_finder.assignee.try(:id),
milestone_id: issues_finder.milestones.first.try(:id) }),
class: "btn btn-new",
- title: "New Issue",
+ title: "New issue",
id: "new_issue_link" do
- New Issue
+ New issue
= render 'shared/issuable/search_bar', type: :issues
.issues-holder
diff --git a/app/views/projects/merge_requests/_discussion.html.haml b/app/views/projects/merge_requests/_discussion.html.haml
index cfb44bd206c..15b5a51c1d0 100644
--- a/app/views/projects/merge_requests/_discussion.html.haml
+++ b/app/views/projects/merge_requests/_discussion.html.haml
@@ -1,9 +1,9 @@
- content_for :note_actions do
- if can?(current_user, :update_merge_request, @merge_request)
- if @merge_request.open?
- = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: {state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: {original_text: "Close merge request", alternative_text: "Comment & close merge request"}
+ = link_to 'Close merge request', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "btn btn-nr btn-comment btn-close close-mr-link js-note-target-close", title: "Close merge request", data: { original_text: "Close merge request", alternative_text: "Comment & close merge request"}
- if @merge_request.reopenable?
- = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-reopen", title: "Reopen merge request", data: {original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
+ = link_to 'Reopen merge request', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: "btn btn-nr btn-comment btn-reopen reopen-mr-link js-note-target-close js-note-target-reopen", title: "Reopen merge request", data: { original_text: "Reopen merge request", alternative_text: "Comment & reopen merge request"}
%comment-and-resolve-btn{ "inline-template" => true, ":discussion-id" => "" }
%button.btn.btn-nr.btn-default.append-right-10.js-comment-resolve-button{ "v-if" => "showButton", type: "submit", data: { project_path: "#{project_path(@merge_request.project)}" } }
{{ buttonText }}
diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml
index e7fcac4c477..03069804c86 100644
--- a/app/views/projects/merge_requests/_new_submit.html.haml
+++ b/app/views/projects/merge_requests/_new_submit.html.haml
@@ -53,5 +53,6 @@
:javascript
var merge_request = new MergeRequest({
- action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}"
+ action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}",
+ setUrl: false,
});
diff --git a/app/views/projects/merge_requests/show/_how_to_merge.html.haml b/app/views/projects/merge_requests/show/_how_to_merge.html.haml
index cde0ce08e14..f3372c7657f 100644
--- a/app/views/projects/merge_requests/show/_how_to_merge.html.haml
+++ b/app/views/projects/merge_requests/show/_how_to_merge.html.haml
@@ -8,7 +8,7 @@
%p
%strong Step 1.
Fetch and check out the branch for this merge request
- = clipboard_button(clipboard_target: "pre#merge-info-1", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-1", title: "Copy commands to clipboard")
%pre.dark#merge-info-1
- if @merge_request.for_fork?
:preserve
@@ -25,7 +25,7 @@
%p
%strong Step 3.
Merge the branch and fix any conflicts that come up
- = clipboard_button(clipboard_target: "pre#merge-info-3", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-3", title: "Copy commands to clipboard")
%pre.dark#merge-info-3
- if @merge_request.for_fork?
:preserve
@@ -38,7 +38,7 @@
%p
%strong Step 4.
Push the result of the merge to GitLab
- = clipboard_button(clipboard_target: "pre#merge-info-4", title: "Copy commands to clipboard")
+ = clipboard_button(target: "pre#merge-info-4", title: "Copy commands to clipboard")
%pre.dark#merge-info-4
:preserve
git push origin #{h @merge_request.target_branch}
diff --git a/app/views/projects/merge_requests/show/_versions.html.haml b/app/views/projects/merge_requests/show/_versions.html.haml
index 74a7b1dc498..547be78992e 100644
--- a/app/views/projects/merge_requests/show/_versions.html.haml
+++ b/app/views/projects/merge_requests/show/_versions.html.haml
@@ -72,13 +72,16 @@
= link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do
new commits
from
- %code= @merge_request.target_branch
+ = succeed '.' do
+ %code= @merge_request.target_branch
- - unless @merge_request_diff.latest? && !@start_sha
+ - if @diff_notes_disabled
.comments-disabled-notif.content-block
= icon('info-circle')
- if @start_sha
Comments are disabled because you're comparing two versions of this merge request.
- else
- Comments are disabled because you're viewing an old version of this merge request.
- = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
+ Discussions on this version of the merge request are displayed but comment creation is disabled.
+
+ .pull-right
+ = link_to 'Show latest version', diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm'
diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml
index caf3bf54eef..a0f54bd28ec 100644
--- a/app/views/projects/merge_requests/widget/_merged_buttons.haml
+++ b/app/views/projects/merge_requests/widget/_merged_buttons.haml
@@ -7,7 +7,7 @@
- if can_remove_source_branch
= link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default remove_source_branch" do
= icon('trash-o')
- Remove Source Branch
+ Remove source branch
- if mr_can_be_reverted
= revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close")
- if mr_can_be_cherry_picked
diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml
index e5ec151a61d..4cbd22150c7 100644
--- a/app/views/projects/merge_requests/widget/open/_accept.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml
@@ -10,24 +10,24 @@
- if @pipeline && @pipeline.active?
%span.btn-group
= button_tag class: "btn btn-info js-merge-when-pipeline-succeeds-button merge-when-pipeline-succeeds" do
- Merge When Pipeline Succeeds
+ Merge when pipeline succeeds
- unless @project.only_allow_merge_if_pipeline_succeeds?
= button_tag class: "btn btn-info dropdown-toggle", 'data-toggle' => 'dropdown' do
= icon('caret-down')
%span.sr-only
- Select Merge Moment
+ Select merge moment
%ul.js-merge-dropdown.dropdown-menu.dropdown-menu-right{ role: 'menu' }
%li
- = link_to "#", class: "merge_when_pipeline_succeeds" do
+ = link_to "#", class: "merge-when-pipeline-succeeds" do
= icon('check fw')
- Merge When Pipeline Succeeds
+ Merge when pipeline succeeds
%li
= link_to "#", class: "accept-merge-request" do
= icon('warning fw')
- Merge Immediately
+ Merge immediately
- else
= f.button class: "btn btn-grouped js-merge-button accept-merge-request" do
- Accept Merge Request
+ Accept merge request
- if @merge_request.force_remove_source_branch?
.accept-control
The source branch will be removed.
diff --git a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml
index 5f347acce4d..76cc1ecd8a5 100644
--- a/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml
+++ b/app/views/projects/merge_requests/widget/open/_merge_when_pipeline_succeeds.html.haml
@@ -26,8 +26,8 @@
- if remove_source_branch_button
= link_to merge_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request, merge_params(@merge_request)), remote: true, method: :post, class: "btn btn-grouped btn-primary btn-sm remove_source_branch" do
= icon('times')
- Remove Source Branch When Merged
+ Remove source branch when merged
- if user_can_cancel_automatic_merge
= link_to cancel_merge_when_pipeline_succeeds_namespace_project_merge_request_path(@merge_request.target_project.namespace, @merge_request.target_project, @merge_request), remote: true, method: :post, class: "btn btn-grouped btn-sm" do
- Cancel Automatic Merge
+ Cancel automatic merge
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index b6340a00b29..8e85b2e8a20 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -9,8 +9,8 @@
.nav-controls
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New Milestone' do
- New Milestone
+ = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone' do
+ New milestone
.milestones
%ul.content-list
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 5249d752585..8b62b156853 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -23,9 +23,9 @@
.milestone-buttons
- if can?(current_user, :admin_milestone, @project)
- if @milestone.active?
- = link_to 'Close Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
+ = link_to 'Close milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
- else
- = link_to 'Reopen Milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
+ = link_to 'Reopen milestone', namespace_project_milestone_path(@project.namespace, @project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
= link_to edit_namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-grouped btn-nr" do
Edit
diff --git a/app/views/projects/notes/_comment_button.html.haml b/app/views/projects/notes/_comment_button.html.haml
new file mode 100644
index 00000000000..6bb55f04b6e
--- /dev/null
+++ b/app/views/projects/notes/_comment_button.html.haml
@@ -0,0 +1,30 @@
+- noteable_name = @note.noteable.human_class_name
+
+.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown
+ %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' }
+
+ - if @note.can_be_discussion_note?
+ = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle comment-btn js-note-new-discussion js-disable-on-submit', data: { 'dropdown-trigger' => '#resolvable-comment-menu' }, 'aria-label' => 'Open comment type dropdown' do
+ = icon('caret-down', class: 'toggle-icon')
+
+ %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } }
+ %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } }
+ %a{ href: '#' }
+ = icon('check')
+ .description
+ %strong Comment
+ %p
+ Add a general comment to this #{noteable_name}.
+
+ %li.divider
+
+ %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
+ %a{ href: '#' }
+ = icon('check')
+ .description
+ %strong Start discussion
+ %p
+ = succeed '.' do
+ Discuss a specific suggestion or question
+ - if @note.noteable.supports_resolvable_notes?
+ that needs to be resolved
diff --git a/app/views/projects/notes/_edit_form.html.haml b/app/views/projects/notes/_edit_form.html.haml
index e8e450742b5..8b4e5928e0d 100644
--- a/app/views/projects/notes/_edit_form.html.haml
+++ b/app/views/projects/notes/_edit_form.html.haml
@@ -9,6 +9,6 @@
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-edit-warning
Finish editing this message first!
- = submit_tag 'Save Comment', class: 'btn btn-nr btn-save js-comment-button'
+ = submit_tag 'Save comment', class: 'btn btn-nr btn-save js-comment-button'
%button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
Cancel
diff --git a/app/views/projects/notes/_form.html.haml b/app/views/projects/notes/_form.html.haml
index b561052e721..0d835a9e949 100644
--- a/app/views/projects/notes/_form.html.haml
+++ b/app/views/projects/notes/_form.html.haml
@@ -4,12 +4,18 @@
= hidden_field_tag :view, diff_view
= hidden_field_tag :line_type
= hidden_field_tag :merge_request_diff_head_sha, @note.noteable.try(:diff_head_sha)
+ = hidden_field_tag :in_reply_to_discussion_id
+
= note_target_fields(@note)
- = f.hidden_field :commit_id
- = f.hidden_field :line_code
- = f.hidden_field :noteable_id
= f.hidden_field :noteable_type
+ = f.hidden_field :noteable_id
+ = f.hidden_field :commit_id
= f.hidden_field :type
+
+ -# LegacyDiffNote
+ = f.hidden_field :line_code
+
+ -# DiffNote
= f.hidden_field :position
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
@@ -22,7 +28,9 @@
.error-alert
.note-form-actions.clearfix
- = f.submit 'Comment', class: "btn btn-nr btn-create append-right-10 comment-btn js-comment-button"
+ = render partial: 'projects/notes/comment_button'
+
= yield(:note_actions)
+
%a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
Discard draft
diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml
index 18afa811bad..c12c05eeb73 100644
--- a/app/views/projects/notes/_note.html.haml
+++ b/app/views/projects/notes/_note.html.haml
@@ -5,8 +5,11 @@
%li.timeline-entry{ id: dom_id(note), class: ["note", "note-row-#{note.id}", ('system-note' if note.system)], data: {author_id: note.author.id, editable: note_editable, note_id: note.id} }
.timeline-entry-inner
.timeline-icon
- %a{ href: user_path(note.author) }
- = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
+ - if note.system
+ = icon_for_system_note(note)
+ - else
+ %a{ href: user_path(note.author) }
+ = image_tag avatar_icon(note.author), alt: '', class: 'avatar s40'
.timeline-content
.note-header
%a.visible-xs{ href: user_path(note.author) }
@@ -31,7 +34,7 @@
- if note.resolvable?
- can_resolve = can?(current_user, :resolve_note, note)
%resolve-btn{ "project-path" => project_path(note.project),
- "discussion-id" => note.discussion_id,
+ "discussion-id" => note.discussion_id(@noteable),
":note-id" => note.id,
":resolved" => note.resolved?,
":can-resolve" => can_resolve,
diff --git a/app/views/projects/notes/_notes.html.haml b/app/views/projects/notes/_notes.html.haml
index 022578bd6db..2b2bab09c74 100644
--- a/app/views/projects/notes/_notes.html.haml
+++ b/app/views/projects/notes/_notes.html.haml
@@ -1,7 +1,7 @@
-- if @discussions.present?
+- if defined?(@discussions)
- @discussions.each do |discussion|
- - if discussion.for_target?(@noteable)
- = render partial: "projects/notes/note", object: discussion.first_note, as: :note
+ - if discussion.individual_note?
+ = render partial: "projects/notes/note", collection: discussion.notes, as: :note
- else
= render 'discussions/discussion', discussion: discussion
- else
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 4be9a1371ec..ab6baaf35b6 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -1,6 +1,6 @@
.page-content-header
.header-main-content
- = render 'ci/status/badge', status: @pipeline.detailed_status(current_user)
+ = render 'ci/status/badge', status: @pipeline.detailed_status(current_user), title: @pipeline.status_title
%strong Pipeline ##{@pipeline.id}
triggered #{time_ago_with_tooltip(@pipeline.created_at)}
- if @pipeline.user
@@ -46,4 +46,4 @@
\...
%span.js-details-content.hide
= link_to @pipeline.sha, namespace_project_commit_path(@project.namespace, @project, @pipeline.sha), class: "monospace commit-hash-full"
- = clipboard_button(clipboard_text: @pipeline.sha, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: @pipeline.sha, title: "Copy commit SHA to clipboard")
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 53067cdcba4..d7cefb8613e 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -36,7 +36,6 @@
%th Job ID
%th Name
%th
- - if pipeline.project.build_coverage_enabled?
- %th Coverage
+ %th Coverage
%th
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml
index 132f6372e40..a3f84476dea 100644
--- a/app/views/projects/pipelines_settings/_show.html.haml
+++ b/app/views/projects/pipelines_settings/_show.html.haml
@@ -21,7 +21,7 @@
Git strategy for pipelines
%p
Choose between <code>clone</code> or <code>fetch</code> to get the recent application code
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'git-strategy'), target: '_blank'
.radio
= f.label :build_allow_git_fetch_false do
= f.radio_button :build_allow_git_fetch, 'false'
@@ -43,7 +43,7 @@
= f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0'
%p.help-block
Per job in minutes. If a job passes this threshold, it will be marked as failed.
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'timeout'), target: '_blank'
%hr
.form-group
@@ -53,7 +53,16 @@
%strong Public pipelines
.help-block
Allow everyone to access pipelines for public and internal projects
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
+ %hr
+ .form-group
+ .checkbox
+ = f.label :auto_cancel_pending_pipelines do
+ = f.check_box :auto_cancel_pending_pipelines, {}, 'enabled', 'disabled'
+ %strong Auto-cancel redundant, pending pipelines
+ .help-block
+ New pipelines will cancel older, pending pipelines on the same branch
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'auto-cancel-pending-pipelines'), target: '_blank'
%hr
.form-group
@@ -65,7 +74,7 @@
%p.help-block
A regular expression that will be used to find the test coverage
output in the job trace. Leave blank to disable
- = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing')
+ = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank'
.bs-callout.bs-callout-info
%p Below are examples of regex for existing tools:
%ul
diff --git a/app/views/projects/protected_branches/show.html.haml b/app/views/projects/protected_branches/show.html.haml
index 4d8169815b3..f8cfe5e4b11 100644
--- a/app/views/projects/protected_branches/show.html.haml
+++ b/app/views/projects/protected_branches/show.html.haml
@@ -1,13 +1,13 @@
-- page_title @protected_branch.name, "Protected Branches"
+- page_title @protected_ref.name, "Protected Branches"
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
- = @protected_branch.name
+ = @protected_ref.name
.col-lg-9
%h5 Matching Branches
- - if @matching_branches.present?
+ - if @matching_refs.present?
.table-responsive
%table.table.protected-branches-list
%colgroup
@@ -18,7 +18,7 @@
%th Branch
%th Last commit
%tbody
- - @matching_branches.each do |matching_branch|
+ - @matching_refs.each do |matching_branch|
= render partial: "matching_branch", object: matching_branch
- else
%p.settings-message.text-center
diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml
new file mode 100644
index 00000000000..6e187b54a59
--- /dev/null
+++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml
@@ -0,0 +1,32 @@
+= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'js-new-protected-tag' } do |f|
+ .panel.panel-default
+ .panel-heading
+ %h3.panel-title
+ Protect a tag
+ .panel-body
+ .form-horizontal
+ = form_errors(@protected_tag)
+ .form-group
+ = f.label :name, class: 'col-md-2 text-right' do
+ Tag:
+ .col-md-10
+ = render partial: "projects/protected_tags/dropdown", locals: { f: f }
+ .help-block
+ = link_to 'Wildcards', help_page_path('user/project/protected_tags', anchor: 'wildcard-protected-tags')
+ such as
+ %code v*
+ or
+ %code *-release
+ are supported
+ .form-group
+ %label.col-md-2.text-right{ for: 'create_access_levels_attributes' }
+ Allowed to create:
+ .col-md-10
+ .create_access_levels-container
+ = dropdown_tag('Select',
+ options: { toggle_class: 'js-allowed-to-create wide',
+ dropdown_class: 'dropdown-menu-selectable',
+ data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
+
+ .panel-footer
+ = f.submit 'Protect', class: 'btn-create btn', disabled: true
diff --git a/app/views/projects/protected_tags/_dropdown.html.haml b/app/views/projects/protected_tags/_dropdown.html.haml
new file mode 100644
index 00000000000..74851519077
--- /dev/null
+++ b/app/views/projects/protected_tags/_dropdown.html.haml
@@ -0,0 +1,15 @@
+= f.hidden_field(:name)
+
+= dropdown_tag('Select tag or create wildcard',
+ options: { toggle_class: 'js-protected-tag-select js-filter-submit wide',
+ filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search protected tag",
+ footer_content: true,
+ data: { show_no: true, show_any: true, show_upcoming: true,
+ selected: params[:protected_tag_name],
+ project_id: @project.try(:id) } }) do
+
+ %ul.dropdown-footer-list
+ %li
+ = link_to '#', title: "New Protected Tag", class: "create-new-protected-tag" do
+ Create wildcard
+ %code
diff --git a/app/views/projects/protected_tags/_index.html.haml b/app/views/projects/protected_tags/_index.html.haml
new file mode 100644
index 00000000000..0bfb1ad191d
--- /dev/null
+++ b/app/views/projects/protected_tags/_index.html.haml
@@ -0,0 +1,18 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('protected_tags')
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ Protected tags
+ %p.prepend-top-20
+ By default, Protected tags are designed to:
+ %ul
+ %li Prevent tag creation by everybody except Masters
+ %li Prevent <strong>anyone</strong> from updating the tag
+ %li Prevent <strong>anyone</strong> from deleting the tag
+ .col-lg-9
+ - if can? current_user, :admin_project, @project
+ = render 'projects/protected_tags/create_protected_tag'
+
+ = render "projects/protected_tags/tags_list"
diff --git a/app/views/projects/protected_tags/_matching_tag.html.haml b/app/views/projects/protected_tags/_matching_tag.html.haml
new file mode 100644
index 00000000000..97e5cd6f9d2
--- /dev/null
+++ b/app/views/projects/protected_tags/_matching_tag.html.haml
@@ -0,0 +1,9 @@
+%tr
+ %td
+ = link_to matching_tag.name, namespace_project_tree_path(@project.namespace, @project, matching_tag.name)
+ - if @project.root_ref?(matching_tag.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - commit = @project.commit(matching_tag.name)
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = time_ago_with_tooltip(commit.committed_date)
diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml
new file mode 100644
index 00000000000..26bd3a1f5ed
--- /dev/null
+++ b/app/views/projects/protected_tags/_protected_tag.html.haml
@@ -0,0 +1,21 @@
+%tr.js-protected-tag-edit-form{ data: { url: namespace_project_protected_tag_path(@project.namespace, @project, protected_tag) } }
+ %td
+ = protected_tag.name
+ - if @project.root_ref?(protected_tag.name)
+ %span.label.label-info.prepend-left-5 default
+ %td
+ - if protected_tag.wildcard?
+ - matching_tags = protected_tag.matching(repository.tags)
+ = link_to pluralize(matching_tags.count, "matching tag"), namespace_project_protected_tag_path(@project.namespace, @project, protected_tag)
+ - else
+ - if commit = protected_tag.commit
+ = link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit_short_id')
+ = time_ago_with_tooltip(commit.committed_date)
+ - else
+ (tag was removed from repository)
+
+ = render partial: 'projects/protected_tags/update_protected_tag', locals: { protected_tag: protected_tag }
+
+ - if can_admin_project
+ %td
+ = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag], data: { confirm: 'tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning'
diff --git a/app/views/projects/protected_tags/_tags_list.html.haml b/app/views/projects/protected_tags/_tags_list.html.haml
new file mode 100644
index 00000000000..728afd75b50
--- /dev/null
+++ b/app/views/projects/protected_tags/_tags_list.html.haml
@@ -0,0 +1,28 @@
+.panel.panel-default.protected-tags-list.js-protected-tags-list
+ - if @protected_tags.empty?
+ .panel-heading
+ %h3.panel-title
+ Protected tag (#{@protected_tags.size})
+ %p.settings-message.text-center
+ There are currently no protected tags, protect a tag with the form above.
+ - else
+ - can_admin_project = can?(current_user, :admin_project, @project)
+
+ %table.table.table-bordered
+ %colgroup
+ %col{ width: "25%" }
+ %col{ width: "25%" }
+ %col{ width: "50%" }
+ %thead
+ %tr
+ %th Protected tag (#{@protected_tags.size})
+ %th Last commit
+ %th Allowed to create
+ - if can_admin_project
+ %th
+ %tbody
+ %tr
+ %td.flash-container{ colspan: 4 }
+ = render partial: 'projects/protected_tags/protected_tag', collection: @protected_tags, locals: { can_admin_project: can_admin_project}
+
+ = paginate @protected_tags, theme: 'gitlab'
diff --git a/app/views/projects/protected_tags/_update_protected_tag.haml b/app/views/projects/protected_tags/_update_protected_tag.haml
new file mode 100644
index 00000000000..62823bee46e
--- /dev/null
+++ b/app/views/projects/protected_tags/_update_protected_tag.haml
@@ -0,0 +1,5 @@
+%td
+ = hidden_field_tag "allowed_to_create_#{protected_tag.id}", protected_tag.create_access_levels.first.access_level
+ = dropdown_tag( (protected_tag.create_access_levels.first.humanize || 'Select') ,
+ options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable js-allowed-to-create-container',
+ data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: protected_tag.create_access_levels.first.id }})
diff --git a/app/views/projects/protected_tags/show.html.haml b/app/views/projects/protected_tags/show.html.haml
new file mode 100644
index 00000000000..63743f28b3c
--- /dev/null
+++ b/app/views/projects/protected_tags/show.html.haml
@@ -0,0 +1,25 @@
+- page_title @protected_ref.name, "Protected Tags"
+
+.row.prepend-top-default.append-bottom-default
+ .col-lg-3
+ %h4.prepend-top-0
+ = @protected_ref.name
+
+ .col-lg-9
+ %h5 Matching Tags
+ - if @matching_refs.present?
+ .table-responsive
+ %table.table.protected-tags-list
+ %colgroup
+ %col{ width: "30%" }
+ %col{ width: "30%" }
+ %thead
+ %tr
+ %th Tag
+ %th Last commit
+ %tbody
+ - @matching_refs.each do |matching_tag|
+ = render partial: "matching_tag", object: matching_tag
+ - else
+ %p.settings-message.text-center
+ Couldn't find any matching tags.
diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml
new file mode 100644
index 00000000000..8bc78f8d018
--- /dev/null
+++ b/app/views/projects/registry/repositories/_image.html.haml
@@ -0,0 +1,32 @@
+.container-image.js-toggle-container
+ .container-image-head
+ = link_to "#", class: "js-toggle-button" do
+ = icon('chevron-down', 'aria-hidden': 'true')
+ = escape_once(image.path)
+
+ = clipboard_button(clipboard_text: "docker pull #{image.location}")
+
+ .controls.hidden-xs.pull-right
+ = link_to namespace_project_container_registry_path(@project.namespace, @project, image),
+ class: 'btn btn-remove has-tooltip',
+ title: 'Remove repository',
+ data: { confirm: 'Are you sure?' },
+ method: :delete do
+ = icon('trash cred', 'aria-hidden': 'true')
+
+ .container-image-tags.js-toggle-content.hide
+ - if image.has_tags?
+ .table-holder
+ %table.table.tags
+ %thead
+ %tr
+ %th Tag
+ %th Tag ID
+ %th Size
+ %th Created
+ - if can?(current_user, :update_container_image, @project)
+ %th
+ = render partial: 'tag', collection: image.tags
+ - else
+ .nothing-here-block No tags in Container Registry for this container image.
+
diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index 10822b6184c..378a23f07e6 100644
--- a/app/views/projects/container_registry/_tag.html.haml
+++ b/app/views/projects/registry/repositories/_tag.html.haml
@@ -1,7 +1,7 @@
%tr.tag
%td
= escape_once(tag.name)
- = clipboard_button(clipboard_text: "docker pull #{tag.path}")
+ = clipboard_button(text: "docker pull #{tag.location}")
%td
- if tag.revision
%span.has-tooltip{ title: "#{tag.revision}" }
@@ -25,5 +25,9 @@
- if can?(current_user, :update_container_image, @project)
%td.content
.controls.hidden-xs.pull-right
- = link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do
- = icon("trash cred")
+ = link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
+ method: :delete,
+ class: 'btn btn-remove has-tooltip',
+ title: 'Remove tag',
+ data: { confirm: 'Are you sure you want to delete this tag?' } do
+ = icon('trash cred')
diff --git a/app/views/projects/container_registry/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 993da27310f..be128e92fa7 100644
--- a/app/views/projects/container_registry/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -15,25 +15,12 @@
%br
Then you are free to create and upload a container image with build and push commands:
%pre
- docker build -t #{escape_once(@project.container_registry_repository_url)} .
+ docker build -t #{escape_once(@project.container_registry_url)}/image .
%br
- docker push #{escape_once(@project.container_registry_repository_url)}
+ docker push #{escape_once(@project.container_registry_url)}/image
- - if @tags.blank?
- %li
- .nothing-here-block No images in Container Registry for this project.
+ - if @images.blank?
+ .nothing-here-block No container image repositories in Container Registry for this project.
- else
- .table-holder
- %table.table.tags
- %thead
- %tr
- %th Name
- %th Image ID
- %th Size
- %th Created
- - if can?(current_user, :update_container_image, @project)
- %th
-
- - @tags.each do |tag|
- = render 'tag', tag: tag
+ = render partial: 'image', collection: @images
diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
index 2fb88297fb3..ef3599460f1 100644
--- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
+++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml
@@ -22,14 +22,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#display_name')
+ = clipboard_button(target: '#display_name')
.form-group
= label_tag :description, 'Description', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#description')
+ = clipboard_button(target: '#description')
.form-group
= label_tag nil, 'Command trigger word', class: 'col-sm-2 col-xs-12 control-label'
@@ -46,7 +46,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :request_url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#request_url')
+ = clipboard_button(target: '#request_url')
.form-group
= label_tag nil, 'Request method', class: 'col-sm-2 col-xs-12 control-label'
@@ -57,14 +57,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :response_username, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#response_username')
+ = clipboard_button(target: '#response_username')
.form-group
= label_tag :response_icon, 'Response icon', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :response_icon, asset_url('gitlab_logo.png'), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#response_icon')
+ = clipboard_button(target: '#response_icon')
.form-group
= label_tag nil, 'Autocomplete', class: 'col-sm-2 col-xs-12 control-label'
@@ -75,14 +75,14 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_hint')
+ = clipboard_button(target: '#autocomplete_hint')
.form-group
= label_tag :autocomplete_description, 'Autocomplete description', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_description')
+ = clipboard_button(target: '#autocomplete_description')
%hr
diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml
index 078b7be6865..73b99453a4b 100644
--- a/app/views/projects/services/slack_slash_commands/_help.html.haml
+++ b/app/views/projects/services/slack_slash_commands/_help.html.haml
@@ -40,7 +40,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :url, service_trigger_url(subject), class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#url')
+ = clipboard_button(target: '#url')
.form-group
= label_tag nil, 'Method', class: 'col-sm-2 col-xs-12 control-label'
@@ -51,7 +51,7 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :customize_name, 'GitLab', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#customize_name')
+ = clipboard_button(target: '#customize_name')
.form-group
= label_tag nil, 'Customize icon', class: 'col-sm-2 col-xs-12 control-label'
@@ -68,21 +68,21 @@
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_description, run_actions_text, class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_description')
+ = clipboard_button(target: '#autocomplete_description')
.form-group
= label_tag :autocomplete_usage_hint, 'Autocomplete usage hint', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#autocomplete_usage_hint')
+ = clipboard_button(target: '#autocomplete_usage_hint')
.form-group
= label_tag :descriptive_label, 'Descriptive label', class: 'col-sm-2 col-xs-12 control-label'
.col-sm-10.col-xs-12.input-group
= text_field_tag :descriptive_label, 'Perform common operations on GitLab project', class: 'form-control input-sm', readonly: 'readonly'
.input-group-btn
- = clipboard_button(clipboard_target: '#descriptive_label')
+ = clipboard_button(target: '#descriptive_label')
%hr
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index 4c02302e161..5402320cb66 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -3,3 +3,4 @@
= render @deploy_keys
= render "projects/protected_branches/index"
+= render "projects/protected_tags/index"
diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml
index edfe6da1816..de1229d58aa 100644
--- a/app/views/projects/show.html.haml
+++ b/app/views/projects/show.html.haml
@@ -13,7 +13,7 @@
= render "home_panel"
- if current_user && can?(current_user, :download_code, @project)
- %nav.project-stats.limit-container-width{ class: container_class }
+ %nav.project-stats{ class: container_class }
%ul.nav
%li
= link_to project_files_path(@project) do
@@ -74,11 +74,11 @@
Set up auto deploy
- if @repository.commit
- .limit-container-width{ class: container_class }
+ %div{ class: container_class }
.project-last-commit
= render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project
-.limit-container-width{ class: container_class }
+%div{ class: container_class }
- if @project.archived?
.text-warning.center.prepend-top-20
%p
diff --git a/app/views/projects/stage/_stage.html.haml b/app/views/projects/stage/_stage.html.haml
index 28e1c060875..f93994bebe3 100644
--- a/app/views/projects/stage/_stage.html.haml
+++ b/app/views/projects/stage/_stage.html.haml
@@ -6,8 +6,8 @@
= ci_icon_for_status(stage.status)
&nbsp;
= stage.name.titleize
-= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true
-= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true
+= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true
+= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true
%tr
%td{ colspan: 10 }
&nbsp;
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index dffe908e85a..451e011a4b8 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -6,6 +6,11 @@
%span.item-title
= icon('tag')
= tag.name
+
+ - if protected_tag?(@project, tag)
+ %span.label.label-success
+ protected
+
- if tag.message.present?
&nbsp;
= strip_gpg_signature(tag.message)
@@ -30,5 +35,5 @@
= icon("pencil")
- if can?(current_user, :admin_project, @project)
- = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do
+ = link_to namespace_project_tag_path(@project.namespace, @project, tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{tag.name}' tag cannot be undone. Are you sure?", container: 'body' }, remote: true do
= icon("trash-o")
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index fad3c5c2173..1c4135c8a54 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -7,6 +7,9 @@
.nav-text
.title
%span.item-title= @tag.name
+ - if protected_tag?(@project, @tag)
+ %span.label.label-success
+ protected
- if @commit
= render 'projects/branches/commit', commit: @commit, project: @project
- else
@@ -24,7 +27,7 @@
= render 'projects/buttons/download', project: @project, ref: @tag.name
- if can?(current_user, :admin_project, @project)
.btn-container.controls-item-full
- = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: 'btn btn-remove remove-row has-tooltip', title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
+ = link_to namespace_project_tag_path(@project.namespace, @project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: "Delete tag", method: :delete, data: { confirm: "Deleting the '#{@tag.name}' tag cannot be undone. Are you sure?" } do
%i.fa.fa-trash-o
- if @tag.message.present?
diff --git a/app/views/projects/tree/_tree_content.html.haml b/app/views/projects/tree/_tree_content.html.haml
index 6855c463c6d..2497a2d91b1 100644
--- a/app/views/projects/tree/_tree_content.html.haml
+++ b/app/views/projects/tree/_tree_content.html.haml
@@ -10,7 +10,7 @@
%i.fa.fa-angle-right
%small.light
= link_to @commit.short_id, namespace_project_commit_path(@project.namespace, @project, @commit), class: "monospace"
- = clipboard_button(clipboard_text: @commit.id, title: "Copy commit SHA to clipboard")
+ = clipboard_button(text: @commit.id, title: "Copy commit SHA to clipboard")
= time_ago_with_tooltip(@commit.committed_date)
\-
= @commit.full_title
diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml
index 5f708b3a2ed..8582bcbb8cc 100644
--- a/app/views/projects/triggers/_form.html.haml
+++ b/app/views/projects/triggers/_form.html.haml
@@ -8,4 +8,26 @@
.form-group
= f.label :key, "Description", class: "label-light"
= f.text_field :description, class: "form-control", required: true, title: 'Trigger description is required.', placeholder: "Trigger description"
+ - if @trigger.persisted?
+ %hr
+ = f.fields_for :trigger_schedule do |schedule_fields|
+ = schedule_fields.hidden_field :id
+ .form-group
+ .checkbox
+ = schedule_fields.label :active do
+ = schedule_fields.check_box :active
+ %strong Schedule trigger (experimental)
+ .help-block
+ If checked, this trigger will be executed periodically according to cron and timezone.
+ = link_to icon('question-circle'), help_page_path('ci/triggers', anchor: 'schedule')
+ .form-group
+ = schedule_fields.label :cron, "Cron", class: "label-light"
+ = schedule_fields.text_field :cron, class: "form-control", title: 'Cron specification is required.', placeholder: "0 1 * * *"
+ .form-group
+ = schedule_fields.label :cron, "Timezone", class: "label-light"
+ = schedule_fields.text_field :cron_timezone, class: "form-control", title: 'Timezone is required.', placeholder: "UTC"
+ .form-group
+ = schedule_fields.label :ref, "Branch or tag", class: "label-light"
+ = schedule_fields.text_field :ref, class: "form-control", title: 'Branch or tag is required.', placeholder: "master"
+ .help-block Existing branch name, tag
= f.submit btn_text, class: "btn btn-save"
diff --git a/app/views/projects/triggers/_index.html.haml b/app/views/projects/triggers/_index.html.haml
index cc74e50a5e3..84e945ee0df 100644
--- a/app/views/projects/triggers/_index.html.haml
+++ b/app/views/projects/triggers/_index.html.haml
@@ -22,6 +22,8 @@
%th
%strong Last used
%th
+ %strong Next run at
+ %th
= render partial: 'projects/triggers/trigger', collection: @triggers, as: :trigger
- else
%p.settings-message.text-center.append-bottom-default
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index ed68e0ed56d..ebd91a8e2af 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -2,7 +2,7 @@
%td
- if can?(current_user, :admin_trigger, trigger)
%span= trigger.token
- = clipboard_button(clipboard_text: trigger.token, title: "Copy trigger token to clipboard")
+ = clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
- else
%span= trigger.short_token
@@ -29,6 +29,12 @@
- else
Never
+ %td
+ - if trigger.trigger_schedule&.active?
+ = trigger.trigger_schedule.real_next_run
+ - else
+ Never
+
%td.text-right.trigger-actions
- take_ownership_confirmation = "By taking ownership you will bind this trigger to your user account. With this the trigger will have access to all your projects as if it was you. Are you sure?"
- revoke_trigger_confirmation = "By revoking a trigger you will break any processes making use of it. Are you sure?"
diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml
index 5211ade1a5f..86178257af8 100644
--- a/app/views/projects/wikis/_main_links.html.haml
+++ b/app/views/projects/wikis/_main_links.html.haml
@@ -1,9 +1,9 @@
- if (@page && @page.persisted?)
- if can?(current_user, :create_wiki, @project)
= link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
- New Page
+ New page
= link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
- Page History
+ Page history
- if can?(current_user, :create_wiki, @project) && @page.latest?
= link_to namespace_project_wiki_edit_path(@project.namespace, @project, @page), class: "btn" do
Edit
diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml
index 3d33679f07d..ba47574563d 100644
--- a/app/views/projects/wikis/_new.html.haml
+++ b/app/views/projects/wikis/_new.html.haml
@@ -18,4 +18,4 @@
Tip: You can specify the full path for the new file.
We will automatically create any missing directories.
.form-actions
- = button_tag 'Create Page', class: 'build-new-wiki btn btn-create'
+ = button_tag 'Create page', class: 'build-new-wiki btn btn-create'
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index 8cf018da1b7..b995d08cd02 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -22,10 +22,10 @@
.nav-controls
- if can?(current_user, :create_wiki, @project)
= link_to '#modal-new-wiki', class: "add-new-wiki btn btn-new", "data-toggle" => "modal" do
- New Page
+ New page
- if @page.persisted?
= link_to namespace_project_wiki_history_path(@project.namespace, @project, @page), class: "btn" do
- Page History
+ Page history
- if can?(current_user, :admin_wiki, @project)
= link_to namespace_project_wiki_path(@project.namespace, @project, @page), data: { confirm: "Are you sure you want to delete this page?"}, method: :delete, class: "btn btn-danger" do
Delete
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index 03684389742..34a4d7398bc 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -19,7 +19,7 @@
= text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true
.input-group-btn
- = clipboard_button(clipboard_target: '#project_clone', title: "Copy URL to clipboard")
+ = clipboard_button(target: '#project_clone', title: "Copy URL to clipboard")
:javascript
$('ul.clone-options-dropdown a').on('click',function(e){
diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml
index 8869d510aef..90ae3f06a98 100644
--- a/app/views/shared/_group_form.html.haml
+++ b/app/views/shared/_group_form.html.haml
@@ -1,12 +1,8 @@
+- content_for :page_specific_javascripts do
+ = page_specific_javascript_bundle_tag('group')
- parent = GroupFinder.new(current_user).execute(id: params[:parent_id] || @group.parent_id)
- group_path = root_url
- group_path << parent.full_path + '/' if parent
-- if @group.persisted?
- .form-group
- = f.label :name, class: 'control-label' do
- Group name
- .col-sm-10
- = f.text_field :name, placeholder: 'open-source', class: 'form-control'
.form-group
= f.label :path, class: 'control-label' do
@@ -20,7 +16,7 @@
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS,
- title: 'Please choose a group name with no special characters.',
+ title: 'Please choose a group path with no special characters.',
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- if parent
= f.hidden_field :parent_id, value: parent.id
@@ -33,6 +29,14 @@
%li It will change web url for access group and group projects.
%li It will change the git path to repositories under this group.
+.form-group.group-name-holder
+ = f.label :name, class: 'control-label' do
+ Group name
+ .col-sm-10
+ = f.text_field :name, class: 'form-control',
+ required: true,
+ title: 'You can choose a descriptive name different from the path.'
+
.form-group.group-description-holder
= f.label :description, class: 'control-label'
.col-sm-10
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index af4cc90f4a7..e8062848fc3 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -1,4 +1,4 @@
-- type = impersonation ? "Impersonation" : "Personal Access"
+- type = impersonation ? "impersonation" : "personal access"
%h5.prepend-top-0
Add a #{type} Token
@@ -22,7 +22,7 @@
= render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes
.prepend-top-default
- = f.submit "Create #{type} Token", class: "btn btn-create"
+ = f.submit "Create #{type} token", class: "btn btn-create"
:javascript
var $dateField = $('.datepicker');
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml
index 67a49815478..ab7a2db002e 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/_personal_access_tokens_table.html.haml
@@ -33,7 +33,7 @@
- if impersonation
%td.token-token-container
= text_field_tag 'impersonation-token-token', token.token, readonly: true, class: "form-control"
- = clipboard_button(clipboard_text: token.token)
+ = clipboard_button(text: token.token)
- 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 pull-right", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." }
- else
diff --git a/app/views/shared/icons/_icon_arrow_circle_o_right.svg b/app/views/shared/icons/_icon_arrow_circle_o_right.svg
new file mode 100644
index 00000000000..db28b5e2d7a
--- /dev/null
+++ b/app/views/shared/icons/_icon_arrow_circle_o_right.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1280 896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-352q-13 0-22.5-9.5t-9.5-22.5v-192q0-13 9.5-22.5t22.5-9.5h352v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm160 0q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg>
diff --git a/app/views/shared/icons/_icon_check_square_o.svg b/app/views/shared/icons/_icon_check_square_o.svg
new file mode 100644
index 00000000000..3dfbfc8c0e9
--- /dev/null
+++ b/app/views/shared/icons/_icon_check_square_o.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1472 930v318q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-10 10-23 10-3 0-9-2-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-254q0-13 9-22l64-64q10-10 23-10 6 0 12 3 20 8 20 29zm231-489l-814 814q-24 24-57 24t-57-24l-430-430q-24-24-24-57t24-57l110-110q24-24 57-24t57 24l263 263 647-647q24-24 57-24t57 24l110 110q24 24 24 57t-24 57z"/></svg>
diff --git a/app/views/shared/icons/_icon_clock_o.svg b/app/views/shared/icons/_icon_clock_o.svg
new file mode 100644
index 00000000000..8ddce62614c
--- /dev/null
+++ b/app/views/shared/icons/_icon_clock_o.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1024 544v448q0 14-9 23t-23 9h-320q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h224v-352q0-14 9-23t23-9h64q14 0 23 9t9 23zm416 352q0-148-73-273t-198-198-273-73-273 73-198 198-73 273 73 273 198 198 273 73 273-73 198-198 73-273zm224 0q0 209-103 385.5t-279.5 279.5-385.5 103-385.5-103-279.5-279.5-103-385.5 103-385.5 279.5-279.5 385.5-103 385.5 103 279.5 279.5 103 385.5z"/></svg>
diff --git a/app/views/shared/icons/_icon_code_fork.svg b/app/views/shared/icons/_icon_code_fork.svg
new file mode 100644
index 00000000000..5a0df2eee19
--- /dev/null
+++ b/app/views/shared/icons/_icon_code_fork.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M672 1472q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm0-1152q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm640 128q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm96 0q0 52-26 96.5t-70 69.5q-2 287-226 414-68 38-203 81-128 40-169.5 71t-41.5 100v26q44 25 70 69.5t26 96.5q0 80-56 136t-136 56-136-56-56-136q0-52 26-96.5t70-69.5v-820q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136q0 52-26 96.5t-70 69.5v497q54-26 154-57 55-17 87.5-29.5t70.5-31 59-39.5 40.5-51 28-69.5 8.5-91.5q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136z"/></svg>
diff --git a/app/views/shared/icons/_icon_comment_o.svg b/app/views/shared/icons/_icon_comment_o.svg
new file mode 100644
index 00000000000..b99bd5f42c8
--- /dev/null
+++ b/app/views/shared/icons/_icon_comment_o.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M896 384q-204 0-381.5 69.5t-282 187.5-104.5 255q0 112 71.5 213.5t201.5 175.5l87 50-27 96q-24 91-70 172 152-63 275-171l43-38 57 6q69 8 130 8 204 0 381.5-69.5t282-187.5 104.5-255-104.5-255-282-187.5-381.5-69.5zm896 512q0 174-120 321.5t-326 233-450 85.5q-70 0-145-8-198 175-460 242-49 14-114 22h-5q-15 0-27-10.5t-16-27.5v-1q-3-4-.5-12t2-10 4.5-9.5l6-9 7-8.5 8-9q7-8 31-34.5t34.5-38 31-39.5 32.5-51 27-59 26-76q-157-89-247.5-220t-90.5-281q0-174 120-321.5t326-233 450-85.5 450 85.5 326 233 120 321.5z"/></svg>
diff --git a/app/views/shared/icons/_icon_commit.svg b/app/views/shared/icons/_icon_commit.svg
index 0e96035b7b7..7e9c0ded04e 100644
--- a/app/views/shared/icons/_icon_commit.svg
+++ b/app/views/shared/icons/_icon_commit.svg
@@ -1,3 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
- <path fill="#8F8F8F" fill-rule="evenodd" d="M28.7769836,18 C27.8675252,13.9920226 24.2831748,11 20,11 C15.7168252,11 12.1324748,13.9920226 11.2230164,18 L4.0085302,18 C2.90195036,18 2,18.8954305 2,20 C2,21.1122704 2.8992496,22 4.0085302,22 L11.2230164,22 C12.1324748,26.0079774 15.7168252,29 20,29 C24.2831748,29 27.8675252,26.0079774 28.7769836,22 L35.9914698,22 C37.0980496,22 38,21.1045695 38,20 C38,18.8877296 37.1007504,18 35.9914698,18 L28.7769836,18 L28.7769836,18 Z M20,25 C22.7614237,25 25,22.7614237 25,20 C25,17.2385763 22.7614237,15 20,15 C17.2385763,15 15,17.2385763 15,20 C15,22.7614237 17.2385763,25 20,25 L20,25 Z"/>
-</svg>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 18" enable-background="new 0 0 36 18"><path d="m34 7h-7.2c-.9-4-4.5-7-8.8-7s-7.9 3-8.8 7h-7.2c-1.1 0-2 .9-2 2 0 1.1.9 2 2 2h7.2c.9 4 4.5 7 8.8 7s7.9-3 8.8-7h7.2c1.1 0 2-.9 2-2 0-1.1-.9-2-2-2m-16 7c-2.8 0-5-2.2-5-5s2.2-5 5-5 5 2.2 5 5-2.2 5-5 5"/></svg>
diff --git a/app/views/shared/icons/_icon_edit.svg b/app/views/shared/icons/_icon_edit.svg
new file mode 100644
index 00000000000..cd4e34147e1
--- /dev/null
+++ b/app/views/shared/icons/_icon_edit.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M888 1184l116-116-152-152-116 116v56h96v96h56zm440-720q-16-16-33 1l-350 350q-17 17-1 33t33-1l350-350q17-17 1-33zm80 594v190q0 119-84.5 203.5t-203.5 84.5h-832q-119 0-203.5-84.5t-84.5-203.5v-832q0-119 84.5-203.5t203.5-84.5h832q63 0 117 25 15 7 18 23 3 17-9 29l-49 49q-14 14-32 8-23-6-45-6h-832q-66 0-113 47t-47 113v832q0 66 47 113t113 47h832q66 0 113-47t47-113v-126q0-13 9-22l64-64q15-15 35-7t20 29zm-96-738l288 288-672 672h-288v-288zm444 132l-92 92-288-288 92-92q28-28 68-28t68 28l152 152q28 28 28 68t-28 68z"/></svg>
diff --git a/app/views/shared/icons/_icon_eye.svg b/app/views/shared/icons/_icon_eye.svg
new file mode 100644
index 00000000000..2e2ae67142f
--- /dev/null
+++ b/app/views/shared/icons/_icon_eye.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1664 960q-152-236-381-353 61 104 61 225 0 185-131.5 316.5t-316.5 131.5-316.5-131.5-131.5-316.5q0-121 61-225-229 117-381 353 133 205 333.5 326.5t434.5 121.5 434.5-121.5 333.5-326.5zm-720-384q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm848 384q0 34-20 69-140 230-376.5 368.5t-499.5 138.5-499.5-139-376.5-368q-20-35-20-69t20-69q140-229 376.5-368t499.5-139 499.5 139 376.5 368q20 35 20 69z"/></svg>
diff --git a/app/views/shared/icons/_icon_eye_slash.svg b/app/views/shared/icons/_icon_eye_slash.svg
new file mode 100644
index 00000000000..a16c5dcb24b
--- /dev/null
+++ b/app/views/shared/icons/_icon_eye_slash.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M555 1335l78-141q-87-63-136-159t-49-203q0-121 61-225-229 117-381 353 167 258 427 375zm389-759q0-20-14-34t-34-14q-125 0-214.5 89.5t-89.5 214.5q0 20 14 34t34 14 34-14 14-34q0-86 61-147t147-61q20 0 34-14t14-34zm363-191q0 7-1 9-105 188-315 566t-316 567l-49 89q-10 16-28 16-12 0-134-70-16-10-16-28 0-12 44-87-143-65-263.5-173t-208.5-245q-20-31-20-69t20-69q153-235 380-371t496-136q89 0 180 17l54-97q10-16 28-16 5 0 18 6t31 15.5 33 18.5 31.5 18.5 19.5 11.5q16 10 16 27zm37 447q0 139-79 253.5t-209 164.5l280-502q8 45 8 84zm448 128q0 35-20 69-39 64-109 145-150 172-347.5 267t-419.5 95l74-132q212-18 392.5-137t301.5-307q-115-179-282-294l63-112q95 64 182.5 153t144.5 184q20 34 20 69z"/></svg>
diff --git a/app/views/shared/icons/_icon_merge.svg b/app/views/shared/icons/_icon_merge.svg
new file mode 100644
index 00000000000..451ae12afbc
--- /dev/null
+++ b/app/views/shared/icons/_icon_merge.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg>
diff --git a/app/views/shared/icons/_icon_merged.svg b/app/views/shared/icons/_icon_merged.svg
new file mode 100644
index 00000000000..43d591daefa
--- /dev/null
+++ b/app/views/shared/icons/_icon_merged.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m2 3c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m.761.85c.154 2.556 1.987 4.692 4.45 5.255.328-.655 1.01-1.105 1.789-1.105 1.105 0 2 .895 2 2 0 1.105-.895 2-2 2-.89 0-1.645-.582-1.904-1.386-1.916-.376-3.548-1.5-4.596-3.044v4.493c.863.222 1.5 1.01 1.5 1.937 0 1.105-.895 2-2 2-1.105 0-2-.895-2-2 0-.74.402-1.387 1-1.732v-8.535c-.598-.346-1-.992-1-1.732 0-1.105.895-2 2-2 1.105 0 2 .895 2 2 0 .835-.512 1.551-1.239 1.85m6.239 7.15c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1m-7 4c.552 0 1-.448 1-1 0-.552-.448-1-1-1-.552 0-1 .448-1 1 0 .552.448 1 1 1" transform="translate(3)"/></svg>
diff --git a/app/views/shared/icons/_icon_pencil.svg b/app/views/shared/icons/_icon_pencil.svg
new file mode 100644
index 00000000000..a3b48404f87
--- /dev/null
+++ b/app/views/shared/icons/_icon_pencil.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M491 1536l91-91-235-235-91 91v107h128v128h107zm523-928q0-22-22-22-10 0-17 7l-542 542q-7 7-7 17 0 22 22 22 10 0 17-7l542-542q7-7 7-17zm-54-192l416 416-832 832h-416v-416zm683 96q0 53-37 90l-166 166-416-416 166-165q36-38 90-38 53 0 91 38l235 234q37 39 37 91z"/></svg>
diff --git a/app/views/shared/icons/_icon_random.svg b/app/views/shared/icons/_icon_random.svg
new file mode 100644
index 00000000000..763bd2d3dd8
--- /dev/null
+++ b/app/views/shared/icons/_icon_random.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M666 481q-60 92-137 273-22-45-37-72.5t-40.5-63.5-51-56.5-63-35-81.5-14.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q250 0 410 225zm1126 799q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192q-32 0-85 .5t-81 1-73-1-71-5-64-10.5-63-18.5-58-28.5-59-40-55-53.5-56-69.5q59-93 136-273 22 45 37 72.5t40.5 63.5 51 56.5 63 35 81.5 14.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23zm0-896q0 14-9 23l-320 320q-9 9-23 9-13 0-22.5-9.5t-9.5-22.5v-192h-256q-48 0-87 15t-69 45-51 61.5-45 77.5q-32 62-78 171-29 66-49.5 111t-54 105-64 100-74 83-90 68.5-106.5 42-128 16.5h-224q-14 0-23-9t-9-23v-192q0-14 9-23t23-9h224q48 0 87-15t69-45 51-61.5 45-77.5q32-62 78-171 29-66 49.5-111t54-105 64-100 74-83 90-68.5 106.5-42 128-16.5h256v-192q0-14 9-23t23-9q12 0 24 10l319 319q9 9 9 23z"/></svg>
diff --git a/app/views/shared/icons/_icon_status_closed.svg b/app/views/shared/icons/_icon_status_closed.svg
new file mode 100644
index 00000000000..de448ee1194
--- /dev/null
+++ b/app/views/shared/icons/_icon_status_closed.svg
@@ -0,0 +1 @@
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><rect x="3.36" y="6.16" width="7.28" height="1.68" rx=".84"/></svg>
diff --git a/app/views/shared/icons/_icon_status_open.svg b/app/views/shared/icons/_icon_status_open.svg
new file mode 100644
index 00000000000..ed58d23c626
--- /dev/null
+++ b/app/views/shared/icons/_icon_status_open.svg
@@ -0,0 +1 @@
+<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg"><path d="M0 7c0-3.866 3.142-7 7-7 3.866 0 7 3.142 7 7 0 3.866-3.142 7-7 7-3.866 0-7-3.142-7-7z"/><path d="M1 7c0 3.309 2.69 6 6 6 3.309 0 6-2.69 6-6 0-3.309-2.69-6-6-6-3.309 0-6 2.69-6 6z" fill="#FFF"/><path d="M7 9.219a2.218 2.218 0 1 0 0-4.436A2.218 2.218 0 0 0 7 9.22zm0 1.12a3.338 3.338 0 1 1 0-6.676 3.338 3.338 0 0 1 0 6.676z"/></svg>
diff --git a/app/views/shared/icons/_icon_tags.svg b/app/views/shared/icons/_icon_tags.svg
new file mode 100644
index 00000000000..fc5acc89c5e
--- /dev/null
+++ b/app/views/shared/icons/_icon_tags.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M384 448q0-53-37.5-90.5t-90.5-37.5-90.5 37.5-37.5 90.5 37.5 90.5 90.5 37.5 90.5-37.5 37.5-90.5zm1067 576q0 53-37 90l-491 492q-39 37-91 37-53 0-90-37l-715-716q-38-37-64.5-101t-26.5-117v-416q0-52 38-90t90-38h416q53 0 117 26.5t102 64.5l715 714q37 39 37 91zm384 0q0 53-37 90l-491 492q-39 37-91 37-36 0-59-14t-53-45l470-470q37-37 37-90 0-52-37-91l-715-714q-38-38-102-64.5t-117-26.5h224q53 0 117 26.5t102 64.5l715 714q37 39 37 91z"/></svg>
diff --git a/app/views/shared/icons/_icon_trash_o.svg b/app/views/shared/icons/_icon_trash_o.svg
new file mode 100644
index 00000000000..0d7a91ab536
--- /dev/null
+++ b/app/views/shared/icons/_icon_trash_o.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M704 736v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm256 0v576q0 14-9 23t-23 9h-64q-14 0-23-9t-9-23v-576q0-14 9-23t23-9h64q14 0 23 9t9 23zm128 724v-948h-896v948q0 22 7 40.5t14.5 27 10.5 8.5h832q3 0 10.5-8.5t14.5-27 7-40.5zm-672-1076h448l-48-117q-7-9-17-11h-317q-10 2-17 11zm928 32v64q0 14-9 23t-23 9h-96v948q0 83-47 143.5t-113 60.5h-832q-66 0-113-58.5t-47-141.5v-952h-96q-14 0-23-9t-9-23v-64q0-14 9-23t23-9h309l70-167q15-37 54-63t79-26h320q40 0 79 26t54 63l70 167h309q14 0 23 9t9 23z"/></svg>
diff --git a/app/views/shared/icons/_icon_user.svg b/app/views/shared/icons/_icon_user.svg
new file mode 100644
index 00000000000..9b8cd74d62b
--- /dev/null
+++ b/app/views/shared/icons/_icon_user.svg
@@ -0,0 +1 @@
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1600 1405q0 120-73 189.5t-194 69.5h-874q-121 0-194-69.5t-73-189.5q0-53 3.5-103.5t14-109 26.5-108.5 43-97.5 62-81 85.5-53.5 111.5-20q9 0 42 21.5t74.5 48 108 48 133.5 21.5 133.5-21.5 108-48 74.5-48 42-21.5q61 0 111.5 20t85.5 53.5 62 81 43 97.5 26.5 108.5 14 109 3.5 103.5zm-320-893q0 159-112.5 271.5t-271.5 112.5-271.5-112.5-112.5-271.5 112.5-271.5 271.5-112.5 271.5 112.5 112.5 271.5z"/></svg>
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
index 847a86e2e68..c72268473ca 100644
--- a/app/views/shared/issuable/_filter.html.haml
+++ b/app/views/shared/issuable/_filter.html.haml
@@ -40,21 +40,21 @@
.issues_bulk_update.hide
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
.filter-item.inline
- = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
+ = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do
%ul
%li
%a{ href: "#", data: { id: "reopen" } } Open
%li
%a{ href: "#", data: {id: "close" } } Closed
.filter-item.inline
- = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
+ = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } })
.filter-item.inline
- = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
+ = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } })
.filter-item.inline.labels-filter
= render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true }
.filter-item.inline
- = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
+ = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do
%ul
%li
%a{ href: "#", data: { id: "subscribe" } } Subscribe
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 330fa8a5b10..b447996a8ab 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -10,85 +10,93 @@
.check-all-holder
= check_box_tag "check_all_issues", nil, false,
class: "check_all_issues left"
- .issues-other-filters.filtered-search-container
- .filtered-search-input-container
- .scroll-container
- %ul.tokens-container.list-unstyled
- %li.input-token
- %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
- = icon('filter')
- %button.clear-search.hidden{ type: 'button' }
- = icon('times')
- #js-dropdown-hint.dropdown-menu.hint-dropdown
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { action: 'submit' } }
- %button.btn.btn-link
- = icon('search')
- %span
- Press Enter or click to search
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link
- -# Encapsulate static class name `{{icon}}` inside #{} to bypass
- -# haml lint's ClassAttributeWithStaticValue
- %i.fa{ class: "#{'{{icon}}'}" }
- %span.js-filter-hint
- {{hint}}
- %span.js-filter-tag.dropdown-light-content
- {{tag}}
- #js-dropdown-author.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
+ .issues-other-filters.filtered-search-wrapper
+ .filtered-search-box
+ = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'),
+ options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
+ toggle_class: "filtered-search-history-dropdown-toggle-button",
+ dropdown_class: "filtered-search-history-dropdown",
+ content_class: "filtered-search-history-dropdown-content",
+ title: "Recent searches" }) do
+ .js-filtered-search-history-dropdown
+ .filtered-search-box-input-container
+ .scroll-container
+ %ul.tokens-container.list-unstyled
+ %li.input-token
+ %input.form-control.filtered-search{ id: "filtered-search-#{type.to_s}", placeholder: 'Search or filter results...', data: { 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } }
+ = icon('filter')
+ %button.clear-search.hidden{ type: 'button' }
+ = icon('times')
+ #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { action: 'submit' } }
+ %button.btn.btn-link
+ = icon('search')
%span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
- %button.btn.btn-link
- No Assignee
- %li.divider
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.dropdown-user
- %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
- .dropdown-user-details
- %span
- {{name}}
- %span.dropdown-light-content
- @{{username}}
- #js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
- %button.btn.btn-link
- No Milestone
- %li.filter-dropdown-item{ data: { value: 'upcoming' } }
- %button.btn.btn-link
- Upcoming
- %li.filter-dropdown-item{ 'data-value' => 'started' }
- %button.btn.btn-link
- Started
- %li.divider
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link.js-data-value
- {{title}}
- #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
- %ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
- %button.btn.btn-link
- No Label
- %li.divider
- %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
- %li.filter-dropdown-item
- %button.btn.btn-link
- %span.dropdown-label-box{ style: 'background: {{color}}' }
- %span.label-title.js-data-value
+ Press Enter or click to search
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link
+ -# Encapsulate static class name `{{icon}}` inside #{} to bypass
+ -# haml lint's ClassAttributeWithStaticValue
+ %i.fa{ class: "#{'{{icon}}'}" }
+ %span.js-filter-hint
+ {{hint}}
+ %span.js-filter-tag.dropdown-light-content
+ {{tag}}
+ #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.dropdown-user
+ %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
+ .dropdown-user-details
+ %span
+ {{name}}
+ %span.dropdown-light-content
+ @{{username}}
+ #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ %button.btn.btn-link
+ No Assignee
+ %li.divider
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.dropdown-user
+ %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
+ .dropdown-user-details
+ %span
+ {{name}}
+ %span.dropdown-light-content
+ @{{username}}
+ #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ %button.btn.btn-link
+ No Milestone
+ %li.filter-dropdown-item{ data: { value: 'upcoming' } }
+ %button.btn.btn-link
+ Upcoming
+ %li.filter-dropdown-item{ 'data-value' => 'started' }
+ %button.btn.btn-link
+ Started
+ %li.divider
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value
{{title}}
+ #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } }
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ %button.btn.btn-link
+ No Label
+ %li.divider
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link
+ %span.dropdown-label-box{ style: 'background: {{color}}' }
+ %span.label-title.js-data-value
+ {{title}}
.filter-dropdown-container
- if type == :boards
- if can?(current_user, :admin_list, @project)
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 92d2d93a732..2e0d6a129fb 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -160,13 +160,13 @@
- project_ref = cross_project_reference(@project, issuable)
.block.project-reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
.cross-project-reference.hide-collapsed
%span
Reference:
%cite{ title: project_ref }
= project_ref
- = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript
gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 647e05e5ff7..e8b04f56839 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -29,5 +29,5 @@
- if @label.persisted?
= f.submit 'Save changes', class: 'btn btn-save js-save-button'
- else
- = f.submit 'Create Label', class: 'btn btn-create js-save-button'
+ = f.submit 'Create label', class: 'btn btn-create js-save-button'
= link_to 'Cancel', back_path, class: 'btn btn-cancel'
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index 2810f1377b2..ccc808ff43e 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -122,10 +122,10 @@
- if milestone_ref.present?
.block.reference
.sidebar-collapsed-icon.dont-change-state
- = clipboard_button(clipboard_text: milestone_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left")
.cross-project-reference.hide-collapsed
%span
Reference:
%cite{ title: milestone_ref }
= milestone_ref
- = clipboard_button(clipboard_text: milestone_ref, title: "Copy reference to clipboard", placement: "left")
+ = clipboard_button(text: milestone_ref, title: "Copy reference to clipboard", placement: "left")
diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml
index 37e2a377a69..ee3be3c789a 100644
--- a/app/views/shared/web_hooks/_form.html.haml
+++ b/app/views/shared/web_hooks/_form.html.haml
@@ -89,7 +89,7 @@
= f.label :enable_ssl_verification do
= f.check_box :enable_ssl_verification
%strong Enable SSL verification
- = f.submit "Add Webhook", class: "btn btn-create"
+ = f.submit "Add webhook", class: "btn btn-create"
%hr
%h5.prepend-top-default
Webhooks (#{hooks.count})
diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml
index adc07bcba73..00788e77b6b 100644
--- a/app/views/u2f/_register.html.haml
+++ b/app/views/u2f/_register.html.haml
@@ -7,13 +7,13 @@
- if current_user.two_factor_otp_enabled?
.row.append-bottom-10
.col-md-3
- %button#js-setup-u2f-device.btn.btn-info Setup New U2F Device
+ %button#js-setup-u2f-device.btn.btn-info Setup new U2F device
.col-md-9
%p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.
- else
.row.append-bottom-10
.col-md-3
- %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup New U2F Device
+ %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup new U2F device
.col-md-9
%p.text-warning You need to register a two-factor authentication app before you can set up a U2F device.
@@ -36,7 +36,7 @@
= text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: "Pick a name"
.col-md-3
= hidden_field_tag 'u2f_registration[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
- = submit_tag "Register U2F Device", class: "btn btn-success"
+ = submit_tag "Register U2F device", class: "btn btn-success"
:javascript
var u2fRegister = new U2FRegister($("#js-register-u2f"), gon.u2f);
diff --git a/app/workers/build_coverage_worker.rb b/app/workers/build_coverage_worker.rb
index def0ab1dde1..f7ae996bb17 100644
--- a/app/workers/build_coverage_worker.rb
+++ b/app/workers/build_coverage_worker.rb
@@ -3,7 +3,6 @@ class BuildCoverageWorker
include BuildQueue
def perform(build_id)
- Ci::Build.find_by(id: build_id)
- .try(:update_coverage)
+ Ci::Build.find_by(id: build_id)&.update_coverage
end
end
diff --git a/app/workers/trigger_schedule_worker.rb b/app/workers/trigger_schedule_worker.rb
new file mode 100644
index 00000000000..9c1baf7e6c5
--- /dev/null
+++ b/app/workers/trigger_schedule_worker.rb
@@ -0,0 +1,18 @@
+class TriggerScheduleWorker
+ include Sidekiq::Worker
+ include CronjobQueue
+
+ def perform
+ Ci::TriggerSchedule.active.where("next_run_at < ?", Time.now).find_each do |trigger_schedule|
+ begin
+ Ci::CreateTriggerRequestService.new.execute(trigger_schedule.project,
+ trigger_schedule.trigger,
+ trigger_schedule.ref)
+ rescue => e
+ Rails.logger.error "#{trigger_schedule.id}: Failed to trigger_schedule job: #{e.message}"
+ ensure
+ trigger_schedule.schedule_next_run!
+ end
+ end
+ end
+end