summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorDavid Planella <dplanella@gitlab.com>2019-05-28 22:33:07 +0200
committerDavid Planella <dplanella@gitlab.com>2019-05-28 22:33:07 +0200
commitbebc7b58b84b4061fec25c7bf1858c1abd274957 (patch)
tree1b3372425af4451960d6bfba249bd1cc6eed15e5 /app
parent38e55d6b96e2bee43e7773182679fbcfe6f41ff3 (diff)
parent8b200634c4909f12cd012c3197c4ffbaf50b99d5 (diff)
downloadgitlab-ce-bebc7b58b84b4061fec25c7bf1858c1abd274957.tar.gz
Merged changes from master
Diffstat (limited to 'app')
-rw-r--r--app/assets/images/favicon-yellow.pngbin1667 -> 1481 bytes
-rw-r--r--app/assets/images/none-scheme-preview.pngbin0 -> 5971 bytes
-rw-r--r--app/assets/images/select2-spinner.gifbin0 -> 1849 bytes
-rw-r--r--app/assets/images/select2.pngbin0 -> 613 bytes
-rw-r--r--app/assets/images/select2x2.pngbin0 -> 845 bytes
-rw-r--r--app/assets/javascripts/api.js38
-rw-r--r--app/assets/javascripts/avatar_picker.js (renamed from app/assets/javascripts/group_avatar.js)9
-rw-r--r--app/assets/javascripts/awards_handler.js8
-rw-r--r--app/assets/javascripts/badges/components/badge_form.vue2
-rw-r--r--app/assets/javascripts/badges/components/badge_list_row.vue4
-rw-r--r--app/assets/javascripts/batch_comments/mixins/resolved_status.js15
-rw-r--r--app/assets/javascripts/behaviors/copy_to_clipboard.js5
-rw-r--r--app/assets/javascripts/behaviors/gl_emoji.js19
-rw-r--r--app/assets/javascripts/behaviors/markdown/copy_as_gfm.js449
-rw-r--r--app/assets/javascripts/behaviors/markdown/editor_extensions.js106
-rw-r--r--app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/bold.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/code.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_diff.js41
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/inline_html.js46
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/italic.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/link.js21
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/math.js41
-rw-r--r--app/assets/javascripts/behaviors/markdown/marks/strike.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/blockquote.js13
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/code_block.js99
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_details.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_list.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/description_term.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/details.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/doc.js15
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/emoji.js41
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/hard_break.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/heading.js13
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/image.js52
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/list_item.js11
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js10
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/paragraph.js24
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/reference.js52
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/summary.js27
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table.js25
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_body.js24
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_cell.js35
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_head.js24
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js43
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js34
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/table_row.js38
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list.js28
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js49
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/text.js20
-rw-r--r--app/assets/javascripts/behaviors/markdown/nodes/video.js54
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_gfm.js2
-rw-r--r--app/assets/javascripts/behaviors/markdown/render_mermaid.js19
-rw-r--r--app/assets/javascripts/behaviors/markdown/schema.js24
-rw-r--r--app/assets/javascripts/behaviors/markdown/serializer.js24
-rw-r--r--app/assets/javascripts/behaviors/preview_markdown.js19
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js5
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js51
-rw-r--r--app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js1
-rw-r--r--app/assets/javascripts/blob/balsamiq_viewer.js3
-rw-r--r--app/assets/javascripts/blob/blob_file_dropzone.js5
-rw-r--r--app/assets/javascripts/blob/sketch/index.js5
-rw-r--r--app/assets/javascripts/blob/template_selectors/dockerfile_selector.js3
-rw-r--r--app/assets/javascripts/blob/viewer/index.js14
-rw-r--r--app/assets/javascripts/blob_edit/blob_bundle.js11
-rw-r--r--app/assets/javascripts/blob_edit/edit_blob.js20
-rw-r--r--app/assets/javascripts/boards/boards_util.js7
-rw-r--r--app/assets/javascripts/boards/components/board.js5
-rw-r--r--app/assets/javascripts/boards/components/board_blank_state.vue10
-rw-r--r--app/assets/javascripts/boards/components/board_card.vue4
-rw-r--r--app/assets/javascripts/boards/components/board_delete.js3
-rw-r--r--app/assets/javascripts/boards/components/board_list.vue7
-rw-r--r--app/assets/javascripts/boards/components/board_new_issue.vue8
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js33
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue72
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue45
-rw-r--r--app/assets/javascripts/boards/components/issue_due_date.vue17
-rw-r--r--app/assets/javascripts/boards/components/issue_time_estimate.vue2
-rw-r--r--app/assets/javascripts/boards/components/modal/empty_state.vue8
-rw-r--r--app/assets/javascripts/boards/components/modal/footer.vue2
-rw-r--r--app/assets/javascripts/boards/components/modal/header.vue8
-rw-r--r--app/assets/javascripts/boards/components/modal/index.vue13
-rw-r--r--app/assets/javascripts/boards/components/modal/list.vue6
-rw-r--r--app/assets/javascripts/boards/components/modal/lists_dropdown.vue2
-rw-r--r--app/assets/javascripts/boards/components/modal/tabs.vue4
-rw-r--r--app/assets/javascripts/boards/components/new_list_dropdown.js6
-rw-r--r--app/assets/javascripts/boards/components/project_select.vue2
-rw-r--r--app/assets/javascripts/boards/filtered_search_boards.js4
-rw-r--r--app/assets/javascripts/boards/index.js30
-rw-r--r--app/assets/javascripts/boards/mixins/issue_card_inner.js5
-rw-r--r--app/assets/javascripts/boards/models/issue.js28
-rw-r--r--app/assets/javascripts/boards/models/list.js21
-rw-r--r--app/assets/javascripts/boards/models/milestone.js11
-rw-r--r--app/assets/javascripts/boards/services/board_service.js3
-rw-r--r--app/assets/javascripts/boards/stores/actions.js67
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js22
-rw-r--r--app/assets/javascripts/boards/stores/boards_store_ee.js5
-rw-r--r--app/assets/javascripts/boards/stores/index.js14
-rw-r--r--app/assets/javascripts/boards/stores/mutation_types.js21
-rw-r--r--app/assets/javascripts/boards/stores/mutations.js92
-rw-r--r--app/assets/javascripts/boards/stores/state.js3
-rw-r--r--app/assets/javascripts/breakpoints.js3
-rw-r--r--app/assets/javascripts/ci_variable_list/ci_variable_list.js42
-rw-r--r--app/assets/javascripts/ci_variable_list/native_form_variable_list.js1
-rw-r--r--app/assets/javascripts/clusters/clusters_bundle.js161
-rw-r--r--app/assets/javascripts/clusters/components/application_row.vue256
-rw-r--r--app/assets/javascripts/clusters/components/applications.vue406
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_button.vue33
-rw-r--r--app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue74
-rw-r--r--app/assets/javascripts/clusters/constants.js31
-rw-r--r--app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js5
-rw-r--r--app/assets/javascripts/clusters/services/application_state_machine.js175
-rw-r--r--app/assets/javascripts/clusters/services/clusters_service.js11
-rw-r--r--app/assets/javascripts/clusters/stores/clusters_store.js140
-rw-r--r--app/assets/javascripts/commit/image_file.js44
-rw-r--r--app/assets/javascripts/commons/bootstrap.js60
-rw-r--r--app/assets/javascripts/commons/jquery.js5
-rw-r--r--app/assets/javascripts/commons/polyfills.js2
-rw-r--r--app/assets/javascripts/contextual_sidebar.js72
-rw-r--r--app/assets/javascripts/create_label.js9
-rw-r--r--app/assets/javascripts/create_merge_request_dropdown.js6
-rw-r--r--app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js3
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue2
-rw-r--r--app/assets/javascripts/diff_notes/components/comment_resolve_btn.js9
-rw-r--r--app/assets/javascripts/diff_notes/components/diff_note_avatars.js3
-rw-r--r--app/assets/javascripts/diff_notes/components/jump_to_discussion.js5
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_btn.js12
-rw-r--r--app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js5
-rw-r--r--app/assets/javascripts/diff_notes/services/resolve.js4
-rw-r--r--app/assets/javascripts/diffs/components/app.vue113
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue36
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions.vue103
-rw-r--r--app/assets/javascripts/diffs/components/compare_versions_dropdown.vue18
-rw-r--r--app/assets/javascripts/diffs/components/diff_content.vue48
-rw-r--r--app/assets/javascripts/diffs/components/diff_discussions.vue2
-rw-r--r--app/assets/javascripts/diffs/components/diff_file.vue80
-rw-r--r--app/assets/javascripts/diffs/components/diff_file_header.vue172
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_gutter_content.vue8
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue16
-rw-r--r--app/assets/javascripts/diffs/components/diff_stats.vue52
-rw-r--r--app/assets/javascripts/diffs/components/diff_table_cell.vue24
-rw-r--r--app/assets/javascripts/diffs/components/edit_button.vue27
-rw-r--r--app/assets/javascripts/diffs/components/image_diff_overlay.vue11
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_comment_row.vue3
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue15
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_view.vue11
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue5
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue4
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_view.vue52
-rw-r--r--app/assets/javascripts/diffs/components/settings_dropdown.vue92
-rw-r--r--app/assets/javascripts/diffs/components/tree_list.vue126
-rw-r--r--app/assets/javascripts/diffs/constants.js25
-rw-r--r--app/assets/javascripts/diffs/index.js66
-rw-r--r--app/assets/javascripts/diffs/mixins/draft_comments.js10
-rw-r--r--app/assets/javascripts/diffs/mixins/image_diff.js13
-rw-r--r--app/assets/javascripts/diffs/store/actions.js207
-rw-r--r--app/assets/javascripts/diffs/store/getters.js30
-rw-r--r--app/assets/javascripts/diffs/store/modules/diff_state.js13
-rw-r--r--app/assets/javascripts/diffs/store/mutation_types.js15
-rw-r--r--app/assets/javascripts/diffs/store/mutations.js79
-rw-r--r--app/assets/javascripts/diffs/store/utils.js143
-rw-r--r--app/assets/javascripts/diffs/workers/tree_worker.js19
-rw-r--r--app/assets/javascripts/dirty_submit/dirty_submit_form.js20
-rw-r--r--app/assets/javascripts/dismissable_callout.js27
-rw-r--r--app/assets/javascripts/dropzone_input.js15
-rw-r--r--app/assets/javascripts/due_date_select.js6
-rw-r--r--app/assets/javascripts/emoji/no_emoji_validator.js63
-rw-r--r--app/assets/javascripts/environments/components/confirm_rollback_modal.vue108
-rw-r--r--app/assets/javascripts/environments/components/container.vue20
-rw-r--r--app/assets/javascripts/environments/components/environment_actions.vue2
-rw-r--r--app/assets/javascripts/environments/components/environment_item.vue78
-rw-r--r--app/assets/javascripts/environments/components/environment_rollback.vue42
-rw-r--r--app/assets/javascripts/environments/components/environment_terminal_button.vue8
-rw-r--r--app/assets/javascripts/environments/components/environments_app.vue16
-rw-r--r--app/assets/javascripts/environments/components/environments_table.vue79
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_bundle.js11
-rw-r--r--app/assets/javascripts/environments/folder/environments_folder_view.vue16
-rw-r--r--app/assets/javascripts/environments/index.js5
-rw-r--r--app/assets/javascripts/environments/mixins/canary_callout_mixin.js5
-rw-r--r--app/assets/javascripts/environments/mixins/container_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environment_item_mixin.js13
-rw-r--r--app/assets/javascripts/environments/mixins/environments_app_mixin.js32
-rw-r--r--app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js29
-rw-r--r--app/assets/javascripts/environments/mixins/environments_mixin.js42
-rw-r--r--app/assets/javascripts/environments/mixins/environments_table_mixin.js10
-rw-r--r--app/assets/javascripts/environments/services/environments_service.js4
-rw-r--r--app/assets/javascripts/environments/stores/environments_store.js28
-rw-r--r--app/assets/javascripts/environments/stores/helpers.js8
-rw-r--r--app/assets/javascripts/error_tracking/components/error_tracking_list.vue124
-rw-r--r--app/assets/javascripts/error_tracking/index.js31
-rw-r--r--app/assets/javascripts/error_tracking/services/index.js7
-rw-r--r--app/assets/javascripts/error_tracking/store/actions.js52
-rw-r--r--app/assets/javascripts/error_tracking/store/index.js19
-rw-r--r--app/assets/javascripts/error_tracking/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/error_tracking/store/mutations.js14
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/app.vue129
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue91
-rw-r--r--app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue82
-rw-r--r--app/assets/javascripts/error_tracking_settings/index.js29
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/actions.js91
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/getters.js44
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/index.js16
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/mutations.js61
-rw-r--r--app/assets/javascripts/error_tracking_settings/store/state.js12
-rw-r--r--app/assets/javascripts/error_tracking_settings/utils.js18
-rw-r--r--app/assets/javascripts/event_tracking/notes.js1
-rw-r--r--app/assets/javascripts/feature_highlight/feature_highlight.js4
-rw-r--r--app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js30
-rw-r--r--app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js8
-rw-r--r--app/assets/javascripts/filtered_search/available_dropdown_mappings.js164
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue4
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_ajax_filter.js68
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_emoji.js3
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_non_user.js3
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_user.js91
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js103
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js32
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_token_keys.js24
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js153
-rw-r--r--app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js23
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_storage_keys.js4
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service_error.js4
-rw-r--r--app/assets/javascripts/filtered_search/visual_token_value.js117
-rw-r--r--app/assets/javascripts/fly_out_nav.js8
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue6
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue67
-rw-r--r--app/assets/javascripts/frequent_items/index.js6
-rw-r--r--app/assets/javascripts/gfm_auto_complete.js64
-rw-r--r--app/assets/javascripts/gl_dropdown.js27
-rw-r--r--app/assets/javascripts/gl_field_error.js3
-rw-r--r--app/assets/javascripts/gl_field_errors.js2
-rw-r--r--app/assets/javascripts/gl_form.js6
-rw-r--r--app/assets/javascripts/gpg_badges.js2
-rw-r--r--app/assets/javascripts/groups/components/app.vue2
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue2
-rw-r--r--app/assets/javascripts/groups/transfer_dropdown.js3
-rw-r--r--app/assets/javascripts/groups_select.js173
-rw-r--r--app/assets/javascripts/header.js6
-rw-r--r--app/assets/javascripts/helpers/monitor_helper.js17
-rw-r--r--app/assets/javascripts/ide/components/activity_bar.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/actions.vue35
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/list.vue2
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/message_field.vue5
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue11
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue6
-rw-r--r--app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue2
-rw-r--r--app/assets/javascripts/ide/components/editor_mode_dropdown.vue4
-rw-r--r--app/assets/javascripts/ide/components/file_row_extra.vue5
-rw-r--r--app/assets/javascripts/ide/components/file_templates/dropdown.vue2
-rw-r--r--app/assets/javascripts/ide/components/ide.vue46
-rw-r--r--app/assets/javascripts/ide/components/ide_status_bar.vue15
-rw-r--r--app/assets/javascripts/ide/components/ide_tree.vue4
-rw-r--r--app/assets/javascripts/ide/components/jobs/detail.vue2
-rw-r--r--app/assets/javascripts/ide/components/merge_requests/list.vue4
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/index.vue35
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/modal.vue74
-rw-r--r--app/assets/javascripts/ide/components/new_dropdown/upload.vue2
-rw-r--r--app/assets/javascripts/ide/components/panes/right.vue2
-rw-r--r--app/assets/javascripts/ide/components/pipelines/list.vue14
-rw-r--r--app/assets/javascripts/ide/components/repo_commit_section.vue2
-rw-r--r--app/assets/javascripts/ide/components/repo_editor.vue12
-rw-r--r--app/assets/javascripts/ide/components/repo_tab.vue4
-rw-r--r--app/assets/javascripts/ide/components/resizable_panel.vue4
-rw-r--r--app/assets/javascripts/ide/components/shared/tokened_input.vue4
-rw-r--r--app/assets/javascripts/ide/constants.js28
-rw-r--r--app/assets/javascripts/ide/ide_router.js15
-rw-r--r--app/assets/javascripts/ide/index.js20
-rw-r--r--app/assets/javascripts/ide/lib/common/model.js6
-rw-r--r--app/assets/javascripts/ide/lib/files.js119
-rw-r--r--app/assets/javascripts/ide/services/index.js3
-rw-r--r--app/assets/javascripts/ide/stores/actions.js76
-rw-r--r--app/assets/javascripts/ide/stores/actions/file.js7
-rw-r--r--app/assets/javascripts/ide/stores/actions/merge_request.js33
-rw-r--r--app/assets/javascripts/ide/stores/actions/project.js34
-rw-r--r--app/assets/javascripts/ide/stores/actions/tree.js46
-rw-r--r--app/assets/javascripts/ide/stores/getters.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/actions.js36
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/constants.js10
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/getters.js16
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutation_types.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/mutations.js12
-rw-r--r--app/assets/javascripts/ide/stores/modules/commit/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/actions.js25
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/getters.js5
-rw-r--r--app/assets/javascripts/ide/stores/modules/file_templates/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/mutations.js3
-rw-r--r--app/assets/javascripts/ide/stores/modules/pipelines/state.js1
-rw-r--r--app/assets/javascripts/ide/stores/mutations.js20
-rw-r--r--app/assets/javascripts/ide/stores/mutations/merge_request.js3
-rw-r--r--app/assets/javascripts/ide/stores/mutations/tree.js14
-rw-r--r--app/assets/javascripts/ide/stores/utils.js47
-rw-r--r--app/assets/javascripts/ide/stores/workers/files_decorator_worker.js100
-rw-r--r--app/assets/javascripts/import_projects/components/import_projects_table.vue101
-rw-r--r--app/assets/javascripts/import_projects/components/import_status.vue47
-rw-r--r--app/assets/javascripts/import_projects/components/imported_project_table_row.vue55
-rw-r--r--app/assets/javascripts/import_projects/components/provider_repo_table_row.vue110
-rw-r--r--app/assets/javascripts/import_projects/constants.js48
-rw-r--r--app/assets/javascripts/import_projects/event_hub.js (renamed from app/assets/javascripts/monitoring/event_hub.js)0
-rw-r--r--app/assets/javascripts/import_projects/index.js48
-rw-r--r--app/assets/javascripts/import_projects/store/actions.js106
-rw-r--r--app/assets/javascripts/import_projects/store/getters.js22
-rw-r--r--app/assets/javascripts/import_projects/store/index.js18
-rw-r--r--app/assets/javascripts/import_projects/store/mutation_types.js11
-rw-r--r--app/assets/javascripts/import_projects/store/mutations.js55
-rw-r--r--app/assets/javascripts/import_projects/store/state.js15
-rw-r--r--app/assets/javascripts/integrations/integration_settings_form.js9
-rw-r--r--app/assets/javascripts/issuable/auto_width_dropdown_select.js12
-rw-r--r--app/assets/javascripts/issuable_bulk_update_actions.js6
-rw-r--r--app/assets/javascripts/issuable_context.js12
-rw-r--r--app/assets/javascripts/issuable_form.js65
-rw-r--r--app/assets/javascripts/issuable_index.js8
-rw-r--r--app/assets/javascripts/issuable_suggestions/index.js4
-rw-r--r--app/assets/javascripts/issue.js29
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue82
-rw-r--r--app/assets/javascripts/issue_show/components/description.vue28
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue7
-rw-r--r--app/assets/javascripts/issue_show/components/fields/title.vue2
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue53
-rw-r--r--app/assets/javascripts/issue_show/components/title.vue1
-rw-r--r--app/assets/javascripts/issue_show/index.js7
-rw-r--r--app/assets/javascripts/issue_show/stores/index.js11
-rw-r--r--app/assets/javascripts/issue_show/utils/parse_data.js15
-rw-r--r--app/assets/javascripts/issue_status_select.js3
-rw-r--r--app/assets/javascripts/jobs/components/artifacts_block.vue32
-rw-r--r--app/assets/javascripts/jobs/components/commit_block.vue23
-rw-r--r--app/assets/javascripts/jobs/components/empty_state.vue2
-rw-r--r--app/assets/javascripts/jobs/components/job_app.vue52
-rw-r--r--app/assets/javascripts/jobs/components/job_container_item.vue8
-rw-r--r--app/assets/javascripts/jobs/components/job_log_controllers.vue6
-rw-r--r--app/assets/javascripts/jobs/components/sidebar.vue94
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue3
-rw-r--r--app/assets/javascripts/jobs/components/stages_dropdown.vue51
-rw-r--r--app/assets/javascripts/jobs/components/trigger_block.vue19
-rw-r--r--app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue30
-rw-r--r--app/assets/javascripts/jobs/index.js1
-rw-r--r--app/assets/javascripts/jobs/store/actions.js26
-rw-r--r--app/assets/javascripts/jobs/store/getters.js30
-rw-r--r--app/assets/javascripts/jobs/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/jobs/store/mutations.js19
-rw-r--r--app/assets/javascripts/jobs/store/state.js1
-rw-r--r--app/assets/javascripts/label_manager.js33
-rw-r--r--app/assets/javascripts/labels_select.js143
-rw-r--r--app/assets/javascripts/layout_nav.js65
-rw-r--r--app/assets/javascripts/lazy_loader.js1
-rw-r--r--app/assets/javascripts/lib/graphql.js29
-rw-r--r--app/assets/javascripts/lib/utils/autosave.js32
-rw-r--r--app/assets/javascripts/lib/utils/chart_utils.js83
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js123
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js201
-rw-r--r--app/assets/javascripts/lib/utils/file_upload.js3
-rw-r--r--app/assets/javascripts/lib/utils/grammar.js40
-rw-r--r--app/assets/javascripts/lib/utils/highlight.js44
-rw-r--r--app/assets/javascripts/lib/utils/http_status.js1
-rw-r--r--app/assets/javascripts/lib/utils/icon_utils.js18
-rw-r--r--app/assets/javascripts/lib/utils/number_utils.js28
-rw-r--r--app/assets/javascripts/lib/utils/poll.js20
-rw-r--r--app/assets/javascripts/lib/utils/simple_poll.js4
-rw-r--r--app/assets/javascripts/lib/utils/text_markdown.js166
-rw-r--r--app/assets/javascripts/lib/utils/text_utility.js63
-rw-r--r--app/assets/javascripts/lib/utils/url_utility.js57
-rw-r--r--app/assets/javascripts/lib/utils/webpack.js18
-rw-r--r--app/assets/javascripts/locale/index.js2
-rw-r--r--app/assets/javascripts/locale/sprintf.js4
-rw-r--r--app/assets/javascripts/main.js151
-rw-r--r--app/assets/javascripts/member_expiration_date.js1
-rw-r--r--app/assets/javascripts/members.js27
-rw-r--r--app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js2
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflict_store.js15
-rw-r--r--app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js3
-rw-r--r--app/assets/javascripts/merge_request.js9
-rw-r--r--app/assets/javascripts/merge_request_tabs.js9
-rw-r--r--app/assets/javascripts/milestone.js3
-rw-r--r--app/assets/javascripts/milestone_select.js15
-rw-r--r--app/assets/javascripts/mini_pipeline_graph_dropdown.js3
-rw-r--r--app/assets/javascripts/mirrors/mirror_repos.js2
-rw-r--r--app/assets/javascripts/mirrors/ssh_mirror.js118
-rw-r--r--app/assets/javascripts/monitoring/components/charts/area.vue253
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue315
-rw-r--r--app/assets/javascripts/monitoring/components/graph.vue329
-rw-r--r--app/assets/javascripts/monitoring/components/graph/axis.vue118
-rw-r--r--app/assets/javascripts/monitoring/components/graph/deployment.vue48
-rw-r--r--app/assets/javascripts/monitoring/components/graph/flag.vue151
-rw-r--r--app/assets/javascripts/monitoring/components/graph/legend.vue62
-rw-r--r--app/assets/javascripts/monitoring/components/graph/path.vue65
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_info.vue28
-rw-r--r--app/assets/javascripts/monitoring/components/graph/track_line.vue33
-rw-r--r--app/assets/javascripts/monitoring/constants.js29
-rw-r--r--app/assets/javascripts/monitoring/mixins/monitoring_mixins.js86
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js8
-rw-r--r--app/assets/javascripts/monitoring/services/monitoring_service.js75
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js117
-rw-r--r--app/assets/javascripts/monitoring/stores/index.js21
-rw-r--r--app/assets/javascripts/monitoring/stores/monitoring_store.js77
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js15
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js45
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js12
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js83
-rw-r--r--app/assets/javascripts/monitoring/utils.js33
-rw-r--r--app/assets/javascripts/monitoring/utils/date_time_formatters.js42
-rw-r--r--app/assets/javascripts/monitoring/utils/measurements.js44
-rw-r--r--app/assets/javascripts/monitoring/utils/multiple_time_series.js219
-rw-r--r--app/assets/javascripts/mr_notes/index.js72
-rw-r--r--app/assets/javascripts/mr_notes/init_notes.js70
-rw-r--r--app/assets/javascripts/mr_popover/components/mr_popover.vue110
-rw-r--r--app/assets/javascripts/mr_popover/constants.js12
-rw-r--r--app/assets/javascripts/mr_popover/index.js67
-rw-r--r--app/assets/javascripts/mr_popover/queries/merge_request.graphql14
-rw-r--r--app/assets/javascripts/namespace_select.js3
-rw-r--r--app/assets/javascripts/namespaces/leave_by_url.js22
-rw-r--r--app/assets/javascripts/notebook/cells/code.vue15
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue3
-rw-r--r--app/assets/javascripts/notebook/cells/output/html.vue15
-rw-r--r--app/assets/javascripts/notebook/cells/output/image.vue20
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue97
-rw-r--r--app/assets/javascripts/notebook/cells/prompt.vue10
-rw-r--r--app/assets/javascripts/notebook/index.vue4
-rw-r--r--app/assets/javascripts/notes.js76
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue49
-rw-r--r--app/assets/javascripts/notes/components/diff_with_note.vue33
-rw-r--r--app/assets/javascripts/notes/components/discussion_actions.vue58
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter.vue69
-rw-r--r--app/assets/javascripts/notes/components/discussion_filter_note.vue52
-rw-r--r--app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue28
-rw-r--r--app/assets/javascripts/notes/components/discussion_notes.vue155
-rw-r--r--app/assets/javascripts/notes/components/discussion_reply_placeholder.vue17
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_button.vue28
-rw-r--r--app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue34
-rw-r--r--app/assets/javascripts/notes/components/note_actions.vue49
-rw-r--r--app/assets/javascripts/notes/components/note_actions/reply_button.vue30
-rw-r--r--app/assets/javascripts/notes/components/note_awards_list.vue18
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue17
-rw-r--r--app/assets/javascripts/notes/components/note_form.vue184
-rw-r--r--app/assets/javascripts/notes/components/note_header.vue48
-rw-r--r--app/assets/javascripts/notes/components/noteable_discussion.vue362
-rw-r--r--app/assets/javascripts/notes/components/noteable_note.vue97
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue45
-rw-r--r--app/assets/javascripts/notes/components/toggle_replies_widget.vue8
-rw-r--r--app/assets/javascripts/notes/constants.js8
-rw-r--r--app/assets/javascripts/notes/discussion_filters.js13
-rw-r--r--app/assets/javascripts/notes/index.js17
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js3
-rw-r--r--app/assets/javascripts/notes/mixins/diff_line_note_form.js10
-rw-r--r--app/assets/javascripts/notes/mixins/draft.js8
-rw-r--r--app/assets/javascripts/notes/mixins/get_discussion.js7
-rw-r--r--app/assets/javascripts/notes/mixins/note_form.js24
-rw-r--r--app/assets/javascripts/notes/mixins/resolvable.js4
-rw-r--r--app/assets/javascripts/notes/stores/actions.js127
-rw-r--r--app/assets/javascripts/notes/stores/getters.js7
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js1
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js2
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js23
-rw-r--r--app/assets/javascripts/notes/stores/utils.js18
-rw-r--r--app/assets/javascripts/notifications_dropdown.js4
-rw-r--r--app/assets/javascripts/operation_settings/components/external_dashboard.vue57
-rw-r--r--app/assets/javascripts/operation_settings/index.js26
-rw-r--r--app/assets/javascripts/pages/admin/application_settings/show/index.js3
-rw-r--r--app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js2
-rw-r--r--app/assets/javascripts/pages/admin/clusters/destroy/index.js (renamed from app/assets/javascripts/pages/groups/clusters/update/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/clusters/edit/index.js (renamed from app/assets/javascripts/pages/projects/clusters/update/index.js)0
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index.js21
-rw-r--r--app/assets/javascripts/pages/admin/clusters/index/index.js6
-rw-r--r--app/assets/javascripts/pages/admin/clusters/show/index.js5
-rw-r--r--app/assets/javascripts/pages/admin/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/admin/index.js6
-rw-r--r--app/assets/javascripts/pages/dashboard/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/dashboard/projects/index.js2
-rw-r--r--app/assets/javascripts/pages/explore/projects/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/clusters/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/clusters/index/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/details/index.js5
-rw-r--r--app/assets/javascripts/pages/groups/edit/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/index.js9
-rw-r--r--app/assets/javascripts/pages/groups/issues/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/groups/merge_requests/index.js3
-rw-r--r--app/assets/javascripts/pages/groups/new/index.js4
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_details.js31
-rw-r--r--app/assets/javascripts/pages/groups/shared/group_tabs.js (renamed from app/assets/javascripts/pages/groups/show/group_tabs.js)0
-rw-r--r--app/assets/javascripts/pages/groups/show/index.js29
-rw-r--r--app/assets/javascripts/pages/import/gitea/status/index.js7
-rw-r--r--app/assets/javascripts/pages/import/github/status/index.js7
-rw-r--r--app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue8
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js2
-rw-r--r--app/assets/javascripts/pages/profiles/show/index.js17
-rw-r--r--app/assets/javascripts/pages/projects/clusters/edit/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/clusters/index/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/edit/index.js21
-rw-r--r--app/assets/javascripts/pages/projects/environments/metrics/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/error_tracking/index.js5
-rw-r--r--app/assets/javascripts/pages/projects/graphs/charts/index.js91
-rw-r--r--app/assets/javascripts/pages/projects/index.js6
-rw-r--r--app/assets/javascripts/pages/projects/issues/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/form.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/index/index.js4
-rw-r--r--app/assets/javascripts/pages/projects/issues/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/issues/show.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/edit/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/labels/new/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue8
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js77
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js8
-rw-r--r--app/assets/javascripts/pages/projects/pipelines/charts/index.js81
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/releases/index/index.js3
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js7
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue4
-rw-r--r--app/assets/javascripts/pages/projects/shared/permissions/constants.js10
-rw-r--r--app/assets/javascripts/pages/projects/show/index.js10
-rw-r--r--app/assets/javascripts/pages/projects/tags/releases/index.js8
-rw-r--r--app/assets/javascripts/pages/projects/tree/show/index.js8
-rw-r--r--app/assets/javascripts/pages/search/show/search.js7
-rw-r--r--app/assets/javascripts/pages/sessions/new/index.js7
-rw-r--r--app/assets/javascripts/pages/sessions/new/oauth_remember_me.js5
-rw-r--r--app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js32
-rw-r--r--app/assets/javascripts/pages/users/activity_calendar.js69
-rw-r--r--app/assets/javascripts/pages/users/user_overview_block.js18
-rw-r--r--app/assets/javascripts/pages/users/user_tabs.js5
-rw-r--r--app/assets/javascripts/pdf/index.vue6
-rw-r--r--app/assets/javascripts/performance_bar/components/detailed_metric.vue34
-rw-r--r--app/assets/javascripts/performance_bar/components/performance_bar_app.vue9
-rw-r--r--app/assets/javascripts/performance_bar/components/request_selector.vue7
-rw-r--r--app/assets/javascripts/persistent_user_callout.js42
-rw-r--r--app/assets/javascripts/pipelines/components/graph/action_component.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue46
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_name_component.vue5
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue97
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_triggerer.vue35
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_url.vue45
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_actions.vue18
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue23
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table.vue53
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_table_row.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/stage.vue2
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_component_mixin.js44
-rw-r--r--app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js16
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines.js9
-rw-r--r--app/assets/javascripts/pipelines/mixins/stage_column_mixin.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js18
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_mediator.js16
-rw-r--r--app/assets/javascripts/pipelines/services/pipeline_service.js4
-rw-r--r--app/assets/javascripts/pipelines/stores/pipeline_store.js1
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/project_label_subscription.js4
-rw-r--r--app/assets/javascripts/project_select.js175
-rw-r--r--app/assets/javascripts/project_select_combo_button.js10
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue4
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue2
-rw-r--r--app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue6
-rw-r--r--app/assets/javascripts/projects/project_new.js75
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js3
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js3
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js7
-rw-r--r--app/assets/javascripts/raven/index.js7
-rw-r--r--app/assets/javascripts/raven/raven_config.js11
-rw-r--r--app/assets/javascripts/registry/components/table_registry.vue8
-rw-r--r--app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue118
-rw-r--r--app/assets/javascripts/related_merge_requests/index.js24
-rw-r--r--app/assets/javascripts/related_merge_requests/store/actions.js37
-rw-r--r--app/assets/javascripts/related_merge_requests/store/index.js14
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutation_types.js4
-rw-r--r--app/assets/javascripts/related_merge_requests/store/mutations.js19
-rw-r--r--app/assets/javascripts/related_merge_requests/store/state.js7
-rw-r--r--app/assets/javascripts/releases/components/app.vue82
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue86
-rw-r--r--app/assets/javascripts/releases/index.js24
-rw-r--r--app/assets/javascripts/releases/store/actions.js37
-rw-r--r--app/assets/javascripts/releases/store/index.js14
-rw-r--r--app/assets/javascripts/releases/store/mutation_types.js3
-rw-r--r--app/assets/javascripts/releases/store/mutations.js37
-rw-r--r--app/assets/javascripts/releases/store/state.js5
-rw-r--r--app/assets/javascripts/reports/components/issue_status_icon.vue7
-rw-r--r--app/assets/javascripts/reports/components/issues_list.vue6
-rw-r--r--app/assets/javascripts/reports/components/modal_open_name.vue2
-rw-r--r--app/assets/javascripts/reports/components/report_item.vue17
-rw-r--r--app/assets/javascripts/reports/components/report_section.vue8
-rw-r--r--app/assets/javascripts/reports/components/test_issue_body.vue2
-rw-r--r--app/assets/javascripts/reports/store/state.js5
-rw-r--r--app/assets/javascripts/reports/store/utils.js6
-rw-r--r--app/assets/javascripts/repository/components/app.vue3
-rw-r--r--app/assets/javascripts/repository/components/table/header.vue9
-rw-r--r--app/assets/javascripts/repository/components/table/index.vue144
-rw-r--r--app/assets/javascripts/repository/components/table/parent_row.vue37
-rw-r--r--app/assets/javascripts/repository/components/table/row.vue72
-rw-r--r--app/assets/javascripts/repository/fragmentTypes.json1
-rw-r--r--app/assets/javascripts/repository/graphql.js43
-rw-r--r--app/assets/javascripts/repository/index.js29
-rw-r--r--app/assets/javascripts/repository/mixins/get_ref.js14
-rw-r--r--app/assets/javascripts/repository/pages/index.vue18
-rw-r--r--app/assets/javascripts/repository/pages/tree.vue20
-rw-r--r--app/assets/javascripts/repository/queries/getFiles.graphql55
-rw-r--r--app/assets/javascripts/repository/queries/getProjectPath.graphql3
-rw-r--r--app/assets/javascripts/repository/queries/getRef.graphql3
-rw-r--r--app/assets/javascripts/repository/router.js36
-rw-r--r--app/assets/javascripts/repository/utils/icon.js99
-rw-r--r--app/assets/javascripts/repository/utils/title.js7
-rw-r--r--app/assets/javascripts/right_sidebar.js22
-rw-r--r--app/assets/javascripts/search_autocomplete.js5
-rw-r--r--app/assets/javascripts/serverless/components/area.vue146
-rw-r--r--app/assets/javascripts/serverless/components/environment_row.vue65
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue102
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue53
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue90
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue63
-rw-r--r--app/assets/javascripts/serverless/components/pod_box.vue36
-rw-r--r--app/assets/javascripts/serverless/components/url.vue38
-rw-r--r--app/assets/javascripts/serverless/constants.js3
-rw-r--r--app/assets/javascripts/serverless/serverless_bundle.js146
-rw-r--r--app/assets/javascripts/serverless/services/get_functions_service.js11
-rw-r--r--app/assets/javascripts/serverless/store/actions.js113
-rw-r--r--app/assets/javascripts/serverless/store/getters.js10
-rw-r--r--app/assets/javascripts/serverless/store/index.js18
-rw-r--r--app/assets/javascripts/serverless/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/serverless/store/mutations.js38
-rw-r--r--app/assets/javascripts/serverless/store/state.js13
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js24
-rw-r--r--app/assets/javascripts/serverless/utils.js23
-rw-r--r--app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js3
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue14
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.vue39
-rw-r--r--app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/todo_toggle/todo.vue5
-rw-r--r--app/assets/javascripts/sidebar/lib/sidebar_move_issue.js7
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js3
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js2
-rw-r--r--app/assets/javascripts/snippet/snippet_embed.js6
-rw-r--r--app/assets/javascripts/star.js2
-rw-r--r--app/assets/javascripts/subscription_select.js3
-rw-r--r--app/assets/javascripts/task_list.js72
-rw-r--r--app/assets/javascripts/templates/issuable_template_selector.js3
-rw-r--r--app/assets/javascripts/terminal/terminal.js7
-rw-r--r--app/assets/javascripts/u2f/error.js12
-rw-r--r--app/assets/javascripts/usage_ping_consent.js3
-rw-r--r--app/assets/javascripts/users_select.js247
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/deployment.vue49
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue46
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue62
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue105
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue38
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue41
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue37
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue101
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue70
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue315
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue34
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/constants.js5
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/index.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js13
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue80
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js3
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js17
-rw-r--r--app/assets/javascripts/vue_shared/components/bar_chart.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/callout.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/changed_file_icon.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/ci_icon.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/clipboard_button.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/commit.vue72
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/deprecated_modal.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue21
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/empty_component.js3
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/index.vue (renamed from app/assets/javascripts/ide/components/file_finder/index.vue)140
-rw-r--r--app/assets/javascripts/vue_shared/components/file_finder/item.vue (renamed from app/assets/javascripts/ide/components/file_finder/item.vue)26
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row.vue57
-rw-r--r--app/assets/javascripts/vue_shared/components/file_row_header.vue25
-rw-r--r--app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue69
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue131
-rw-r--r--app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/loading_button.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue34
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/header.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue46
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestions.vue43
-rw-r--r--app/assets/javascripts/vue_shared/components/navigation_tabs.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/system_note.vue8
-rw-r--r--app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/panel_resizer.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/pikaday.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/project_avatar/default.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue74
-rw-r--r--app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue103
-rw-r--r--app/assets/javascripts/vue_shared/components/recaptcha_modal.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/select2_select.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue19
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue56
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue35
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue47
-rw-r--r--app/assets/javascripts/vue_shared/components/svg_gradient.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/table_pagination.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue89
-rw-r--r--app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue46
-rw-r--r--app/assets/javascripts/vue_shared/directives/tooltip.js1
-rw-r--r--app/assets/javascripts/vue_shared/mixins/is_ee.js10
-rw-r--r--app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js217
-rw-r--r--app/assets/javascripts/vuex_shared/modules/modal/actions.js20
-rw-r--r--app/assets/javascripts/vuex_shared/modules/modal/index.js10
-rw-r--r--app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js4
-rw-r--r--app/assets/javascripts/vuex_shared/modules/modal/mutations.js18
-rw-r--r--app/assets/javascripts/vuex_shared/modules/modal/state.js4
-rw-r--r--app/assets/stylesheets/application.scss49
-rw-r--r--app/assets/stylesheets/bootstrap_migration.scss30
-rw-r--r--app/assets/stylesheets/components/avatar.scss232
-rw-r--r--app/assets/stylesheets/components/dashboard_skeleton.scss77
-rw-r--r--app/assets/stylesheets/components/popover.scss44
-rw-r--r--app/assets/stylesheets/components/project_list_item.scss24
-rw-r--r--app/assets/stylesheets/components/related_items_list.scss291
-rw-r--r--app/assets/stylesheets/components/toast.scss53
-rw-r--r--app/assets/stylesheets/framework.scss5
-rw-r--r--app/assets/stylesheets/framework/animations.scss74
-rw-r--r--app/assets/stylesheets/framework/asciidoctor.scss2
-rw-r--r--app/assets/stylesheets/framework/avatar.scss136
-rw-r--r--app/assets/stylesheets/framework/awards.scss29
-rw-r--r--app/assets/stylesheets/framework/blank.scss28
-rw-r--r--app/assets/stylesheets/framework/blocks.scss20
-rw-r--r--app/assets/stylesheets/framework/buttons.scss46
-rw-r--r--app/assets/stylesheets/framework/callout.scss4
-rw-r--r--app/assets/stylesheets/framework/ci_variable_list.scss2
-rw-r--r--app/assets/stylesheets/framework/common.scss142
-rw-r--r--app/assets/stylesheets/framework/contextual_sidebar.scss133
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss10
-rw-r--r--app/assets/stylesheets/framework/emojis.scss2
-rw-r--r--app/assets/stylesheets/framework/files.scss31
-rw-r--r--app/assets/stylesheets/framework/filters.scss24
-rw-r--r--app/assets/stylesheets/framework/forms.scss36
-rw-r--r--app/assets/stylesheets/framework/gfm.scss2
-rw-r--r--app/assets/stylesheets/framework/gitlab_theme.scss40
-rw-r--r--app/assets/stylesheets/framework/header.scss99
-rw-r--r--app/assets/stylesheets/framework/highlight.scss10
-rw-r--r--app/assets/stylesheets/framework/icons.scss9
-rw-r--r--app/assets/stylesheets/framework/images.scss3
-rw-r--r--app/assets/stylesheets/framework/issue_box.scss5
-rw-r--r--app/assets/stylesheets/framework/layout.scss8
-rw-r--r--app/assets/stylesheets/framework/lists.scss12
-rw-r--r--app/assets/stylesheets/framework/logo.scss35
-rw-r--r--app/assets/stylesheets/framework/markdown_area.scss135
-rw-r--r--app/assets/stylesheets/framework/mixins.scss79
-rw-r--r--app/assets/stylesheets/framework/mobile.scss88
-rw-r--r--app/assets/stylesheets/framework/modal.scss63
-rw-r--r--app/assets/stylesheets/framework/notes.scss2
-rw-r--r--app/assets/stylesheets/framework/page_title.scss4
-rw-r--r--app/assets/stylesheets/framework/panels.scss1
-rw-r--r--app/assets/stylesheets/framework/responsive_tables.scss2
-rw-r--r--app/assets/stylesheets/framework/secondary_navigation_elements.scss27
-rw-r--r--app/assets/stylesheets/framework/selects.scss35
-rw-r--r--app/assets/stylesheets/framework/sidebar.scss52
-rw-r--r--app/assets/stylesheets/framework/sortable.scss92
-rw-r--r--app/assets/stylesheets/framework/spinner.scss51
-rw-r--r--app/assets/stylesheets/framework/stacked_progress_bar.scss8
-rw-r--r--app/assets/stylesheets/framework/system_messages.scss111
-rw-r--r--app/assets/stylesheets/framework/tables.scss8
-rw-r--r--app/assets/stylesheets/framework/terms.scss1
-rw-r--r--app/assets/stylesheets/framework/timeline.scss1
-rw-r--r--app/assets/stylesheets/framework/toggle.scss14
-rw-r--r--app/assets/stylesheets/framework/typography.scss147
-rw-r--r--app/assets/stylesheets/framework/variables.scss240
-rw-r--r--app/assets/stylesheets/framework/variables_overrides.scss26
-rw-r--r--app/assets/stylesheets/framework/vue_transitions.scss6
-rw-r--r--app/assets/stylesheets/framework/wells.scss4
-rw-r--r--app/assets/stylesheets/highlight/common.scss18
-rw-r--r--app/assets/stylesheets/highlight/embedded.scss2
-rw-r--r--app/assets/stylesheets/highlight/themes/dark.scss (renamed from app/assets/stylesheets/highlight/dark.scss)6
-rw-r--r--app/assets/stylesheets/highlight/themes/monokai.scss (renamed from app/assets/stylesheets/highlight/monokai.scss)6
-rw-r--r--app/assets/stylesheets/highlight/themes/none.scss238
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-dark.scss (renamed from app/assets/stylesheets/highlight/solarized_dark.scss)6
-rw-r--r--app/assets/stylesheets/highlight/themes/solarized-light.scss (renamed from app/assets/stylesheets/highlight/solarized_light.scss)12
-rw-r--r--app/assets/stylesheets/highlight/themes/white.scss3
-rw-r--r--app/assets/stylesheets/highlight/white.scss3
-rw-r--r--app/assets/stylesheets/highlight/white_base.scss80
-rw-r--r--app/assets/stylesheets/mailers/highlighted_diff_email.scss67
-rw-r--r--app/assets/stylesheets/notify.scss2
-rw-r--r--app/assets/stylesheets/page_bundles/ide.scss80
-rw-r--r--app/assets/stylesheets/page_bundles/xterm.scss6
-rw-r--r--app/assets/stylesheets/pages/boards.scss218
-rw-r--r--app/assets/stylesheets/pages/branches.scss13
-rw-r--r--app/assets/stylesheets/pages/builds.scss83
-rw-r--r--app/assets/stylesheets/pages/clusters.scss16
-rw-r--r--app/assets/stylesheets/pages/commits.scss54
-rw-r--r--app/assets/stylesheets/pages/cycle_analytics.scss1
-rw-r--r--app/assets/stylesheets/pages/detail_page.scss2
-rw-r--r--app/assets/stylesheets/pages/diff.scss487
-rw-r--r--app/assets/stylesheets/pages/editor.scss7
-rw-r--r--app/assets/stylesheets/pages/environments.scss346
-rw-r--r--app/assets/stylesheets/pages/events.scss4
-rw-r--r--app/assets/stylesheets/pages/graph.scss8
-rw-r--r--app/assets/stylesheets/pages/groups.scss155
-rw-r--r--app/assets/stylesheets/pages/help.scss10
-rw-r--r--app/assets/stylesheets/pages/import.scss47
-rw-r--r--app/assets/stylesheets/pages/issuable.scss61
-rw-r--r--app/assets/stylesheets/pages/issues.scss29
-rw-r--r--app/assets/stylesheets/pages/issues/issue_count_badge.scss6
-rw-r--r--app/assets/stylesheets/pages/labels.scss87
-rw-r--r--app/assets/stylesheets/pages/login.scss16
-rw-r--r--app/assets/stylesheets/pages/members.scss47
-rw-r--r--app/assets/stylesheets/pages/merge_conflicts.scss116
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss159
-rw-r--r--app/assets/stylesheets/pages/milestone.scss10
-rw-r--r--app/assets/stylesheets/pages/monitor.scss5
-rw-r--r--app/assets/stylesheets/pages/note_form.scss18
-rw-r--r--app/assets/stylesheets/pages/notes.scss113
-rw-r--r--app/assets/stylesheets/pages/pipeline_schedules.scss7
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss175
-rw-r--r--app/assets/stylesheets/pages/profile.scss86
-rw-r--r--app/assets/stylesheets/pages/profiles/preferences.scss4
-rw-r--r--app/assets/stylesheets/pages/projects.scss298
-rw-r--r--app/assets/stylesheets/pages/prometheus.scss270
-rw-r--r--app/assets/stylesheets/pages/reports.scss7
-rw-r--r--app/assets/stylesheets/pages/search.scss15
-rw-r--r--app/assets/stylesheets/pages/serverless.scss3
-rw-r--r--app/assets/stylesheets/pages/settings.scss67
-rw-r--r--app/assets/stylesheets/pages/stat_graph.scss10
-rw-r--r--app/assets/stylesheets/pages/status.scss9
-rw-r--r--app/assets/stylesheets/pages/todos.scss79
-rw-r--r--app/assets/stylesheets/pages/tree.scss20
-rw-r--r--app/assets/stylesheets/pages/ui_dev_kit.scss2
-rw-r--r--app/assets/stylesheets/pages/wiki.scss6
-rw-r--r--app/assets/stylesheets/performance_bar.scss8
-rw-r--r--app/assets/stylesheets/print.scss18
-rw-r--r--app/assets/stylesheets/utilities.scss17
-rw-r--r--app/assets/stylesheets/vendors/atwho.scss92
-rw-r--r--app/controllers/abuse_reports_controller.rb6
-rw-r--r--app/controllers/admin/appearances_controller.rb15
-rw-r--r--app/controllers/admin/application_controller.rb7
-rw-r--r--app/controllers/admin/application_settings_controller.rb31
-rw-r--r--app/controllers/admin/applications_controller.rb4
-rw-r--r--app/controllers/admin/broadcast_messages_controller.rb4
-rw-r--r--app/controllers/admin/clusters/applications_controller.rb11
-rw-r--r--app/controllers/admin/clusters_controller.rb13
-rw-r--r--app/controllers/admin/deploy_keys_controller.rb2
-rw-r--r--app/controllers/admin/groups_controller.rb11
-rw-r--r--app/controllers/admin/health_check_controller.rb8
-rw-r--r--app/controllers/admin/hooks_controller.rb4
-rw-r--r--app/controllers/admin/identities_controller.rb8
-rw-r--r--app/controllers/admin/impersonation_tokens_controller.rb8
-rw-r--r--app/controllers/admin/keys_controller.rb4
-rw-r--r--app/controllers/admin/labels_controller.rb6
-rw-r--r--app/controllers/admin/logs_controller.rb3
-rw-r--r--app/controllers/admin/projects_controller.rb6
-rw-r--r--app/controllers/admin/requests_profiles_controller.rb2
-rw-r--r--app/controllers/admin/runners_controller.rb16
-rw-r--r--app/controllers/admin/spam_logs_controller.rb6
-rw-r--r--app/controllers/admin/users_controller.rb54
-rw-r--r--app/controllers/application_controller.rb53
-rw-r--r--app/controllers/autocomplete_controller.rb9
-rw-r--r--app/controllers/clusters/applications_controller.rb42
-rw-r--r--app/controllers/clusters/clusters_controller.rb33
-rw-r--r--app/controllers/concerns/authenticates_with_two_factor.rb13
-rw-r--r--app/controllers/concerns/boards_actions.rb38
-rw-r--r--app/controllers/concerns/boards_responses.rb10
-rw-r--r--app/controllers/concerns/continue_params.rb2
-rw-r--r--app/controllers/concerns/creates_commit.rb25
-rw-r--r--app/controllers/concerns/enforces_admin_authentication.rb19
-rw-r--r--app/controllers/concerns/enforces_two_factor_authentication.rb2
-rw-r--r--app/controllers/concerns/group_tree.rb4
-rw-r--r--app/controllers/concerns/invalid_utf8_error_handler.rb27
-rw-r--r--app/controllers/concerns/issuable_actions.rb23
-rw-r--r--app/controllers/concerns/issuable_collections.rb16
-rw-r--r--app/controllers/concerns/issuable_collections_action.rb (renamed from app/controllers/concerns/issues_action.rb)33
-rw-r--r--app/controllers/concerns/lfs_request.rb12
-rw-r--r--app/controllers/concerns/membership_actions.rb35
-rw-r--r--app/controllers/concerns/merge_requests_action.rb25
-rw-r--r--app/controllers/concerns/milestone_actions.rb4
-rw-r--r--app/controllers/concerns/notes_actions.rb59
-rw-r--r--app/controllers/concerns/preview_markdown.rb4
-rw-r--r--app/controllers/concerns/project_unauthorized.rb19
-rw-r--r--app/controllers/concerns/record_user_last_activity.rb27
-rw-r--r--app/controllers/concerns/renders_notes.rb2
-rw-r--r--app/controllers/concerns/routable_actions.rb18
-rw-r--r--app/controllers/concerns/send_file_upload.rb19
-rw-r--r--app/controllers/concerns/service_params.rb8
-rw-r--r--app/controllers/concerns/spammable_actions.rb6
-rw-r--r--app/controllers/concerns/uploads_actions.rb22
-rw-r--r--app/controllers/confirmations_controller.rb2
-rw-r--r--app/controllers/dashboard/application_controller.rb1
-rw-r--r--app/controllers/dashboard/milestones_controller.rb6
-rw-r--r--app/controllers/dashboard/projects_controller.rb14
-rw-r--r--app/controllers/dashboard/todos_controller.rb4
-rw-r--r--app/controllers/dashboard_controller.rb8
-rw-r--r--app/controllers/explore/projects_controller.rb9
-rw-r--r--app/controllers/google_api/authorizations_controller.rb32
-rw-r--r--app/controllers/graphql_controller.rb60
-rw-r--r--app/controllers/groups/boards_controller.rb40
-rw-r--r--app/controllers/groups/children_controller.rb2
-rw-r--r--app/controllers/groups/group_members_controller.rb1
-rw-r--r--app/controllers/groups/milestones_controller.rb23
-rw-r--r--app/controllers/groups/runners_controller.rb10
-rw-r--r--app/controllers/groups/settings/ci_cd_controller.rb26
-rw-r--r--app/controllers/groups/variables_controller.rb2
-rw-r--r--app/controllers/groups_controller.rb47
-rw-r--r--app/controllers/help_controller.rb42
-rw-r--r--app/controllers/import/base_controller.rb2
-rw-r--r--app/controllers/import/bitbucket_controller.rb6
-rw-r--r--app/controllers/import/bitbucket_server_controller.rb19
-rw-r--r--app/controllers/import/fogbugz_controller.rb6
-rw-r--r--app/controllers/import/gitea_controller.rb22
-rw-r--r--app/controllers/import/github_controller.rb116
-rw-r--r--app/controllers/import/gitlab_controller.rb2
-rw-r--r--app/controllers/import/gitlab_projects_controller.rb4
-rw-r--r--app/controllers/import/google_code_controller.rb12
-rw-r--r--app/controllers/invites_controller.rb8
-rw-r--r--app/controllers/jwt_controller.rb8
-rw-r--r--app/controllers/ldap/omniauth_callbacks_controller.rb2
-rw-r--r--app/controllers/notification_settings_controller.rb7
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb42
-rw-r--r--app/controllers/passwords_controller.rb6
-rw-r--r--app/controllers/profiles/accounts_controller.rb2
-rw-r--r--app/controllers/profiles/active_sessions_controller.rb11
-rw-r--r--app/controllers/profiles/chat_names_controller.rb10
-rw-r--r--app/controllers/profiles/keys_controller.rb1
-rw-r--r--app/controllers/profiles/notifications_controller.rb4
-rw-r--r--app/controllers/profiles/passwords_controller.rb2
-rw-r--r--app/controllers/profiles/personal_access_tokens_controller.rb8
-rw-r--r--app/controllers/profiles/preferences_controller.rb20
-rw-r--r--app/controllers/profiles/two_factor_auths_controller.rb25
-rw-r--r--app/controllers/profiles/u2f_registrations_controller.rb2
-rw-r--r--app/controllers/profiles_controller.rb8
-rw-r--r--app/controllers/projects/application_controller.rb11
-rw-r--r--app/controllers/projects/artifacts_controller.rb9
-rw-r--r--app/controllers/projects/autocomplete_sources_controller.rb2
-rw-r--r--app/controllers/projects/badges_controller.rb13
-rw-r--r--app/controllers/projects/blob_controller.rb67
-rw-r--r--app/controllers/projects/boards_controller.rb39
-rw-r--r--app/controllers/projects/branches_controller.rb21
-rw-r--r--app/controllers/projects/build_artifacts_controller.rb9
-rw-r--r--app/controllers/projects/ci/lints_controller.rb8
-rw-r--r--app/controllers/projects/clusters/applications_controller.rb4
-rw-r--r--app/controllers/projects/clusters_controller.rb4
-rw-r--r--app/controllers/projects/commit_controller.rb10
-rw-r--r--app/controllers/projects/commits_controller.rb1
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb4
-rw-r--r--app/controllers/projects/discussions_controller.rb2
-rw-r--r--app/controllers/projects/environments/prometheus_api_controller.rb60
-rw-r--r--app/controllers/projects/environments_controller.rb93
-rw-r--r--app/controllers/projects/error_tracking_controller.rb84
-rw-r--r--app/controllers/projects/git_http_client_controller.rb14
-rw-r--r--app/controllers/projects/git_http_controller.rb22
-rw-r--r--app/controllers/projects/graphs_controller.rb5
-rw-r--r--app/controllers/projects/group_links_controller.rb7
-rw-r--r--app/controllers/projects/hooks_controller.rb2
-rw-r--r--app/controllers/projects/imports_controller.rb16
-rw-r--r--app/controllers/projects/issues_controller.rb58
-rw-r--r--app/controllers/projects/jobs_controller.rb20
-rw-r--r--app/controllers/projects/labels_controller.rb2
-rw-r--r--app/controllers/projects/lfs_api_controller.rb4
-rw-r--r--app/controllers/projects/lfs_locks_api_controller.rb10
-rw-r--r--app/controllers/projects/lfs_storage_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests/application_controller.rb14
-rw-r--r--app/controllers/projects/merge_requests/conflicts_controller.rb16
-rw-r--r--app/controllers/projects/merge_requests/creations_controller.rb6
-rw-r--r--app/controllers/projects/merge_requests/diffs_controller.rb5
-rw-r--r--app/controllers/projects/merge_requests_controller.rb53
-rw-r--r--app/controllers/projects/milestones_controller.rb4
-rw-r--r--app/controllers/projects/mirrors_controller.rb5
-rw-r--r--app/controllers/projects/pages_controller.rb5
-rw-r--r--app/controllers/projects/pages_domains_controller.rb2
-rw-r--r--app/controllers/projects/pipeline_schedules_controller.rb10
-rw-r--r--app/controllers/projects/pipelines_controller.rb23
-rw-r--r--app/controllers/projects/pipelines_settings_controller.rb2
-rw-r--r--app/controllers/projects/protected_branches_controller.rb4
-rw-r--r--app/controllers/projects/protected_refs_controller.rb4
-rw-r--r--app/controllers/projects/protected_tags_controller.rb4
-rw-r--r--app/controllers/projects/refs_controller.rb2
-rw-r--r--app/controllers/projects/releases_controller.rb36
-rw-r--r--app/controllers/projects/repositories_controller.rb2
-rw-r--r--app/controllers/projects/runners_controller.rb10
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb50
-rw-r--r--app/controllers/projects/services_controller.rb10
-rw-r--r--app/controllers/projects/settings/ci_cd_controller.rb10
-rw-r--r--app/controllers/projects/settings/operations_controller.rb75
-rw-r--r--app/controllers/projects/settings/repository_controller.rb5
-rw-r--r--app/controllers/projects/snippets_controller.rb9
-rw-r--r--app/controllers/projects/stages_controller.rb25
-rw-r--r--app/controllers/projects/tags/releases_controller.rb38
-rw-r--r--app/controllers/projects/tags_controller.rb19
-rw-r--r--app/controllers/projects/tree_controller.rb18
-rw-r--r--app/controllers/projects/triggers_controller.rb21
-rw-r--r--app/controllers/projects/variables_controller.rb2
-rw-r--r--app/controllers/projects/wikis_controller.rb19
-rw-r--r--app/controllers/projects_controller.rb42
-rw-r--r--app/controllers/registrations_controller.rb25
-rw-r--r--app/controllers/root_controller.rb2
-rw-r--r--app/controllers/search_controller.rb9
-rw-r--r--app/controllers/sent_notifications_controller.rb2
-rw-r--r--app/controllers/sessions_controller.rb16
-rw-r--r--app/controllers/sherlock/transactions_controller.rb2
-rw-r--r--app/controllers/snippets/notes_controller.rb4
-rw-r--r--app/controllers/snippets_controller.rb8
-rw-r--r--app/controllers/uploads_controller.rb17
-rw-r--r--app/finders/admin/runners_finder.rb11
-rw-r--r--app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb44
-rw-r--r--app/finders/autocomplete/users_finder.rb10
-rw-r--r--app/finders/cluster_ancestors_finder.rb35
-rw-r--r--app/finders/concerns/finder_methods.rb4
-rw-r--r--app/finders/concerns/finder_with_cross_project_access.rb30
-rw-r--r--app/finders/contributed_projects_finder.rb7
-rw-r--r--app/finders/events_finder.rb45
-rw-r--r--app/finders/group_descendants_finder.rb8
-rw-r--r--app/finders/groups_finder.rb12
-rw-r--r--app/finders/issuable_finder.rb146
-rw-r--r--app/finders/issues_finder.rb30
-rw-r--r--app/finders/members_finder.rb43
-rw-r--r--app/finders/merge_requests_finder.rb25
-rw-r--r--app/finders/milestones_finder.rb21
-rw-r--r--app/finders/projects/daily_statistics_finder.rb21
-rw-r--r--app/finders/projects/serverless/functions_finder.rb61
-rw-r--r--app/finders/projects_finder.rb8
-rw-r--r--app/finders/releases_finder.rb14
-rw-r--r--app/finders/snippets_finder.rb2
-rw-r--r--app/graphql/gitlab_schema.rb59
-rw-r--r--app/graphql/mutations/merge_requests/base.rb3
-rw-r--r--app/graphql/resolvers/base_resolver.rb26
-rw-r--r--app/graphql/resolvers/concerns/resolves_pipelines.rb10
-rw-r--r--app/graphql/resolvers/full_path_resolver.rb6
-rw-r--r--app/graphql/resolvers/group_resolver.rb13
-rw-r--r--app/graphql/resolvers/issues_resolver.rb45
-rw-r--r--app/graphql/resolvers/merge_requests_resolver.rb (renamed from app/graphql/resolvers/merge_request_resolver.rb)21
-rw-r--r--app/graphql/resolvers/metadata_resolver.rb11
-rw-r--r--app/graphql/resolvers/project_resolver.rb4
-rw-r--r--app/graphql/resolvers/tree_resolver.rb26
-rw-r--r--app/graphql/types/base_field.rb39
-rw-r--r--app/graphql/types/ci/detailed_status_type.rb17
-rw-r--r--app/graphql/types/ci/pipeline_type.rb10
-rw-r--r--app/graphql/types/group_type.rb23
-rw-r--r--app/graphql/types/issuable_state_enum.rb12
-rw-r--r--app/graphql/types/issue_state_enum.rb8
-rw-r--r--app/graphql/types/issue_type.rb22
-rw-r--r--app/graphql/types/merge_request_state_enum.rb10
-rw-r--r--app/graphql/types/merge_request_type.rb16
-rw-r--r--app/graphql/types/metadata_type.rb10
-rw-r--r--app/graphql/types/milestone_type.rb2
-rw-r--r--app/graphql/types/namespace_type.rb19
-rw-r--r--app/graphql/types/permission_types/group.rb11
-rw-r--r--app/graphql/types/permission_types/project.rb2
-rw-r--r--app/graphql/types/project_type.rb29
-rw-r--r--app/graphql/types/query_type.rb17
-rw-r--r--app/graphql/types/repository_type.rb14
-rw-r--r--app/graphql/types/tree/blob_type.rb10
-rw-r--r--app/graphql/types/tree/entry_type.rb14
-rw-r--r--app/graphql/types/tree/submodule_type.rb10
-rw-r--r--app/graphql/types/tree/tree_entry_type.rb11
-rw-r--r--app/graphql/types/tree/tree_type.rb12
-rw-r--r--app/graphql/types/tree/type_enum.rb14
-rw-r--r--app/graphql/types/user_type.rb2
-rw-r--r--app/helpers/appearances_helper.rb38
-rw-r--r--app/helpers/application_helper.rb35
-rw-r--r--app/helpers/application_settings_helper.rb69
-rw-r--r--app/helpers/auth_helper.rb19
-rw-r--r--app/helpers/auto_devops_helper.rb42
-rw-r--r--app/helpers/blob_helper.rb58
-rw-r--r--app/helpers/boards_helper.rb2
-rw-r--r--app/helpers/broadcast_messages_helper.rb2
-rw-r--r--app/helpers/builds_helper.rb6
-rw-r--r--app/helpers/button_helper.rb2
-rw-r--r--app/helpers/ci_status_helper.rb25
-rw-r--r--app/helpers/ci_variables_helper.rb30
-rw-r--r--app/helpers/clusters_helper.rb6
-rw-r--r--app/helpers/commits_helper.rb2
-rw-r--r--app/helpers/count_helper.rb2
-rw-r--r--app/helpers/dashboard_helper.rb4
-rw-r--r--app/helpers/diff_helper.rb24
-rw-r--r--app/helpers/emails_helper.rb70
-rw-r--r--app/helpers/environments_helper.rb8
-rw-r--r--app/helpers/events_helper.rb12
-rw-r--r--app/helpers/external_wiki_helper.rb12
-rw-r--r--app/helpers/form_helper.rb44
-rw-r--r--app/helpers/groups/group_members_helper.rb7
-rw-r--r--app/helpers/groups_helper.rb8
-rw-r--r--app/helpers/import_helper.rb40
-rw-r--r--app/helpers/issuables_helper.rb130
-rw-r--r--app/helpers/labels_helper.rb108
-rw-r--r--app/helpers/markup_helper.rb17
-rw-r--r--app/helpers/members_helper.rb17
-rw-r--r--app/helpers/merge_requests_helper.rb6
-rw-r--r--app/helpers/milestones_helper.rb48
-rw-r--r--app/helpers/mirror_helper.rb4
-rw-r--r--app/helpers/namespaces_helper.rb13
-rw-r--r--app/helpers/nav_helper.rb8
-rw-r--r--app/helpers/notes_helper.rb15
-rw-r--r--app/helpers/notifications_helper.rb7
-rw-r--r--app/helpers/page_layout_helper.rb3
-rw-r--r--app/helpers/preferences_helper.rb20
-rw-r--r--app/helpers/profiles_helper.rb4
-rw-r--r--app/helpers/projects/error_tracking_helper.rb15
-rw-r--r--app/helpers/projects_helper.rb144
-rw-r--r--app/helpers/runners_helper.rb10
-rw-r--r--app/helpers/search_helper.rb77
-rw-r--r--app/helpers/sidekiq_helper.rb2
-rw-r--r--app/helpers/snippets_helper.rb10
-rw-r--r--app/helpers/sorting_helper.rb135
-rw-r--r--app/helpers/storage_helper.rb12
-rw-r--r--app/helpers/tree_helper.rb21
-rw-r--r--app/helpers/user_callouts_helper.rb7
-rw-r--r--app/helpers/users_helper.rb11
-rw-r--r--app/helpers/visibility_level_helper.rb16
-rw-r--r--app/helpers/wiki_helper.rb20
-rw-r--r--app/helpers/workhorse_helper.rb5
-rw-r--r--app/mailers/abuse_report_mailer.rb4
-rw-r--r--app/mailers/devise_mailer.rb1
-rw-r--r--app/mailers/email_rejection_mailer.rb4
-rw-r--r--app/mailers/emails/issues.rb23
-rw-r--r--app/mailers/emails/merge_requests.rb14
-rw-r--r--app/mailers/emails/pipelines.rb2
-rw-r--r--app/mailers/notify.rb3
-rw-r--r--app/mailers/previews/notify_preview.rb4
-rw-r--r--app/mailers/repository_check_mailer.rb4
-rw-r--r--app/models/abuse_report.rb2
-rw-r--r--app/models/active_session.rb55
-rw-r--r--app/models/appearance.rb50
-rw-r--r--app/models/application_record.rb43
-rw-r--r--app/models/application_setting.rb346
-rw-r--r--app/models/application_setting/term.rb2
-rw-r--r--app/models/application_setting_implementation.rb298
-rw-r--r--app/models/audit_event.rb2
-rw-r--r--app/models/award_emoji.rb2
-rw-r--r--app/models/badge.rb4
-rw-r--r--app/models/blob.rb12
-rw-r--r--app/models/blob_viewer/base.rb6
-rw-r--r--app/models/blob_viewer/gitlab_ci_yml.rb8
-rw-r--r--app/models/board.rb6
-rw-r--r--app/models/board_group_recent_visit.rb11
-rw-r--r--app/models/board_project_recent_visit.rb11
-rw-r--r--app/models/broadcast_message.rb41
-rw-r--r--app/models/chat_name.rb2
-rw-r--r--app/models/chat_team.rb2
-rw-r--r--app/models/ci/bridge.rb28
-rw-r--r--app/models/ci/build.rb331
-rw-r--r--app/models/ci/build_metadata.rb16
-rw-r--r--app/models/ci/build_runner_session.rb22
-rw-r--r--app/models/ci/build_trace_chunk.rb6
-rw-r--r--app/models/ci/build_trace_section.rb2
-rw-r--r--app/models/ci/build_trace_section_name.rb2
-rw-r--r--app/models/ci/group_variable.rb3
-rw-r--r--app/models/ci/job_artifact.rb32
-rw-r--r--app/models/ci/legacy_stage.rb4
-rw-r--r--app/models/ci/pipeline.rb182
-rw-r--r--app/models/ci/pipeline_chat_data.rb13
-rw-r--r--app/models/ci/pipeline_enums.rb3
-rw-r--r--app/models/ci/pipeline_schedule.rb5
-rw-r--r--app/models/ci/pipeline_schedule_variable.rb2
-rw-r--r--app/models/ci/pipeline_variable.rb2
-rw-r--r--app/models/ci/runner.rb14
-rw-r--r--app/models/ci/runner_namespace.rb2
-rw-r--r--app/models/ci/runner_project.rb2
-rw-r--r--app/models/ci/stage.rb14
-rw-r--r--app/models/ci/trigger.rb5
-rw-r--r--app/models/ci/trigger_request.rb2
-rw-r--r--app/models/ci/variable.rb3
-rw-r--r--app/models/clusters/applications/cert_manager.rb10
-rw-r--r--app/models/clusters/applications/helm.rb9
-rw-r--r--app/models/clusters/applications/ingress.rb14
-rw-r--r--app/models/clusters/applications/jupyter.rb32
-rw-r--r--app/models/clusters/applications/knative.rb55
-rw-r--r--app/models/clusters/applications/prometheus.rb53
-rw-r--r--app/models/clusters/applications/runner.rb28
-rw-r--r--app/models/clusters/cluster.rb139
-rw-r--r--app/models/clusters/concerns/application_core.rb16
-rw-r--r--app/models/clusters/concerns/application_data.rb66
-rw-r--r--app/models/clusters/concerns/application_status.rb38
-rw-r--r--app/models/clusters/concerns/application_version.rb8
-rw-r--r--app/models/clusters/group.rb2
-rw-r--r--app/models/clusters/instance.rb17
-rw-r--r--app/models/clusters/kubernetes_namespace.rb4
-rw-r--r--app/models/clusters/platforms/kubernetes.rb86
-rw-r--r--app/models/clusters/project.rb3
-rw-r--r--app/models/clusters/providers/gcp.rb2
-rw-r--r--app/models/commit.rb11
-rw-r--r--app/models/commit_collection.rb46
-rw-r--r--app/models/commit_range.rb14
-rw-r--r--app/models/commit_status.rb26
-rw-r--r--app/models/commit_status_enums.rb3
-rw-r--r--app/models/concerns/artifact_migratable.rb14
-rw-r--r--app/models/concerns/atomic_internal_id.rb16
-rw-r--r--app/models/concerns/avatarable.rb16
-rw-r--r--app/models/concerns/blob_language_from_git_attributes.rb2
-rw-r--r--app/models/concerns/blob_like.rb2
-rw-r--r--app/models/concerns/cache_markdown_field.rb60
-rw-r--r--app/models/concerns/cacheable_attributes.rb7
-rw-r--r--app/models/concerns/ci/contextable.rb108
-rw-r--r--app/models/concerns/ci/metadatable.rb69
-rw-r--r--app/models/concerns/ci/pipeline_delegator.rb21
-rw-r--r--app/models/concerns/ci/processable.rb31
-rw-r--r--app/models/concerns/closed_at_filterable.rb14
-rw-r--r--app/models/concerns/deployment_platform.rb13
-rw-r--r--app/models/concerns/deprecated_assignee.rb86
-rw-r--r--app/models/concerns/descendant.rb11
-rw-r--r--app/models/concerns/discussion_on_diff.rb21
-rw-r--r--app/models/concerns/enum_with_nil.rb3
-rw-r--r--app/models/concerns/fast_destroy_all.rb2
-rw-r--r--app/models/concerns/feature_gate.rb2
-rw-r--r--app/models/concerns/group_descendant.rb4
-rw-r--r--app/models/concerns/has_ref.rb31
-rw-r--r--app/models/concerns/has_status.rb31
-rw-r--r--app/models/concerns/has_variable.rb11
-rw-r--r--app/models/concerns/ignorable_column.rb2
-rw-r--r--app/models/concerns/iid_routes.rb2
-rw-r--r--app/models/concerns/issuable.rb108
-rw-r--r--app/models/concerns/issuable_states.rb25
-rw-r--r--app/models/concerns/manual_inverse_association.rb4
-rw-r--r--app/models/concerns/maskable.rb22
-rw-r--r--app/models/concerns/milestoneish.rb25
-rw-r--r--app/models/concerns/mirror_authentication.rb2
-rw-r--r--app/models/concerns/noteable.rb27
-rw-r--r--app/models/concerns/participable.rb2
-rw-r--r--app/models/concerns/prometheus_adapter.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb45
-rw-r--r--app/models/concerns/redactable.rb2
-rw-r--r--app/models/concerns/redis_cacheable.rb6
-rw-r--r--app/models/concerns/sha_attribute.rb10
-rw-r--r--app/models/concerns/sortable.rb28
-rw-r--r--app/models/concerns/storage/legacy_namespace.rb24
-rw-r--r--app/models/concerns/strip_attribute.rb2
-rw-r--r--app/models/concerns/taskable.rb10
-rw-r--r--app/models/concerns/token_authenticatable.rb19
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb18
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb52
-rw-r--r--app/models/concerns/update_project_statistics.rb60
-rw-r--r--app/models/concerns/with_uploads.rb34
-rw-r--r--app/models/container_repository.rb13
-rw-r--r--app/models/conversational_development_index/metric.rb2
-rw-r--r--app/models/dashboard_group_milestone.rb28
-rw-r--r--app/models/dashboard_milestone.rb10
-rw-r--r--app/models/deploy_keys_project.rb2
-rw-r--r--app/models/deploy_token.rb2
-rw-r--r--app/models/deployment.rb21
-rw-r--r--app/models/diff_note.rb14
-rw-r--r--app/models/diff_viewer/base.rb39
-rw-r--r--app/models/diff_viewer/image.rb2
-rw-r--r--app/models/diff_viewer/rich.rb2
-rw-r--r--app/models/diff_viewer/server_side.rb12
-rw-r--r--app/models/diff_viewer/simple.rb2
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/email.rb6
-rw-r--r--app/models/environment.rb43
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb162
-rw-r--r--app/models/event.rb69
-rw-r--r--app/models/external_issue.rb8
-rw-r--r--app/models/fork_network.rb2
-rw-r--r--app/models/fork_network_member.rb2
-rw-r--r--app/models/generic_commit_status.rb2
-rw-r--r--app/models/global_label.rb8
-rw-r--r--app/models/global_milestone.rb119
-rw-r--r--app/models/gpg_key.rb2
-rw-r--r--app/models/gpg_key_subkey.rb2
-rw-r--r--app/models/gpg_signature.rb16
-rw-r--r--app/models/group.rb28
-rw-r--r--app/models/group_custom_attribute.rb2
-rw-r--r--app/models/group_milestone.rb30
-rw-r--r--app/models/hooks/web_hook.rb2
-rw-r--r--app/models/hooks/web_hook_log.rb2
-rw-r--r--app/models/identity.rb5
-rw-r--r--app/models/identity/uniqueness_scopes.rb2
-rw-r--r--app/models/import_export_upload.rb2
-rw-r--r--app/models/individual_note_discussion.rb12
-rw-r--r--app/models/instance_configuration.rb2
-rw-r--r--app/models/internal_id.rb74
-rw-r--r--app/models/issue.rb47
-rw-r--r--app/models/issue/metrics.rb2
-rw-r--r--app/models/issue_assignee.rb2
-rw-r--r--app/models/key.rb2
-rw-r--r--app/models/label.rb19
-rw-r--r--app/models/label_link.rb2
-rw-r--r--app/models/label_note.rb2
-rw-r--r--app/models/label_priority.rb2
-rw-r--r--app/models/legacy_diff_note.rb2
-rw-r--r--app/models/lfs_download_object.rb22
-rw-r--r--app/models/lfs_file_lock.rb2
-rw-r--r--app/models/lfs_object.rb4
-rw-r--r--app/models/lfs_objects_project.rb2
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/member.rb15
-rw-r--r--app/models/members/group_member.rb6
-rw-r--r--app/models/members/project_member.rb6
-rw-r--r--app/models/merge_request.rb300
-rw-r--r--app/models/merge_request/metrics.rb2
-rw-r--r--app/models/merge_request_assignee.rb6
-rw-r--r--app/models/merge_request_diff.rb273
-rw-r--r--app/models/merge_request_diff_commit.rb2
-rw-r--r--app/models/merge_request_diff_file.rb16
-rw-r--r--app/models/merge_requests_closing_issues.rb2
-rw-r--r--app/models/milestone.rb80
-rw-r--r--app/models/namespace.rb80
-rw-r--r--app/models/network/graph.rb9
-rw-r--r--app/models/note.rb14
-rw-r--r--app/models/note_diff_file.rb21
-rw-r--r--app/models/notification_recipient.rb46
-rw-r--r--app/models/notification_setting.rb2
-rw-r--r--app/models/pages_domain.rb20
-rw-r--r--app/models/personal_access_token.rb7
-rw-r--r--app/models/pool_repository.rb33
-rw-r--r--app/models/postgresql/replication_slot.rb2
-rw-r--r--app/models/programming_language.rb8
-rw-r--r--app/models/project.rb451
-rw-r--r--app/models/project_authorization.rb2
-rw-r--r--app/models/project_auto_devops.rb15
-rw-r--r--app/models/project_ci_cd_setting.rb2
-rw-r--r--app/models/project_custom_attribute.rb2
-rw-r--r--app/models/project_daily_statistic.rb10
-rw-r--r--app/models/project_deploy_token.rb2
-rw-r--r--app/models/project_feature.rb36
-rw-r--r--app/models/project_group_link.rb6
-rw-r--r--app/models/project_import_data.rb6
-rw-r--r--app/models/project_import_state.rb2
-rw-r--r--app/models/project_metrics_setting.rb9
-rw-r--r--app/models/project_repository.rb2
-rw-r--r--app/models/project_services/asana_service.rb8
-rw-r--r--app/models/project_services/bamboo_service.rb44
-rw-r--r--app/models/project_services/campfire_service.rb2
-rw-r--r--app/models/project_services/chat_message/deployment_message.rb77
-rw-r--r--app/models/project_services/chat_notification_service.rb4
-rw-r--r--app/models/project_services/deployment_service.rb2
-rw-r--r--app/models/project_services/discord_service.rb9
-rw-r--r--app/models/project_services/emails_on_push_service.rb14
-rw-r--r--app/models/project_services/external_wiki_service.rb6
-rw-r--r--app/models/project_services/flowdock_service.rb4
-rw-r--r--app/models/project_services/hangouts_chat_service.rb5
-rw-r--r--app/models/project_services/irker_service.rb4
-rw-r--r--app/models/project_services/jira_service.rb36
-rw-r--r--app/models/project_services/kubernetes_service.rb20
-rw-r--r--app/models/project_services/microsoft_teams_service.rb5
-rw-r--r--app/models/project_services/mock_ci_service.rb2
-rw-r--r--app/models/project_services/pipelines_email_service.rb6
-rw-r--r--app/models/project_services/pivotaltracker_service.rb8
-rw-r--r--app/models/project_services/prometheus_service.rb6
-rw-r--r--app/models/project_services/pushover_service.rb26
-rw-r--r--app/models/project_services/slack_slash_commands_service.rb4
-rw-r--r--app/models/project_services/teamcity_service.rb6
-rw-r--r--app/models/project_services/youtrack_service.rb40
-rw-r--r--app/models/project_statistics.rb11
-rw-r--r--app/models/project_team.rb12
-rw-r--r--app/models/project_wiki.rb36
-rw-r--r--app/models/prometheus_metric.rb87
-rw-r--r--app/models/protected_branch.rb16
-rw-r--r--app/models/protected_branch/merge_access_level.rb2
-rw-r--r--app/models/protected_branch/push_access_level.rb2
-rw-r--r--app/models/protected_tag.rb2
-rw-r--r--app/models/protected_tag/create_access_level.rb2
-rw-r--r--app/models/push_event.rb2
-rw-r--r--app/models/push_event_payload.rb2
-rw-r--r--app/models/redirect_route.rb2
-rw-r--r--app/models/release.rb48
-rw-r--r--app/models/releases/link.rb22
-rw-r--r--app/models/releases/source.rb35
-rw-r--r--app/models/remote_mirror.rb30
-rw-r--r--app/models/repository.rb169
-rw-r--r--app/models/repository_language.rb2
-rw-r--r--app/models/resource_label_event.rb2
-rw-r--r--app/models/route.rb18
-rw-r--r--app/models/sent_notification.rb27
-rw-r--r--app/models/serverless/function.rb26
-rw-r--r--app/models/service.rb12
-rw-r--r--app/models/shard.rb2
-rw-r--r--app/models/snippet.rb12
-rw-r--r--app/models/spam_log.rb2
-rw-r--r--app/models/ssh_host_key.rb8
-rw-r--r--app/models/storage/hashed_project.rb2
-rw-r--r--app/models/storage/legacy_project.rb11
-rw-r--r--app/models/subscription.rb2
-rw-r--r--app/models/suggestion.rb47
-rw-r--r--app/models/system_note_metadata.rb2
-rw-r--r--app/models/term_agreement.rb2
-rw-r--r--app/models/timelog.rb2
-rw-r--r--app/models/todo.rb16
-rw-r--r--app/models/trending_project.rb2
-rw-r--r--app/models/u2f_registration.rb4
-rw-r--r--app/models/upload.rb8
-rw-r--r--app/models/user.rb127
-rw-r--r--app/models/user_agent_detail.rb2
-rw-r--r--app/models/user_callout.rb2
-rw-r--r--app/models/user_custom_attribute.rb2
-rw-r--r--app/models/user_interacted_project.rb20
-rw-r--r--app/models/user_preference.rb6
-rw-r--r--app/models/user_status.rb2
-rw-r--r--app/models/user_synced_attributes_metadata.rb2
-rw-r--r--app/models/users_star_project.rb2
-rw-r--r--app/models/wiki_page.rb21
-rw-r--r--app/policies/base_policy.rb13
-rw-r--r--app/policies/board_policy.rb16
-rw-r--r--app/policies/ci/pipeline_policy.rb21
-rw-r--r--app/policies/clusters/cluster_policy.rb1
-rw-r--r--app/policies/clusters/instance_policy.rb21
-rw-r--r--app/policies/concerns/clusterable_actions.rb14
-rw-r--r--app/policies/container_repository_policy.rb5
-rw-r--r--app/policies/global_policy.rb5
-rw-r--r--app/policies/group_policy.rb35
-rw-r--r--app/policies/identity_provider_policy.rb15
-rw-r--r--app/policies/issuable_policy.rb3
-rw-r--r--app/policies/issue_policy.rb1
-rw-r--r--app/policies/merge_request_policy.rb3
-rw-r--r--app/policies/note_policy.rb1
-rw-r--r--app/policies/personal_snippet_policy.rb12
-rw-r--r--app/policies/project_policy.rb84
-rw-r--r--app/policies/project_snippet_policy.rb13
-rw-r--r--app/policies/release_policy.rb5
-rw-r--r--app/presenters/blobs/unfold_presenter.rb75
-rw-r--r--app/presenters/ci/bridge_presenter.rb9
-rw-r--r--app/presenters/ci/build_runner_presenter.rb49
-rw-r--r--app/presenters/ci/pipeline_presenter.rb45
-rw-r--r--app/presenters/ci/trigger_presenter.rb19
-rw-r--r--app/presenters/clusterable_presenter.rb8
-rw-r--r--app/presenters/clusters/cluster_presenter.rb50
-rw-r--r--app/presenters/commit_presenter.rb13
-rw-r--r--app/presenters/commit_status_presenter.rb3
-rw-r--r--app/presenters/group_clusterable_presenter.rb5
-rw-r--r--app/presenters/instance_clusterable_presenter.rb69
-rw-r--r--app/presenters/label_presenter.rb51
-rw-r--r--app/presenters/merge_request_presenter.rb53
-rw-r--r--app/presenters/project_clusterable_presenter.rb5
-rw-r--r--app/presenters/project_presenter.rb37
-rw-r--r--app/serializers/acts_as_taggable_on/tag_entity.rb6
-rw-r--r--app/serializers/acts_as_taggable_on/tag_serializer.rb5
-rw-r--r--app/serializers/analytics_stage_entity.rb5
-rw-r--r--app/serializers/base_serializer.rb10
-rw-r--r--app/serializers/build_details_entity.rb12
-rw-r--r--app/serializers/cluster_application_entity.rb4
-rw-r--r--app/serializers/concerns/user_status_tooltip.rb4
-rw-r--r--app/serializers/container_repository_entity.rb2
-rw-r--r--app/serializers/container_tag_entity.rb2
-rw-r--r--app/serializers/deployment_entity.rb39
-rw-r--r--app/serializers/detailed_status_entity.rb16
-rw-r--r--app/serializers/diff_file_base_entity.rb21
-rw-r--r--app/serializers/diff_file_entity.rb12
-rw-r--r--app/serializers/diff_viewer_entity.rb5
-rw-r--r--app/serializers/entity_date_helper.rb16
-rw-r--r--app/serializers/environment_entity.rb19
-rw-r--r--app/serializers/error_tracking/error_entity.rb10
-rw-r--r--app/serializers/error_tracking/error_serializer.rb7
-rw-r--r--app/serializers/error_tracking/project_entity.rb7
-rw-r--r--app/serializers/error_tracking/project_serializer.rb7
-rw-r--r--app/serializers/group_variable_entity.rb1
-rw-r--r--app/serializers/issuable_sidebar_basic_entity.rb106
-rw-r--r--app/serializers/issuable_sidebar_extras_entity.rb (renamed from app/serializers/issuable_sidebar_entity.rb)6
-rw-r--r--app/serializers/issuable_sidebar_todo_entity.rb11
-rw-r--r--app/serializers/issue_board_entity.rb4
-rw-r--r--app/serializers/issue_entity.rb2
-rw-r--r--app/serializers/issue_serializer.rb6
-rw-r--r--app/serializers/issue_sidebar_basic_entity.rb6
-rw-r--r--app/serializers/issue_sidebar_entity.rb5
-rw-r--r--app/serializers/issue_sidebar_extras_entity.rb4
-rw-r--r--app/serializers/merge_request_assignee_entity.rb7
-rw-r--r--app/serializers/merge_request_basic_entity.rb6
-rw-r--r--app/serializers/merge_request_basic_serializer.rb5
-rw-r--r--app/serializers/merge_request_diff_entity.rb8
-rw-r--r--app/serializers/merge_request_for_pipeline_entity.rb17
-rw-r--r--app/serializers/merge_request_serializer.rb9
-rw-r--r--app/serializers/merge_request_sidebar_extras_entity.rb7
-rw-r--r--app/serializers/merge_request_widget_commit_entity.rb7
-rw-r--r--app/serializers/merge_request_widget_entity.rb36
-rw-r--r--app/serializers/namespace_basic_entity.rb6
-rw-r--r--app/serializers/namespace_serializer.rb5
-rw-r--r--app/serializers/pipeline_details_entity.rb5
-rw-r--r--app/serializers/pipeline_entity.rb21
-rw-r--r--app/serializers/pipeline_serializer.rb39
-rw-r--r--app/serializers/project_import_entity.rb13
-rw-r--r--app/serializers/project_serializer.rb12
-rw-r--r--app/serializers/projects/serverless/service_entity.rb43
-rw-r--r--app/serializers/provider_repo_entity.rb25
-rw-r--r--app/serializers/provider_repo_serializer.rb5
-rw-r--r--app/serializers/suggestion_entity.rb2
-rw-r--r--app/serializers/suggestion_serializer.rb9
-rw-r--r--app/serializers/test_case_entity.rb1
-rw-r--r--app/serializers/tree_entity.rb15
-rw-r--r--app/serializers/tree_root_entity.rb27
-rw-r--r--app/serializers/tree_serializer.rb5
-rw-r--r--app/serializers/variable_entity.rb1
-rw-r--r--app/services/after_branch_delete_service.rb23
-rw-r--r--app/services/application_settings/update_service.rb10
-rw-r--r--app/services/applications/create_service.rb8
-rw-r--r--app/services/auth/container_registry_authentication_service.rb5
-rw-r--r--app/services/boards/issues/move_service.rb2
-rw-r--r--app/services/boards/lists/destroy_service.rb2
-rw-r--r--app/services/boards/visits/latest_service.rb14
-rw-r--r--app/services/ci/create_pipeline_service.rb33
-rw-r--r--app/services/ci/destroy_expired_job_artifacts_service.rb38
-rw-r--r--app/services/ci/destroy_pipeline_service.rb2
-rw-r--r--app/services/ci/expire_pipeline_cache_service.rb62
-rw-r--r--app/services/ci/pipeline_trigger_service.rb10
-rw-r--r--app/services/ci/play_manual_stage_service.rb29
-rw-r--r--app/services/ci/prepare_build_service.rb27
-rw-r--r--app/services/ci/process_pipeline_service.rb16
-rw-r--r--app/services/ci/register_job_service.rb9
-rw-r--r--app/services/ci/retry_build_service.rb2
-rw-r--r--app/services/ci/stop_environments_service.rb16
-rw-r--r--app/services/clusters/applications/base_helm_service.rb34
-rw-r--r--app/services/clusters/applications/base_service.rb92
-rw-r--r--app/services/clusters/applications/check_ingress_ip_address_service.rb16
-rw-r--r--app/services/clusters/applications/check_installation_progress_service.rb29
-rw-r--r--app/services/clusters/applications/check_uninstall_progress_service.rb62
-rw-r--r--app/services/clusters/applications/create_service.rb75
-rw-r--r--app/services/clusters/applications/destroy_service.rb23
-rw-r--r--app/services/clusters/applications/install_service.rb31
-rw-r--r--app/services/clusters/applications/patch_service.rb32
-rw-r--r--app/services/clusters/applications/schedule_installation_service.rb19
-rw-r--r--app/services/clusters/applications/uninstall_service.rb29
-rw-r--r--app/services/clusters/applications/update_service.rb17
-rw-r--r--app/services/clusters/applications/upgrade_service.rb34
-rw-r--r--app/services/clusters/build_service.rb2
-rw-r--r--app/services/clusters/create_service.rb2
-rw-r--r--app/services/clusters/gcp/finalize_creation_service.rb8
-rw-r--r--app/services/clusters/refresh_service.rb2
-rw-r--r--app/services/commits/create_service.rb13
-rw-r--r--app/services/commits/tag_service.rb3
-rw-r--r--app/services/compare_service.rb4
-rw-r--r--app/services/concerns/exclusive_lease_guard.rb15
-rw-r--r--app/services/concerns/suggestible.rb37
-rw-r--r--app/services/concerns/users/participable_service.rb33
-rw-r--r--app/services/concerns/validates_classification_label.rb27
-rw-r--r--app/services/create_branch_service.rb4
-rw-r--r--app/services/create_release_service.rb35
-rw-r--r--app/services/delete_branch_service.rb34
-rw-r--r--app/services/deploy_keys/create_service.rb2
-rw-r--r--app/services/emails/base_service.rb5
-rw-r--r--app/services/emails/create_service.rb9
-rw-r--r--app/services/error_tracking/list_issues_service.rb62
-rw-r--r--app/services/error_tracking/list_projects_service.rb44
-rw-r--r--app/services/files/delete_service.rb2
-rw-r--r--app/services/files/multi_service.rb3
-rw-r--r--app/services/files/update_service.rb2
-rw-r--r--app/services/git/base_hooks_service.rb105
-rw-r--r--app/services/git/branch_hooks_service.rb144
-rw-r--r--app/services/git/branch_push_service.rb92
-rw-r--r--app/services/git/tag_hooks_service.rb36
-rw-r--r--app/services/git/tag_push_service.rb14
-rw-r--r--app/services/git_push_service.rb233
-rw-r--r--app/services/git_tag_push_service.rb61
-rw-r--r--app/services/groups/auto_devops_service.rb17
-rw-r--r--app/services/groups/base_service.rb6
-rw-r--r--app/services/groups/create_service.rb18
-rw-r--r--app/services/groups/destroy_service.rb2
-rw-r--r--app/services/groups/nested_create_service.rb4
-rw-r--r--app/services/groups/transfer_service.rb24
-rw-r--r--app/services/groups/update_service.rb11
-rw-r--r--app/services/import/base_service.rb35
-rw-r--r--app/services/import/github_service.rb48
-rw-r--r--app/services/issuable/clone/content_rewriter.rb1
-rw-r--r--app/services/issuable/common_system_notes_service.rb21
-rw-r--r--app/services/issuable_base_service.rb126
-rw-r--r--app/services/issues/base_service.rb22
-rw-r--r--app/services/issues/build_service.rb12
-rw-r--r--app/services/issues/close_service.rb13
-rw-r--r--app/services/issues/import_csv_service.rb53
-rw-r--r--app/services/issues/move_service.rb4
-rw-r--r--app/services/issues/update_service.rb13
-rw-r--r--app/services/labels/available_labels_service.rb60
-rw-r--r--app/services/labels/create_service.rb2
-rw-r--r--app/services/labels/promote_service.rb2
-rw-r--r--app/services/labels/update_service.rb3
-rw-r--r--app/services/lfs/file_transformer.rb9
-rw-r--r--app/services/lfs/locks_finder_service.rb2
-rw-r--r--app/services/members/base_service.rb6
-rw-r--r--app/services/members/create_service.rb27
-rw-r--r--app/services/members/destroy_service.rb32
-rw-r--r--app/services/members/update_service.rb9
-rw-r--r--app/services/merge_requests/add_todo_when_build_fails_service.rb4
-rw-r--r--app/services/merge_requests/base_service.rb65
-rw-r--r--app/services/merge_requests/build_service.rb25
-rw-r--r--app/services/merge_requests/close_service.rb1
-rw-r--r--app/services/merge_requests/create_service.rb18
-rw-r--r--app/services/merge_requests/delete_non_latest_diffs_service.rb4
-rw-r--r--app/services/merge_requests/merge_base_service.rb63
-rw-r--r--app/services/merge_requests/merge_service.rb47
-rw-r--r--app/services/merge_requests/merge_to_ref_service.rb71
-rw-r--r--app/services/merge_requests/migrate_external_diffs_service.rb23
-rw-r--r--app/services/merge_requests/post_merge_service.rb1
-rw-r--r--app/services/merge_requests/push_options_handler_service.rb162
-rw-r--r--app/services/merge_requests/rebase_service.rb12
-rw-r--r--app/services/merge_requests/refresh_service.rb21
-rw-r--r--app/services/merge_requests/reopen_service.rb2
-rw-r--r--app/services/merge_requests/squash_service.rb35
-rw-r--r--app/services/merge_requests/update_service.rb34
-rw-r--r--app/services/milestones/promote_service.rb14
-rw-r--r--app/services/note_summary.rb4
-rw-r--r--app/services/notes/build_service.rb17
-rw-r--r--app/services/notes/create_service.rb15
-rw-r--r--app/services/notes/quick_actions_service.rb33
-rw-r--r--app/services/notes/update_service.rb2
-rw-r--r--app/services/notification_recipient_service.rb35
-rw-r--r--app/services/notification_service.rb31
-rw-r--r--app/services/preview_markdown_service.rb39
-rw-r--r--app/services/projects/after_rename_service.rb32
-rw-r--r--app/services/projects/autocomplete_service.rb2
-rw-r--r--app/services/projects/cleanup_service.rb47
-rw-r--r--app/services/projects/container_repository/cleanup_tags_service.rb94
-rw-r--r--app/services/projects/create_from_template_service.rb2
-rw-r--r--app/services/projects/create_service.rb14
-rw-r--r--app/services/projects/destroy_service.rb36
-rw-r--r--app/services/projects/detect_repository_languages_service.rb12
-rw-r--r--app/services/projects/download_service.rb2
-rw-r--r--app/services/projects/fetch_statistics_increment_service.rb32
-rw-r--r--app/services/projects/fork_service.rb12
-rw-r--r--app/services/projects/git_deduplication_service.rb64
-rw-r--r--app/services/projects/group_links/create_service.rb10
-rw-r--r--app/services/projects/hashed_storage/base_attachment_service.rb51
-rw-r--r--app/services/projects/hashed_storage/base_repository_service.rb67
-rw-r--r--app/services/projects/hashed_storage/migrate_attachments_service.rb45
-rw-r--r--app/services/projects/hashed_storage/migrate_repository_service.rb52
-rw-r--r--app/services/projects/hashed_storage/migration_service.rb39
-rw-r--r--app/services/projects/hashed_storage/rollback_attachments_service.rb34
-rw-r--r--app/services/projects/hashed_storage/rollback_repository_service.rb40
-rw-r--r--app/services/projects/hashed_storage/rollback_service.rb37
-rw-r--r--app/services/projects/hashed_storage_migration_service.rb27
-rw-r--r--app/services/projects/housekeeping_service.rb12
-rw-r--r--app/services/projects/import_error_filter.rb14
-rw-r--r--app/services/projects/import_service.rb31
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_link_list_service.rb35
-rw-r--r--app/services/projects/lfs_pointers/lfs_download_service.rb100
-rw-r--r--app/services/projects/lfs_pointers/lfs_import_service.rb92
-rw-r--r--app/services/projects/lfs_pointers/lfs_link_service.rb4
-rw-r--r--app/services/projects/lfs_pointers/lfs_object_download_list_service.rb96
-rw-r--r--app/services/projects/move_project_group_links_service.rb2
-rw-r--r--app/services/projects/operations/update_service.rb49
-rw-r--r--app/services/projects/propagate_service_template.rb2
-rw-r--r--app/services/projects/protect_default_branch_service.rb67
-rw-r--r--app/services/projects/repository_languages_service.rb24
-rw-r--r--app/services/projects/transfer_service.rb26
-rw-r--r--app/services/projects/update_pages_configuration_service.rb34
-rw-r--r--app/services/projects/update_pages_service.rb41
-rw-r--r--app/services/projects/update_service.rb21
-rw-r--r--app/services/projects/update_statistics_service.rb19
-rw-r--r--app/services/prometheus/adapter_service.rb2
-rw-r--r--app/services/prometheus/proxy_service.rb116
-rw-r--r--app/services/protected_branches/api_service.rb21
-rw-r--r--app/services/protected_branches/legacy_api_update_service.rb19
-rw-r--r--app/services/push_event_payload_service.rb2
-rw-r--r--app/services/quick_actions/interpret_service.rb666
-rw-r--r--app/services/releases/concerns.rb48
-rw-r--r--app/services/releases/create_service.rb65
-rw-r--r--app/services/releases/destroy_service.rb24
-rw-r--r--app/services/releases/update_service.rb32
-rw-r--r--app/services/resource_events/change_labels_service.rb2
-rw-r--r--app/services/search/global_service.rb13
-rw-r--r--app/services/search/group_service.rb6
-rw-r--r--app/services/search/project_service.rb7
-rw-r--r--app/services/service_response.rb31
-rw-r--r--app/services/suggestions/apply_service.rb45
-rw-r--r--app/services/suggestions/create_service.rb42
-rw-r--r--app/services/suggestions/outdate_service.rb19
-rw-r--r--app/services/system_hooks_service.rb6
-rw-r--r--app/services/system_note_service.rb26
-rw-r--r--app/services/tags/create_service.rb7
-rw-r--r--app/services/tags/destroy_service.rb21
-rw-r--r--app/services/task_list_toggle_service.rb72
-rw-r--r--app/services/test_hooks/project_service.rb16
-rw-r--r--app/services/test_hooks/system_service.rb2
-rw-r--r--app/services/todo_service.rb16
-rw-r--r--app/services/todos/destroy/entity_leave_service.rb13
-rw-r--r--app/services/update_deployment_service.rb2
-rw-r--r--app/services/update_release_service.rb28
-rw-r--r--app/services/upload_service.rb4
-rw-r--r--app/services/users/activity_service.rb9
-rw-r--r--app/services/users/build_service.rb6
-rw-r--r--app/services/users/migrate_to_ghost_user_service.rb2
-rw-r--r--app/services/users/refresh_authorized_projects_service.rb6
-rw-r--r--app/services/users/update_service.rb12
-rw-r--r--app/services/validate_new_branch_service.rb4
-rw-r--r--app/services/verify_pages_domain_service.rb15
-rw-r--r--app/uploaders/attachment_uploader.rb2
-rw-r--r--app/uploaders/avatar_uploader.rb2
-rw-r--r--app/uploaders/external_diff_uploader.rb23
-rw-r--r--app/uploaders/file_mover.rb8
-rw-r--r--app/uploaders/file_uploader.rb16
-rw-r--r--app/uploaders/import_export_uploader.rb4
-rw-r--r--app/uploaders/legacy_artifact_uploader.rb3
-rw-r--r--app/uploaders/object_storage.rb8
-rw-r--r--app/uploaders/personal_file_uploader.rb54
-rw-r--r--app/uploaders/records_uploads.rb24
-rw-r--r--app/validators/addressable_url_validator.rb112
-rw-r--r--app/validators/cluster_name_validator.rb8
-rw-r--r--app/validators/devise_email_validator.rb36
-rw-r--r--app/validators/email_validator.rb7
-rw-r--r--app/validators/public_url_validator.rb19
-rw-r--r--app/validators/sha_validator.rb9
-rw-r--r--app/validators/url_validator.rb96
-rw-r--r--app/validators/x509_certificate_credentials_validator.rb86
-rw-r--r--app/views/abuse_reports/new.html.haml6
-rw-r--r--app/views/admin/appearances/_form.html.haml169
-rw-r--r--app/views/admin/appearances/_system_header_footer_form.html.haml33
-rw-r--r--app/views/admin/appearances/show.html.haml7
-rw-r--r--app/views/admin/application_settings/_account_and_limit.html.haml31
-rw-r--r--app/views/admin/application_settings/_ci_cd.html.haml38
-rw-r--r--app/views/admin/application_settings/_email.html.haml16
-rw-r--r--app/views/admin/application_settings/_external_authorization_service_form.html.haml51
-rw-r--r--app/views/admin/application_settings/_help_page.html.haml12
-rw-r--r--app/views/admin/application_settings/_localization.html.haml11
-rw-r--r--app/views/admin/application_settings/_logging.html.haml6
-rw-r--r--app/views/admin/application_settings/_pages.html.haml28
-rw-r--r--app/views/admin/application_settings/_performance_bar.html.haml4
-rw-r--r--app/views/admin/application_settings/_repository_mirrors_form.html.haml10
-rw-r--r--app/views/admin/application_settings/_repository_storage.html.haml1
-rw-r--r--app/views/admin/application_settings/_visibility_and_access.html.haml18
-rw-r--r--app/views/admin/application_settings/metrics_and_profiling.html.haml2
-rw-r--r--app/views/admin/application_settings/preferences.html.haml11
-rw-r--r--app/views/admin/application_settings/show.html.haml4
-rw-r--r--app/views/admin/applications/_form.html.haml12
-rw-r--r--app/views/admin/applications/show.html.haml4
-rw-r--r--app/views/admin/broadcast_messages/_form.html.haml16
-rw-r--r--app/views/admin/broadcast_messages/index.html.haml4
-rw-r--r--app/views/admin/dashboard/index.html.haml7
-rw-r--r--app/views/admin/deploy_keys/edit.html.haml8
-rw-r--r--app/views/admin/deploy_keys/index.html.haml20
-rw-r--r--app/views/admin/groups/_form.html.haml7
-rw-r--r--app/views/admin/groups/_group.html.haml2
-rw-r--r--app/views/admin/groups/show.html.haml14
-rw-r--r--app/views/admin/health_check/show.html.haml2
-rw-r--r--app/views/admin/identities/_form.html.haml6
-rw-r--r--app/views/admin/labels/_form.html.haml9
-rw-r--r--app/views/admin/labels/_label.html.haml2
-rw-r--r--app/views/admin/projects/_projects.html.haml4
-rw-r--r--app/views/admin/projects/index.html.haml2
-rw-r--r--app/views/admin/projects/show.html.haml20
-rw-r--r--app/views/admin/runners/_runner.html.haml17
-rw-r--r--app/views/admin/runners/_sort_dropdown.html.haml4
-rw-r--r--app/views/admin/runners/index.html.haml23
-rw-r--r--app/views/admin/users/_access_levels.html.haml24
-rw-r--r--app/views/admin/users/_form.html.haml68
-rw-r--r--app/views/admin/users/_head.html.haml1
-rw-r--r--app/views/admin/users/_user.html.haml55
-rw-r--r--app/views/admin/users/_user_detail.html.haml17
-rw-r--r--app/views/admin/users/index.html.haml96
-rw-r--r--app/views/admin/users/show.html.haml12
-rw-r--r--app/views/award_emoji/_awards_block.html.haml10
-rw-r--r--app/views/ci/status/_dropdown_graph_badge.html.haml10
-rw-r--r--app/views/ci/status/_icon.html.haml16
-rw-r--r--app/views/ci/variables/_content.html.haml4
-rw-r--r--app/views/ci/variables/_header.html.haml11
-rw-r--r--app/views/ci/variables/_index.html.haml6
-rw-r--r--app/views/ci/variables/_variable_header.html.haml16
-rw-r--r--app/views/ci/variables/_variable_row.html.haml44
-rw-r--r--app/views/clusters/clusters/_advanced_settings.html.haml2
-rw-r--r--app/views/clusters/clusters/_banner.html.haml12
-rw-r--r--app/views/clusters/clusters/_buttons.html.haml8
-rw-r--r--app/views/clusters/clusters/_cluster.html.haml2
-rw-r--r--app/views/clusters/clusters/_empty_state.html.haml2
-rw-r--r--app/views/clusters/clusters/_form.html.haml46
-rw-r--r--app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml6
-rw-r--r--app/views/clusters/clusters/_integration_form.html.haml31
-rw-r--r--app/views/clusters/clusters/_sidebar.html.haml2
-rw-r--r--app/views/clusters/clusters/gcp/_form.html.haml58
-rw-r--r--app/views/clusters/clusters/gcp/_show.html.haml50
-rw-r--r--app/views/clusters/clusters/index.html.haml13
-rw-r--r--app/views/clusters/clusters/new.html.haml4
-rw-r--r--app/views/clusters/clusters/show.html.haml30
-rw-r--r--app/views/clusters/clusters/user/_form.html.haml80
-rw-r--r--app/views/clusters/clusters/user/_show.html.haml39
-rw-r--r--app/views/clusters/platforms/kubernetes/_form.html.haml58
-rw-r--r--app/views/dashboard/_activities.html.haml2
-rw-r--r--app/views/dashboard/_activity_head.html.haml2
-rw-r--r--app/views/dashboard/_groups_head.html.haml2
-rw-r--r--app/views/dashboard/_projects_head.html.haml28
-rw-r--r--app/views/dashboard/_snippets_head.html.haml6
-rw-r--r--app/views/dashboard/activity.html.haml3
-rw-r--r--app/views/dashboard/groups/_groups.html.haml4
-rw-r--r--app/views/dashboard/groups/index.html.haml4
-rw-r--r--app/views/dashboard/issues.html.haml4
-rw-r--r--app/views/dashboard/merge_requests.html.haml4
-rw-r--r--app/views/dashboard/milestones/_milestone.html.haml2
-rw-r--r--app/views/dashboard/milestones/index.html.haml4
-rw-r--r--app/views/dashboard/projects/_nav.html.haml27
-rw-r--r--app/views/dashboard/projects/_starred_empty_state.html.haml9
-rw-r--r--app/views/dashboard/projects/_zero_authorized_projects.html.haml15
-rw-r--r--app/views/dashboard/projects/index.html.haml4
-rw-r--r--app/views/dashboard/projects/starred.html.haml13
-rw-r--r--app/views/dashboard/snippets/index.html.haml12
-rw-r--r--app/views/dashboard/todos/_todo.html.haml2
-rw-r--r--app/views/dashboard/todos/index.html.haml4
-rw-r--r--app/views/devise/confirmations/new.html.haml2
-rw-r--r--app/views/devise/mailer/_confirmation_instructions_secondary.html.haml2
-rw-r--r--app/views/devise/mailer/email_changed.html.haml2
-rw-r--r--app/views/devise/mailer/email_changed.text.erb2
-rw-r--r--app/views/devise/passwords/edit.html.haml4
-rw-r--r--app/views/devise/passwords/new.html.haml2
-rw-r--r--app/views/devise/registrations/edit.html.erb2
-rw-r--r--app/views/devise/sessions/_new_ldap.html.haml2
-rw-r--r--app/views/devise/sessions/new.html.haml2
-rw-r--r--app/views/devise/sessions/two_factor.html.haml2
-rw-r--r--app/views/devise/shared/_omniauth_box.html.haml2
-rw-r--r--app/views/devise/shared/_signin_box.html.haml8
-rw-r--r--app/views/devise/shared/_signup_box.html.haml27
-rw-r--r--app/views/devise/shared/_tabs_ldap.html.haml1
-rw-r--r--app/views/devise/unlocks/new.html.haml2
-rw-r--r--app/views/discussions/_diff_discussion.html.haml2
-rw-r--r--app/views/discussions/_diff_with_notes.html.haml4
-rw-r--r--app/views/discussions/_parallel_diff_discussion.html.haml8
-rw-r--r--app/views/doorkeeper/applications/index.html.haml2
-rw-r--r--app/views/doorkeeper/applications/show.html.haml4
-rw-r--r--app/views/doorkeeper/authorizations/new.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.html.haml2
-rw-r--r--app/views/email_rejection_mailer/rejection.text.haml2
-rw-r--r--app/views/events/_event.html.haml6
-rw-r--r--app/views/events/_events.html.haml19
-rw-r--r--app/views/events/event/_common.html.haml3
-rw-r--r--app/views/events/event/_note.html.haml5
-rw-r--r--app/views/events/event/_push.html.haml3
-rw-r--r--app/views/explore/groups/_groups.html.haml4
-rw-r--r--app/views/explore/groups/index.html.haml2
-rw-r--r--app/views/explore/projects/_filter.html.haml10
-rw-r--r--app/views/explore/projects/index.html.haml6
-rw-r--r--app/views/explore/projects/starred.html.haml6
-rw-r--r--app/views/explore/projects/trending.html.haml6
-rw-r--r--app/views/explore/snippets/index.html.haml2
-rw-r--r--app/views/groups/_activities.html.haml2
-rw-r--r--app/views/groups/_archived_projects.html.haml4
-rw-r--r--app/views/groups/_create_chat_team.html.haml9
-rw-r--r--app/views/groups/_group_admin_settings.html.haml11
-rw-r--r--app/views/groups/_home_panel.html.haml71
-rw-r--r--app/views/groups/_shared_projects.html.haml4
-rw-r--r--app/views/groups/_subgroups_and_projects.html.haml4
-rw-r--r--app/views/groups/edit.html.haml4
-rw-r--r--app/views/groups/group_members/_new_group_member.html.haml2
-rw-r--r--app/views/groups/group_members/index.html.haml6
-rw-r--r--app/views/groups/labels/index.html.haml14
-rw-r--r--app/views/groups/merge_requests.html.haml2
-rw-r--r--app/views/groups/milestones/_form.html.haml17
-rw-r--r--app/views/groups/milestones/index.html.haml1
-rw-r--r--app/views/groups/new.html.haml4
-rw-r--r--app/views/groups/runners/_group_runners.html.haml2
-rw-r--r--app/views/groups/settings/_general.html.haml20
-rw-r--r--app/views/groups/settings/_permissions.html.haml1
-rw-r--r--app/views/groups/settings/_project_creation_level.html.haml3
-rw-r--r--app/views/groups/settings/ci_cd/_auto_devops_form.html.haml15
-rw-r--r--app/views/groups/settings/ci_cd/show.html.haml24
-rw-r--r--app/views/groups/show.html.haml95
-rw-r--r--app/views/help/_shortcuts.html.haml12
-rw-r--r--app/views/help/index.html.haml13
-rw-r--r--app/views/help/instance_configuration.html.haml2
-rw-r--r--app/views/help/show.html.haml2
-rw-r--r--app/views/help/ui.html.haml4
-rw-r--r--app/views/ide/_show.html.haml2
-rw-r--r--app/views/import/_githubish_status.html.haml59
-rw-r--r--app/views/import/bitbucket_server/status.html.haml2
-rw-r--r--app/views/import/gitea/new.html.haml6
-rw-r--r--app/views/import/gitea/status.html.haml2
-rw-r--r--app/views/import/github/new.html.haml4
-rw-r--r--app/views/import/github/status.html.haml4
-rw-r--r--app/views/import/manifest/status.html.haml2
-rw-r--r--app/views/instance_statistics/conversational_development_index/_callout.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/_card.html.haml4
-rw-r--r--app/views/instance_statistics/conversational_development_index/_no_data.html.haml6
-rw-r--r--app/views/instance_statistics/conversational_development_index/index.html.haml4
-rw-r--r--app/views/issues/_issue.atom.builder1
-rw-r--r--app/views/issues/_issues_calendar.ics.ruby2
-rw-r--r--app/views/layouts/_head.html.haml31
-rw-r--r--app/views/layouts/_init_client_detection_flags.html.haml7
-rw-r--r--app/views/layouts/_mailer.html.haml8
-rw-r--r--app/views/layouts/_page.html.haml2
-rw-r--r--app/views/layouts/_piwik.html.haml2
-rw-r--r--app/views/layouts/_search.html.haml2
-rw-r--r--app/views/layouts/application.html.haml7
-rw-r--r--app/views/layouts/devise.html.haml19
-rw-r--r--app/views/layouts/devise_empty.html.haml2
-rw-r--r--app/views/layouts/empty_mailer.html.haml5
-rw-r--r--app/views/layouts/empty_mailer.text.erb5
-rw-r--r--app/views/layouts/group.html.haml2
-rw-r--r--app/views/layouts/header/_default.html.haml8
-rw-r--r--app/views/layouts/header/_empty.html.haml4
-rw-r--r--app/views/layouts/header/_help_dropdown.html.haml8
-rw-r--r--app/views/layouts/header/_new_dropdown.haml4
-rw-r--r--app/views/layouts/mailer.text.erb5
-rw-r--r--app/views/layouts/nav/_classification_level_banner.html.haml5
-rw-r--r--app/views/layouts/nav/_dashboard.html.haml11
-rw-r--r--app/views/layouts/nav/sidebar/_admin.html.haml28
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml47
-rw-r--r--app/views/layouts/nav/sidebar/_profile.html.haml8
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml59
-rw-r--r--app/views/layouts/notify.html.haml4
-rw-r--r--app/views/layouts/notify.text.erb5
-rw-r--r--app/views/layouts/snippets.html.haml2
-rw-r--r--app/views/notify/_note_email.html.haml10
-rw-r--r--app/views/notify/_note_email.text.erb10
-rw-r--r--app/views/notify/_reassigned_issuable_email.html.haml10
-rw-r--r--app/views/notify/_removal_notification.html.haml9
-rw-r--r--app/views/notify/autodevops_disabled_email.text.erb2
-rw-r--r--app/views/notify/changed_milestone_email.html.haml (renamed from app/views/notify/changed_milestone_issue_email.html.haml)2
-rw-r--r--app/views/notify/changed_milestone_email.text.erb1
-rw-r--r--app/views/notify/changed_milestone_issue_email.text.erb1
-rw-r--r--app/views/notify/changed_milestone_merge_request_email.html.haml3
-rw-r--r--app/views/notify/changed_milestone_merge_request_email.text.erb1
-rw-r--r--app/views/notify/closed_issue_email.html.haml2
-rw-r--r--app/views/notify/closed_issue_email.text.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.html.haml2
-rw-r--r--app/views/notify/closed_merge_request_email.text.haml6
-rw-r--r--app/views/notify/import_issues_csv_email.html.haml18
-rw-r--r--app/views/notify/import_issues_csv_email.text.erb11
-rw-r--r--app/views/notify/issue_due_email.html.haml2
-rw-r--r--app/views/notify/issue_due_email.text.erb2
-rw-r--r--app/views/notify/issue_moved_email.html.haml11
-rw-r--r--app/views/notify/issue_moved_email.text.erb4
-rw-r--r--app/views/notify/issue_status_changed_email.html.haml2
-rw-r--r--app/views/notify/issue_status_changed_email.text.erb2
-rw-r--r--app/views/notify/links/ci/builds/_build.text.erb2
-rw-r--r--app/views/notify/member_access_granted_email.html.haml11
-rw-r--r--app/views/notify/member_access_granted_email.text.erb7
-rw-r--r--app/views/notify/member_access_requested_email.text.erb2
-rw-r--r--app/views/notify/member_invite_accepted_email.text.erb2
-rw-r--r--app/views/notify/member_invited_email.text.erb2
-rw-r--r--app/views/notify/merge_request_status_email.html.haml2
-rw-r--r--app/views/notify/merge_request_status_email.text.haml6
-rw-r--r--app/views/notify/merge_request_unmergeable_email.text.haml4
-rw-r--r--app/views/notify/merged_merge_request_email.text.haml4
-rw-r--r--app/views/notify/new_gpg_key_email.html.haml2
-rw-r--r--app/views/notify/new_gpg_key_email.text.erb2
-rw-r--r--app/views/notify/new_issue_email.html.haml2
-rw-r--r--app/views/notify/new_issue_email.text.erb4
-rw-r--r--app/views/notify/new_mention_in_issue_email.text.erb4
-rw-r--r--app/views/notify/new_mention_in_merge_request_email.text.erb4
-rw-r--r--app/views/notify/new_merge_request_email.html.haml6
-rw-r--r--app/views/notify/new_merge_request_email.text.erb2
-rw-r--r--app/views/notify/new_ssh_key_email.html.haml2
-rw-r--r--app/views/notify/new_ssh_key_email.text.erb2
-rw-r--r--app/views/notify/new_user_email.html.haml2
-rw-r--r--app/views/notify/new_user_email.text.erb2
-rw-r--r--app/views/notify/pages_domain_disabled_email.html.haml4
-rw-r--r--app/views/notify/pages_domain_verification_failed_email.html.haml4
-rw-r--r--app/views/notify/pipeline_failed_email.text.erb6
-rw-r--r--app/views/notify/pipeline_success_email.text.erb6
-rw-r--r--app/views/notify/push_to_merge_request_email.html.haml2
-rw-r--r--app/views/notify/push_to_merge_request_email.text.haml2
-rw-r--r--app/views/notify/reassigned_issue_email.html.haml11
-rw-r--r--app/views/notify/reassigned_issue_email.text.erb2
-rw-r--r--app/views/notify/reassigned_merge_request_email.html.haml11
-rw-r--r--app/views/notify/reassigned_merge_request_email.text.erb4
-rw-r--r--app/views/notify/resolved_all_discussions_email.html.haml2
-rw-r--r--app/views/notify/resolved_all_discussions_email.text.erb2
-rw-r--r--app/views/profiles/_email_settings.html.haml16
-rw-r--r--app/views/profiles/_event_table.html.haml6
-rw-r--r--app/views/profiles/accounts/_providers.html.haml21
-rw-r--r--app/views/profiles/accounts/show.html.haml43
-rw-r--r--app/views/profiles/active_sessions/_active_session.html.haml6
-rw-r--r--app/views/profiles/audit_log.html.haml4
-rw-r--r--app/views/profiles/chat_names/_chat_name.html.haml4
-rw-r--r--app/views/profiles/chat_names/index.html.haml16
-rw-r--r--app/views/profiles/notifications/_email_settings.html.haml6
-rw-r--r--app/views/profiles/notifications/show.html.haml20
-rw-r--r--app/views/profiles/passwords/new.html.haml28
-rw-r--r--app/views/profiles/preferences/show.html.haml79
-rw-r--r--app/views/profiles/show.html.haml70
-rw-r--r--app/views/profiles/two_factor_auths/show.html.haml3
-rw-r--r--app/views/projects/_activity.html.haml4
-rw-r--r--app/views/projects/_classification_policy_settings.html.haml6
-rw-r--r--app/views/projects/_export.html.haml66
-rw-r--r--app/views/projects/_files.html.haml10
-rw-r--r--app/views/projects/_flash_messages.html.haml4
-rw-r--r--app/views/projects/_home_panel.html.haml54
-rw-r--r--app/views/projects/_import_project_pane.html.haml2
-rw-r--r--app/views/projects/_issuable_by_email.html.haml9
-rw-r--r--app/views/projects/_md_preview.html.haml20
-rw-r--r--app/views/projects/_merge_request_merge_checks_settings.html.haml19
-rw-r--r--app/views/projects/_merge_request_merge_method_settings.html.haml46
-rw-r--r--app/views/projects/_merge_request_merge_options_settings.html.haml14
-rw-r--r--app/views/projects/_merge_request_merge_settings.html.haml23
-rw-r--r--app/views/projects/_merge_request_settings.html.haml4
-rw-r--r--app/views/projects/_new_project_fields.html.haml16
-rw-r--r--app/views/projects/_wiki.html.haml5
-rw-r--r--app/views/projects/_zen.html.haml1
-rw-r--r--app/views/projects/artifacts/_tree_file.html.haml2
-rw-r--r--app/views/projects/artifacts/browse.html.haml2
-rw-r--r--app/views/projects/badges/badge_flat-square.svg.erb17
-rw-r--r--app/views/projects/blob/_editor.html.haml4
-rw-r--r--app/views/projects/blob/_header_content.html.haml2
-rw-r--r--app/views/projects/blob/_markdown_buttons.html.haml13
-rw-r--r--app/views/projects/blob/diff.html.haml6
-rw-r--r--app/views/projects/blob/edit.html.haml2
-rw-r--r--app/views/projects/blob/preview.html.haml41
-rw-r--r--app/views/projects/blob/viewers/_dependency_manager.html.haml5
-rw-r--r--app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml4
-rw-r--r--app/views/projects/blob/viewers/_loading.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_markup.html.haml6
-rw-r--r--app/views/projects/blob/viewers/_readme.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map.html.haml2
-rw-r--r--app/views/projects/blob/viewers/_route_map_loading.html.haml2
-rw-r--r--app/views/projects/branches/_branch.html.haml37
-rw-r--r--app/views/projects/branches/_commit.html.haml4
-rw-r--r--app/views/projects/branches/_panel.html.haml2
-rw-r--r--app/views/projects/branches/index.html.haml5
-rw-r--r--app/views/projects/buttons/_clone.html.haml22
-rw-r--r--app/views/projects/buttons/_download.html.haml45
-rw-r--r--app/views/projects/buttons/_download_links.html.haml5
-rw-r--r--app/views/projects/buttons/_dropdown.html.haml4
-rw-r--r--app/views/projects/ci/builds/_build.html.haml26
-rw-r--r--app/views/projects/ci/lints/_create.html.haml35
-rw-r--r--app/views/projects/ci/lints/show.html.haml12
-rw-r--r--app/views/projects/cleanup/_show.html.haml6
-rw-r--r--app/views/projects/commit/_ajax_signature.html.haml2
-rw-r--r--app/views/projects/commit/_ci_menu.html.haml8
-rw-r--r--app/views/projects/commit/_commit_box.html.haml21
-rw-r--r--app/views/projects/commit/_limit_exceeded_message.html.haml4
-rw-r--r--app/views/projects/commit/_other_user_signature_badge.html.haml4
-rw-r--r--app/views/projects/commit/_same_user_different_email_signature_badge.html.haml5
-rw-r--r--app/views/projects/commit/_signature_badge.html.haml4
-rw-r--r--app/views/projects/commit/_unverified_signature_badge.html.haml4
-rw-r--r--app/views/projects/commit/_verified_signature_badge.html.haml5
-rw-r--r--app/views/projects/commit/pipelines.html.haml2
-rw-r--r--app/views/projects/commit/show.html.haml11
-rw-r--r--app/views/projects/commits/_commit.html.haml18
-rw-r--r--app/views/projects/commits/_inline_commit.html.haml2
-rw-r--r--app/views/projects/commits/show.html.haml2
-rw-r--r--app/views/projects/compare/show.html.haml2
-rw-r--r--app/views/projects/cycle_analytics/show.html.haml2
-rw-r--r--app/views/projects/default_branch/_show.html.haml2
-rw-r--r--app/views/projects/deploy_keys/_index.html.haml4
-rw-r--r--app/views/projects/deployments/_actions.haml4
-rw-r--r--app/views/projects/deployments/_commit.html.haml6
-rw-r--r--app/views/projects/deployments/_confirm_rollback_modal.html.haml23
-rw-r--r--app/views/projects/deployments/_deployment.html.haml2
-rw-r--r--app/views/projects/deployments/_rollback.haml3
-rw-r--r--app/views/projects/diffs/_content.html.haml2
-rw-r--r--app/views/projects/diffs/_diffs.html.haml4
-rw-r--r--app/views/projects/diffs/_file.html.haml4
-rw-r--r--app/views/projects/diffs/_line.html.haml2
-rw-r--r--app/views/projects/diffs/_parallel_view.html.haml8
-rw-r--r--app/views/projects/diffs/_render_error.html.haml6
-rw-r--r--app/views/projects/diffs/_replaced_image_diff.html.haml6
-rw-r--r--app/views/projects/diffs/_single_image_diff.html.haml2
-rw-r--r--app/views/projects/diffs/_text_file.html.haml2
-rw-r--r--app/views/projects/diffs/_warning.html.haml2
-rw-r--r--app/views/projects/edit.html.haml352
-rw-r--r--app/views/projects/empty.html.haml133
-rw-r--r--app/views/projects/environments/_form.html.haml2
-rw-r--r--app/views/projects/environments/folder.html.haml5
-rw-r--r--app/views/projects/environments/index.html.haml1
-rw-r--r--app/views/projects/error_tracking/index.html.haml3
-rw-r--r--app/views/projects/forks/_fork_button.html.haml4
-rw-r--r--app/views/projects/forks/error.html.haml14
-rw-r--r--app/views/projects/forks/index.html.haml13
-rw-r--r--app/views/projects/forks/new.html.haml16
-rw-r--r--app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml4
-rw-r--r--app/views/projects/graphs/charts.html.haml12
-rw-r--r--app/views/projects/graphs/show.html.haml4
-rw-r--r--app/views/projects/issues/_closed_by_box.html.haml4
-rw-r--r--app/views/projects/issues/_discussion.html.haml2
-rw-r--r--app/views/projects/issues/_form.html.haml3
-rw-r--r--app/views/projects/issues/_issue.html.haml10
-rw-r--r--app/views/projects/issues/_merge_requests.html.haml35
-rw-r--r--app/views/projects/issues/_nav_btns.html.haml41
-rw-r--r--app/views/projects/issues/_new_branch.html.haml2
-rw-r--r--app/views/projects/issues/_related_branches.html.haml4
-rw-r--r--app/views/projects/issues/import_csv/_button.html.haml9
-rw-r--r--app/views/projects/issues/import_csv/_modal.html.haml24
-rw-r--r--app/views/projects/issues/index.html.haml5
-rw-r--r--app/views/projects/issues/new.html.haml9
-rw-r--r--app/views/projects/issues/show.html.haml33
-rw-r--r--app/views/projects/jobs/index.html.haml4
-rw-r--r--app/views/projects/jobs/show.html.haml1
-rw-r--r--app/views/projects/labels/index.html.haml48
-rw-r--r--app/views/projects/merge_requests/_form.html.haml3
-rw-r--r--app/views/projects/merge_requests/_merge_request.html.haml13
-rw-r--r--app/views/projects/merge_requests/_merge_requests.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_box.html.haml2
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml6
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml36
-rw-r--r--app/views/projects/merge_requests/conflicts/_submit_form.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml2
-rw-r--r--app/views/projects/merge_requests/conflicts/show.html.haml6
-rw-r--r--app/views/projects/merge_requests/show.html.haml22
-rw-r--r--app/views/projects/milestones/_form.html.haml23
-rw-r--r--app/views/projects/milestones/edit.html.haml10
-rw-r--r--app/views/projects/milestones/index.html.haml9
-rw-r--r--app/views/projects/milestones/new.html.haml10
-rw-r--r--app/views/projects/milestones/show.html.haml33
-rw-r--r--app/views/projects/mirrors/_authentication_method.html.haml22
-rw-r--r--app/views/projects/mirrors/_disabled_mirror_badge.html.haml1
-rw-r--r--app/views/projects/mirrors/_instructions.html.haml2
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml38
-rw-r--r--app/views/projects/mirrors/_mirror_repos_push.html.haml2
-rw-r--r--app/views/projects/mirrors/_ssh_host_keys.html.haml2
-rw-r--r--app/views/projects/new.html.haml16
-rw-r--r--app/views/projects/notes/_actions.html.haml6
-rw-r--r--app/views/projects/pages/_destroy.haml2
-rw-r--r--app/views/projects/pages/_https_only.html.haml2
-rw-r--r--app/views/projects/pages_domains/_form.html.haml19
-rw-r--r--app/views/projects/pages_domains/edit.html.haml4
-rw-r--r--app/views/projects/pages_domains/new.html.haml8
-rw-r--r--app/views/projects/pages_domains/show.html.haml27
-rw-r--r--app/views/projects/pipeline_schedules/_form.html.haml8
-rw-r--r--app/views/projects/pipelines/_info.html.haml35
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml30
-rw-r--r--app/views/projects/pipelines/charts.html.haml6
-rw-r--r--app/views/projects/pipelines/charts/_pipeline_times.haml6
-rw-r--r--app/views/projects/pipelines/charts/_pipelines.haml9
-rw-r--r--app/views/projects/pipelines/index.html.haml4
-rw-r--r--app/views/projects/pipelines/new.html.haml12
-rw-r--r--app/views/projects/pipelines/show.html.haml10
-rw-r--r--app/views/projects/project_members/_groups.html.haml3
-rw-r--r--app/views/projects/project_members/_new_project_group.html.haml5
-rw-r--r--app/views/projects/project_members/_new_project_member.html.haml16
-rw-r--r--app/views/projects/project_members/_team.html.haml9
-rw-r--r--app/views/projects/project_members/import.html.haml12
-rw-r--r--app/views/projects/project_members/index.html.haml53
-rw-r--r--app/views/projects/project_templates/_built_in_templates.html.haml6
-rw-r--r--app/views/projects/protected_branches/_protected_branch.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_create_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_branches/shared/_index.html.haml2
-rw-r--r--app/views/projects/protected_branches/shared/_protected_branch.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_create_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/shared/_index.html.haml2
-rw-r--r--app/views/projects/registry/repositories/_tag.html.haml2
-rw-r--r--app/views/projects/releases/index.html.haml5
-rw-r--r--app/views/projects/runners/_group_runners.html.haml2
-rw-r--r--app/views/projects/serverless/functions/index.html.haml5
-rw-r--r--app/views/projects/serverless/functions/show.html.haml21
-rw-r--r--app/views/projects/services/prometheus/_metrics.html.haml2
-rw-r--r--app/views/projects/services/prometheus/_show.html.haml4
-rw-r--r--app/views/projects/settings/_general.html.haml42
-rw-r--r--app/views/projects/settings/ci_cd/_autodevops_form.html.haml28
-rw-r--r--app/views/projects/settings/ci_cd/_form.html.haml6
-rw-r--r--app/views/projects/settings/ci_cd/show.html.haml12
-rw-r--r--app/views/projects/settings/operations/_error_tracking.html.haml20
-rw-r--r--app/views/projects/settings/operations/_external_dashboard.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml8
-rw-r--r--app/views/projects/snippets/index.html.haml18
-rw-r--r--app/views/projects/snippets/new.html.haml4
-rw-r--r--app/views/projects/tags/_tag.html.haml7
-rw-r--r--app/views/projects/tags/index.html.haml2
-rw-r--r--app/views/projects/tags/releases/edit.html.haml (renamed from app/views/projects/releases/edit.html.haml)3
-rw-r--r--app/views/projects/tags/show.html.haml9
-rw-r--r--app/views/projects/tree/_readme.html.haml2
-rw-r--r--app/views/projects/tree/_tree_header.html.haml21
-rw-r--r--app/views/projects/triggers/_trigger.html.haml2
-rw-r--r--app/views/projects/wikis/_form.html.haml16
-rw-r--r--app/views/projects/wikis/_sidebar.html.haml2
-rw-r--r--app/views/projects/wikis/edit.html.haml3
-rw-r--r--app/views/projects/wikis/pages.html.haml15
-rw-r--r--app/views/projects/wikis/show.html.haml6
-rw-r--r--app/views/repository_check_mailer/notify.text.haml2
-rw-r--r--app/views/search/_category.html.haml36
-rw-r--r--app/views/search/_filter.html.haml20
-rw-r--r--app/views/search/_form.html.haml6
-rw-r--r--app/views/search/_results.html.haml13
-rw-r--r--app/views/search/results/_blob.html.haml2
-rw-r--r--app/views/search/results/_empty.html.haml2
-rw-r--r--app/views/search/results/_issue.html.haml2
-rw-r--r--app/views/search/results/_merge_request.html.haml4
-rw-r--r--app/views/search/results/_note.html.haml8
-rw-r--r--app/views/search/results/_snippet_blob.html.haml8
-rw-r--r--app/views/search/results/_snippet_title.html.haml2
-rw-r--r--app/views/search/results/_user.html.haml10
-rw-r--r--app/views/search/results/_wiki_blob.html.haml4
-rw-r--r--app/views/search/show.html.haml2
-rw-r--r--app/views/sent_notifications/unsubscribe.html.haml13
-rw-r--r--app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml4
-rw-r--r--app/views/shared/_choose_avatar_button.html.haml4
-rw-r--r--app/views/shared/_choose_group_avatar_button.html.haml4
-rw-r--r--app/views/shared/_clone_panel.html.haml5
-rw-r--r--app/views/shared/_confirm_modal.html.haml8
-rw-r--r--app/views/shared/_delete_label_modal.html.haml6
-rw-r--r--app/views/shared/_file_highlight.html.haml2
-rw-r--r--app/views/shared/_flash_user_callout.html.haml11
-rw-r--r--app/views/shared/_import_form.html.haml3
-rw-r--r--app/views/shared/_issuable_meta_data.html.haml2
-rw-r--r--app/views/shared/_label.html.haml10
-rw-r--r--app/views/shared/_label_row.html.haml17
-rw-r--r--app/views/shared/_milestones_sort_dropdown.html.haml12
-rw-r--r--app/views/shared/_mini_pipeline_graph.html.haml7
-rw-r--r--app/views/shared/_mobile_clone_panel.html.haml11
-rw-r--r--app/views/shared/_personal_access_tokens_created_container.html.haml2
-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/_remote_mirror_update_button.html.haml2
-rw-r--r--app/views/shared/_sidebar_toggle_button.html.haml4
-rw-r--r--app/views/shared/boards/_show.html.haml10
-rw-r--r--app/views/shared/boards/components/_board.html.haml13
-rw-r--r--app/views/shared/boards/components/_sidebar.html.haml9
-rw-r--r--app/views/shared/boards/components/sidebar/_assignee.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_due_date.html.haml2
-rw-r--r--app/views/shared/boards/components/sidebar/_labels.html.haml25
-rw-r--r--app/views/shared/boards/components/sidebar/_time_tracker.html.haml6
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml9
-rw-r--r--app/views/shared/empty_states/_issues.html.haml31
-rw-r--r--app/views/shared/empty_states/_labels.html.haml2
-rw-r--r--app/views/shared/empty_states/_merge_requests.html.haml19
-rw-r--r--app/views/shared/empty_states/_priority_labels.html.haml2
-rw-r--r--app/views/shared/empty_states/_profile_tabs.html.haml19
-rw-r--r--app/views/shared/empty_states/_snippets.html.haml20
-rw-r--r--app/views/shared/empty_states/_wikis.html.haml2
-rw-r--r--app/views/shared/empty_states/_wikis_layout.html.haml2
-rw-r--r--app/views/shared/form_elements/_description.html.haml2
-rw-r--r--app/views/shared/groups/_dropdown.html.haml6
-rw-r--r--app/views/shared/groups/_group.html.haml2
-rw-r--r--app/views/shared/groups/_list.html.haml14
-rw-r--r--app/views/shared/icons/_emoji_slightly_smiling_face.svg1
-rw-r--r--app/views/shared/icons/_emoji_smile.svg1
-rw-r--r--app/views/shared/icons/_emoji_smiley.svg1
-rw-r--r--app/views/shared/icons/_express.svg1
-rw-r--r--app/views/shared/icons/_gitea_logo.svg.erb1
-rw-r--r--app/views/shared/icons/_rails.svg1
-rw-r--r--app/views/shared/icons/_spring.svg1
-rw-r--r--app/views/shared/issuable/_assignees.html.haml6
-rw-r--r--app/views/shared/issuable/_board_create_list_dropdown.html.haml4
-rw-r--r--app/views/shared/issuable/_bulk_update_sidebar.html.haml8
-rw-r--r--app/views/shared/issuable/_feed_buttons.html.haml4
-rw-r--r--app/views/shared/issuable/_filter.html.haml32
-rw-r--r--app/views/shared/issuable/_label_dropdown.html.haml2
-rw-r--r--app/views/shared/issuable/_label_page_create.html.haml8
-rw-r--r--app/views/shared/issuable/_label_page_default.html.haml2
-rw-r--r--app/views/shared/issuable/_nav.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml42
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml169
-rw-r--r--app/views/shared/issuable/_sidebar_assignees.html.haml81
-rw-r--r--app/views/shared/issuable/_sidebar_todo.html.haml18
-rw-r--r--app/views/shared/issuable/_sort_dropdown.html.haml17
-rw-r--r--app/views/shared/issuable/form/_contribution.html.haml2
-rw-r--r--app/views/shared/issuable/form/_merge_params.html.haml4
-rw-r--r--app/views/shared/issuable/form/_merge_request_assignee.html.haml31
-rw-r--r--app/views/shared/issuable/form/_metadata.html.haml10
-rw-r--r--app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml (renamed from app/views/shared/issuable/form/_metadata_issue_assignee.html.haml)6
-rw-r--r--app/views/shared/issuable/form/_title.html.haml2
-rw-r--r--app/views/shared/issuable/nav_links/_all.html.haml2
-rw-r--r--app/views/shared/labels/_form.html.haml13
-rw-r--r--app/views/shared/labels/_nav.html.haml4
-rw-r--r--app/views/shared/labels/_sort_dropdown.html.haml2
-rw-r--r--app/views/shared/members/_access_request_buttons.html.haml20
-rw-r--r--app/views/shared/members/_access_request_links.html.haml2
-rw-r--r--app/views/shared/members/_member.html.haml2
-rw-r--r--app/views/shared/milestones/_form_dates.html.haml6
-rw-r--r--app/views/shared/milestones/_issuable.html.haml3
-rw-r--r--app/views/shared/milestones/_labels_tab.html.haml5
-rw-r--r--app/views/shared/milestones/_milestone.html.haml18
-rw-r--r--app/views/shared/milestones/_search_form.html.haml8
-rw-r--r--app/views/shared/milestones/_sidebar.html.haml2
-rw-r--r--app/views/shared/milestones/_tabs.html.haml6
-rw-r--r--app/views/shared/milestones/_top.html.haml43
-rw-r--r--app/views/shared/notes/_comment_button.html.haml19
-rw-r--r--app/views/shared/notes/_edit.html.haml2
-rw-r--r--app/views/shared/notes/_edit_form.html.haml8
-rw-r--r--app/views/shared/notes/_form.html.haml8
-rw-r--r--app/views/shared/notes/_hints.html.haml12
-rw-r--r--app/views/shared/notes/_note.html.haml21
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml14
-rw-r--r--app/views/shared/notifications/_button.html.haml4
-rw-r--r--app/views/shared/notifications/_new_button.html.haml (renamed from app/views/projects/buttons/_notifications.html.haml)16
-rw-r--r--app/views/shared/notifications/_notification_dropdown.html.haml2
-rw-r--r--app/views/shared/projects/_dropdown.html.haml21
-rw-r--r--app/views/shared/projects/_list.html.haml27
-rw-r--r--app/views/shared/projects/_project.html.haml53
-rw-r--r--app/views/shared/projects/_search_bar.html.haml28
-rw-r--r--app/views/shared/projects/_search_form.html.haml7
-rw-r--r--app/views/shared/projects/_sort_dropdown.html.haml39
-rw-r--r--app/views/shared/snippets/_form.html.haml15
-rw-r--r--app/views/shared/snippets/_header.html.haml12
-rw-r--r--app/views/shared/snippets/_list.html.haml12
-rw-r--r--app/views/shared/snippets/_snippet.html.haml2
-rw-r--r--app/views/shared/tokens/_scopes_form.html.haml2
-rw-r--r--app/views/snippets/_actions.html.haml2
-rw-r--r--app/views/snippets/_snippets.html.haml13
-rw-r--r--app/views/snippets/index.html.haml2
-rw-r--r--app/views/snippets/new.html.haml2
-rw-r--r--app/views/snippets/notes/_actions.html.haml6
-rw-r--r--app/views/users/_groups.html.haml2
-rw-r--r--app/views/users/calendar_activities.html.haml2
-rw-r--r--app/views/users/show.html.haml12
-rw-r--r--app/workers/all_queues.yml27
-rw-r--r--app/workers/build_finished_worker.rb28
-rw-r--r--app/workers/chat_notification_worker.rb33
-rw-r--r--app/workers/ci/build_prepare_worker.rb16
-rw-r--r--app/workers/cleanup_container_repository_worker.rb53
-rw-r--r--app/workers/cluster_configure_worker.rb14
-rw-r--r--app/workers/cluster_patch_app_worker.rb13
-rw-r--r--app/workers/cluster_platform_configure_worker.rb12
-rw-r--r--app/workers/cluster_provision_worker.rb2
-rw-r--r--app/workers/cluster_upgrade_app_worker.rb13
-rw-r--r--app/workers/clusters/applications/uninstall_worker.rb17
-rw-r--r--app/workers/clusters/applications/wait_for_uninstall_app_worker.rb20
-rw-r--r--app/workers/concerns/application_worker.rb2
-rw-r--r--app/workers/concerns/waitable_worker.rb8
-rw-r--r--app/workers/create_gpg_signature_worker.rb12
-rw-r--r--app/workers/delete_container_repository_worker.rb2
-rw-r--r--app/workers/deployments/finished_worker.rb13
-rw-r--r--app/workers/detect_repository_languages_worker.rb7
-rw-r--r--app/workers/email_receiver_worker.rb16
-rw-r--r--app/workers/emails_on_push_worker.rb34
-rw-r--r--app/workers/expire_build_artifacts_worker.rb14
-rw-r--r--app/workers/expire_pipeline_cache_worker.rb39
-rw-r--r--app/workers/git_garbage_collect_worker.rb14
-rw-r--r--app/workers/hashed_storage/base_worker.rb21
-rw-r--r--app/workers/hashed_storage/migrator_worker.rb16
-rw-r--r--app/workers/hashed_storage/project_migrate_worker.rb26
-rw-r--r--app/workers/hashed_storage/project_rollback_worker.rb26
-rw-r--r--app/workers/hashed_storage/rollbacker_worker.rb16
-rw-r--r--app/workers/import_issues_csv_worker.rb20
-rw-r--r--app/workers/mail_scheduler/notification_service_worker.rb54
-rw-r--r--app/workers/migrate_external_diffs_worker.rb12
-rw-r--r--app/workers/namespaceless_project_destroy_worker.rb2
-rw-r--r--app/workers/object_pool/destroy_worker.rb16
-rw-r--r--app/workers/object_pool/join_worker.rb11
-rw-r--r--app/workers/object_storage/migrate_uploads_worker.rb22
-rw-r--r--app/workers/pages_domain_removal_cron_worker.rb14
-rw-r--r--app/workers/pipeline_metrics_worker.rb2
-rw-r--r--app/workers/pipeline_schedule_worker.rb15
-rw-r--r--app/workers/post_receive.rb37
-rw-r--r--app/workers/process_commit_worker.rb2
-rw-r--r--app/workers/project_cache_worker.rb21
-rw-r--r--app/workers/project_daily_statistics_worker.rb13
-rw-r--r--app/workers/project_migrate_hashed_storage_worker.rb38
-rw-r--r--app/workers/reactive_caching_worker.rb9
-rw-r--r--app/workers/remote_mirror_notification_worker.rb3
-rw-r--r--app/workers/remove_expired_members_worker.rb8
-rw-r--r--app/workers/repository_fork_worker.rb10
-rw-r--r--app/workers/schedule_migrate_external_diffs_worker.rb14
-rw-r--r--app/workers/storage_migrator_worker.rb10
-rw-r--r--app/workers/stuck_merge_jobs_worker.rb6
-rw-r--r--app/workers/update_head_pipeline_for_merge_request_worker.rb21
-rw-r--r--app/workers/update_project_statistics_worker.rb18
2348 files changed, 40808 insertions, 18602 deletions
diff --git a/app/assets/images/favicon-yellow.png b/app/assets/images/favicon-yellow.png
index 2d5289818b4..a80827808fc 100644
--- a/app/assets/images/favicon-yellow.png
+++ b/app/assets/images/favicon-yellow.png
Binary files differ
diff --git a/app/assets/images/none-scheme-preview.png b/app/assets/images/none-scheme-preview.png
new file mode 100644
index 00000000000..2eb6bf96671
--- /dev/null
+++ b/app/assets/images/none-scheme-preview.png
Binary files differ
diff --git a/app/assets/images/select2-spinner.gif b/app/assets/images/select2-spinner.gif
new file mode 100644
index 00000000000..5b33f7e54f4
--- /dev/null
+++ b/app/assets/images/select2-spinner.gif
Binary files differ
diff --git a/app/assets/images/select2.png b/app/assets/images/select2.png
new file mode 100644
index 00000000000..1d804ffb996
--- /dev/null
+++ b/app/assets/images/select2.png
Binary files differ
diff --git a/app/assets/images/select2x2.png b/app/assets/images/select2x2.png
new file mode 100644
index 00000000000..4bdd5c961d4
--- /dev/null
+++ b/app/assets/images/select2x2.png
Binary files differ
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js
index 7607c4b3b79..e583a8affd4 100644
--- a/app/assets/javascripts/api.js
+++ b/app/assets/javascripts/api.js
@@ -1,16 +1,19 @@
import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
+import { joinPaths } from './lib/utils/url_utility';
const Api = {
groupsPath: '/api/:version/groups.json',
groupPath: '/api/:version/groups/:id',
+ groupMembersPath: '/api/:version/groups/:id/members',
subgroupsPath: '/api/:version/groups/:id/subgroups',
namespacesPath: '/api/:version/namespaces.json',
groupProjectsPath: '/api/:version/groups/:id/projects.json',
projectsPath: '/api/:version/projects.json',
projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels',
+ projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests',
projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
projectMergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
@@ -29,6 +32,7 @@ const Api = {
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
+ releasesPath: '/api/:version/projects/:id/releases',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@@ -39,6 +43,12 @@ const Api = {
});
},
+ groupMembers(id) {
+ const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
// Return groups list. Filtered by query
groups(query, options, callback = $.noop) {
const url = Api.buildUrl(Api.groupsPath);
@@ -103,6 +113,22 @@ const Api = {
return axios.get(url);
},
+ /**
+ * Get all Merge Requests for a project, eventually filtering based on
+ * supplied parameters
+ * @param projectPath
+ * @param params
+ * @returns {Promise}
+ */
+ projectMergeRequests(projectPath, params = {}) {
+ const url = Api.buildUrl(Api.projectMergeRequestsPath).replace(
+ ':id',
+ encodeURIComponent(projectPath),
+ );
+
+ return axios.get(url, { params });
+ },
+
// Return Merge Request for project
projectMergeRequest(projectPath, mergeRequestId, params = {}) {
const url = Api.buildUrl(Api.projectMergeRequestPath)
@@ -307,12 +333,14 @@ const Api = {
});
},
+ releases(id) {
+ const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
+
+ return axios.get(url);
+ },
+
buildUrl(url) {
- let urlRoot = '';
- if (gon.relative_url_root != null) {
- urlRoot = gon.relative_url_root;
- }
- return urlRoot + url.replace(':version', gon.api_version);
+ return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
},
};
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js
index dcda625f587..d38e0b4abaa 100644
--- a/app/assets/javascripts/group_avatar.js
+++ b/app/assets/javascripts/avatar_picker.js
@@ -1,11 +1,12 @@
import $ from 'jquery';
-export default function groupAvatar() {
- $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() {
+export default function initAvatarPicker() {
+ $('.js-choose-avatar-button').on('click', function onClickAvatar() {
const form = $(this).closest('form');
- return form.find('.js-group-avatar-input').click();
+ return form.find('.js-avatar-input').click();
});
- $('.js-group-avatar-input').on('change', function onChangeAvatarInput() {
+
+ $('.js-avatar-input').on('change', function onChangeAvatarInput() {
const form = $(this).closest('form');
const filename = $(this)
.val()
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index cace8bb9dba..743f11625bc 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -8,6 +8,7 @@ import { updateTooltipTitle } from './lib/utils/common_utils';
import { isInVueNoteablePage } from './lib/utils/dom_utils';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import bp from './breakpoints';
const animationEndEventString = 'animationend webkitAnimationEnd MSAnimationEnd oAnimationEnd';
const transitionEndEventString = 'transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd';
@@ -264,7 +265,10 @@ export class AwardsHandler {
const css = {
top: `${$addBtn.offset().top + $addBtn.outerHeight()}px`,
};
- if (position === 'right') {
+ // for xs screen we position the element on center
+ if (bp.getBreakpointSize() === 'xs') {
+ css.left = '5%';
+ } else if (position === 'right') {
css.left = `${$addBtn.offset().left - $menu.outerWidth() + 20}px`;
$menu.addClass('is-aligned-right');
} else {
@@ -437,7 +441,7 @@ export class AwardsHandler {
createAwardButtonForVotesBlock(votesBlock, emojiName) {
const buttonHtml = `
- <button class="btn award-control js-emoji-btn has-tooltip active" title="You" data-placement="bottom">
+ <button class="btn award-control js-emoji-btn has-tooltip active" title="You">
${this.emoji.glEmojiTag(emojiName)}
<span class="award-control-text js-counter">1</span>
</button>
diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue
index 85a15b38de1..df74eb2c2f7 100644
--- a/app/assets/javascripts/badges/components/badge_form.vue
+++ b/app/assets/javascripts/badges/components/badge_form.vue
@@ -90,7 +90,7 @@ export default {
},
badgeImageUrlExample() {
const exampleUrl =
- 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/badge.svg';
+ 'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/pipeline.svg';
return sprintf(s__('Badges|e.g. %{exampleUrl}'), {
exampleUrl,
});
diff --git a/app/assets/javascripts/badges/components/badge_list_row.vue b/app/assets/javascripts/badges/components/badge_list_row.vue
index 9051be1e102..cad5611c8c5 100644
--- a/app/assets/javascripts/badges/components/badge_list_row.vue
+++ b/app/assets/javascripts/badges/components/badge_list_row.vue
@@ -55,7 +55,7 @@ export default {
:disabled="badge.isDeleting"
class="btn btn-default append-right-8"
type="button"
- @click="editBadge(badge);"
+ @click="editBadge(badge)"
>
<icon :size="16" :aria-label="__('Edit')" name="pencil" />
</button>
@@ -65,7 +65,7 @@ export default {
type="button"
data-toggle="modal"
data-target="#delete-badge-modal"
- @click="updateBadgeInModal(badge);"
+ @click="updateBadgeInModal(badge)"
>
<icon :size="16" :aria-label="__('Delete')" name="remove" />
</button>
diff --git a/app/assets/javascripts/batch_comments/mixins/resolved_status.js b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
new file mode 100644
index 00000000000..3bbbaa86b51
--- /dev/null
+++ b/app/assets/javascripts/batch_comments/mixins/resolved_status.js
@@ -0,0 +1,15 @@
+import { sprintf, __ } from '~/locale';
+
+export default {
+ computed: {
+ resolveButtonTitle() {
+ let title = __('Mark comment as resolved');
+
+ if (this.resolvedBy) {
+ title = sprintf(__('Resolved by %{name}'), { name: this.resolvedBy.name });
+ }
+
+ return title;
+ },
+ },
+};
diff --git a/app/assets/javascripts/behaviors/copy_to_clipboard.js b/app/assets/javascripts/behaviors/copy_to_clipboard.js
index 9a33a060c76..c3541e62568 100644
--- a/app/assets/javascripts/behaviors/copy_to_clipboard.js
+++ b/app/assets/javascripts/behaviors/copy_to_clipboard.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Clipboard from 'clipboard';
+import { sprintf, __ } from '~/locale';
function showTooltip(target, title) {
const $target = $(target);
@@ -16,7 +17,7 @@ function showTooltip(target, title) {
}
function genericSuccess(e) {
- showTooltip(e.trigger, 'Copied');
+ showTooltip(e.trigger, __('Copied'));
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
@@ -33,7 +34,7 @@ function genericError(e) {
} else {
key = 'Ctrl';
}
- showTooltip(e.trigger, `Press ${key}-C to copy`);
+ showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key }));
}
export default function initCopyToClipboard() {
diff --git a/app/assets/javascripts/behaviors/gl_emoji.js b/app/assets/javascripts/behaviors/gl_emoji.js
index 56293d5f96f..d1d75658181 100644
--- a/app/assets/javascripts/behaviors/gl_emoji.js
+++ b/app/assets/javascripts/behaviors/gl_emoji.js
@@ -1,11 +1,10 @@
-import installCustomElements from 'document-register-element';
+import 'document-register-element';
import isEmojiUnicodeSupported from '../emoji/support';
-installCustomElements(window);
+class GlEmoji extends HTMLElement {
+ constructor() {
+ super();
-export default function installGlEmojiElement() {
- const GlEmojiElementProto = Object.create(HTMLElement.prototype);
- GlEmojiElementProto.createdCallback = function createdCallback() {
const emojiUnicode = this.textContent.trim();
const { name, unicodeVersion, fallbackSrc, fallbackSpriteClass } = this.dataset;
@@ -43,9 +42,11 @@ export default function installGlEmojiElement() {
});
}
}
- };
+ }
+}
- document.registerElement('gl-emoji', {
- prototype: GlEmojiElementProto,
- });
+export default function installGlEmojiElement() {
+ if (!customElements.get('gl-emoji')) {
+ customElements.define('gl-emoji', GlEmoji);
+ }
}
diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
index fe02096d903..318b7f77c7b 100644
--- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js
@@ -1,320 +1,5 @@
-/* eslint-disable object-shorthand, no-unused-vars, no-use-before-define, no-restricted-syntax, guard-for-in, no-continue */
-
import $ from 'jquery';
-import _ from 'underscore';
-import { insertText, getSelectedFragment, nodeMatchesSelector } from '~/lib/utils/common_utils';
-import { placeholderImage } from '~/lazy_loader';
-
-const gfmRules = {
- // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert
- // GitLab Flavored Markdown (GFM) to HTML.
- // These handlers consequently convert that same HTML to GFM to be copied to the clipboard.
- // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
- // from GFM should have a handler here, in reverse order.
- // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
- InlineDiffFilter: {
- 'span.idiff.addition'(el, text) {
- return `{+${text}+}`;
- },
- 'span.idiff.deletion'(el, text) {
- return `{-${text}-}`;
- },
- },
- TaskListFilter: {
- 'input[type=checkbox].task-list-item-checkbox'(el) {
- return `[${el.checked ? 'x' : ' '}]`;
- },
- },
- ReferenceFilter: {
- '.tooltip'(el) {
- return '';
- },
- 'a.gfm:not([data-link=true])'(el, text) {
- return el.dataset.original || text;
- },
- },
- AutolinkFilter: {
- a(el, text) {
- // Fallback on the regular MarkdownFilter's `a` handler.
- if (text !== el.getAttribute('href')) return false;
-
- return text;
- },
- },
- TableOfContentsFilter: {
- 'ul.section-nav'(el) {
- return '[[_TOC_]]';
- },
- },
- EmojiFilter: {
- 'img.emoji'(el) {
- return el.getAttribute('alt');
- },
- 'gl-emoji'(el) {
- return `:${el.getAttribute('data-name')}:`;
- },
- },
- ImageLinkFilter: {
- 'a.no-attachment-icon'(el, text) {
- return text;
- },
- },
- ImageLazyLoadFilter: {
- img(el, text) {
- return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`;
- },
- },
- VideoLinkFilter: {
- '.video-container'(el) {
- const videoEl = el.querySelector('video');
- if (!videoEl) return false;
-
- return CopyAsGFM.nodeToGFM(videoEl);
- },
- video(el) {
- return `![${el.dataset.title}](${el.getAttribute('src')})`;
- },
- },
- MermaidFilter: {
- 'svg.mermaid'(el, text) {
- const sourceEl = el.querySelector('text.source');
- if (!sourceEl) return false;
-
- return `\`\`\`mermaid\n${CopyAsGFM.nodeToGFM(sourceEl)}\n\`\`\``;
- },
- 'svg.mermaid style, svg.mermaid g'(el, text) {
- // We don't want to include the content of these elements in the copied text.
- return '';
- },
- },
- MathFilter: {
- 'pre.code.math[data-math-style=display]'(el, text) {
- return `\`\`\`math\n${text.trim()}\n\`\`\``;
- },
- 'code.code.math[data-math-style=inline]'(el, text) {
- return `$\`${text}\`$`;
- },
- 'span.katex-display span.katex-mathml'(el) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``;
- },
- 'span.katex-mathml'(el) {
- const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]');
- if (!mathAnnotation) return false;
-
- return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`;
- },
- 'span.katex-html'(el) {
- // We don't want to include the content of this element in the copied text.
- return '';
- },
- 'annotation[encoding="application/x-tex"]'(el, text) {
- return text.trim();
- },
- },
- SanitizationFilter: {
- 'a[name]:not([href]):empty'(el) {
- return el.outerHTML;
- },
- dl(el, text) {
- let lines = text
- .replace(/\n\n/g, '\n')
- .trim()
- .split('\n');
- // Add two spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- lines = lines.map(l => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- });
-
- return `<dl>\n${lines.join('\n')}\n</dl>\n`;
- },
- 'dt, dd, summary, details'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>\n`;
- },
- 'sup, sub, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) {
- const tag = el.nodeName.toLowerCase();
- return `<${tag}>${text}</${tag}>`;
- },
- },
- SyntaxHighlightFilter: {
- 'pre.code.highlight'(el, t) {
- const text = t.trimRight();
-
- let lang = el.getAttribute('lang');
- if (!lang || lang === 'plaintext') {
- lang = '';
- }
-
- // Prefixes lines with 4 spaces if the code contains triple backticks
- if (lang === '' && text.match(/^```/gm)) {
- return text
- .split('\n')
- .map(l => {
- const line = l.trim();
- if (line.length === 0) return '';
-
- return ` ${line}`;
- })
- .join('\n');
- }
-
- return `\`\`\`${lang}\n${text}\n\`\`\``;
- },
- 'pre > code'(el, text) {
- // Don't wrap code blocks in ``
- return text;
- },
- },
- MarkdownFilter: {
- br(el) {
- // Two spaces at the end of a line are turned into a BR
- return ' ';
- },
- code(el, text) {
- let backtickCount = 1;
- const backtickMatch = text.match(/`+/);
- if (backtickMatch) {
- backtickCount = backtickMatch[0].length + 1;
- }
-
- const backticks = Array(backtickCount + 1).join('`');
- const spaceOrNoSpace = backtickCount > 1 ? ' ' : '';
-
- return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks;
- },
- blockquote(el, text) {
- return text
- .trim()
- .split('\n')
- .map(s => `> ${s}`.trim())
- .join('\n');
- },
- img(el) {
- const imageSrc = el.src;
- const imageUrl = imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
- return `![${el.getAttribute('alt')}](${imageUrl})`;
- },
- 'a.anchor'(el, text) {
- // Don't render a Markdown link for the anchor link inside a heading
- return text;
- },
- a(el, text) {
- return `[${text}](${el.getAttribute('href')})`;
- },
- li(el, text) {
- const lines = text.trim().split('\n');
- const firstLine = `- ${lines.shift()}`;
- // Add four spaces to the front of subsequent list items lines,
- // or leave the line entirely blank.
- const nextLines = lines.map(s => {
- if (s.trim().length === 0) return '';
-
- return ` ${s}`;
- });
-
- return `${firstLine}\n${nextLines.join('\n')}`;
- },
- ul(el, text) {
- return text;
- },
- ol(el, text) {
- // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists.
- return text.replace(/^- /gm, '1. ');
- },
- h1(el, text) {
- return `# ${text.trim()}\n`;
- },
- h2(el, text) {
- return `## ${text.trim()}\n`;
- },
- h3(el, text) {
- return `### ${text.trim()}\n`;
- },
- h4(el, text) {
- return `#### ${text.trim()}\n`;
- },
- h5(el, text) {
- return `##### ${text.trim()}\n`;
- },
- h6(el, text) {
- return `###### ${text.trim()}\n`;
- },
- strong(el, text) {
- return `**${text}**`;
- },
- em(el, text) {
- return `_${text}_`;
- },
- del(el, text) {
- return `~~${text}~~`;
- },
- hr(el) {
- // extra leading \n is to ensure that there is a blank line between
- // a list followed by an hr, otherwise this breaks old redcarpet rendering
- return '\n-----\n';
- },
- p(el, text) {
- return `${text.trim()}\n`;
- },
- table(el) {
- const theadEl = el.querySelector('thead');
- const tbodyEl = el.querySelector('tbody');
- if (!theadEl || !tbodyEl) return false;
-
- const theadText = CopyAsGFM.nodeToGFM(theadEl);
- const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl);
-
- return [theadText, tbodyText].join('\n');
- },
- thead(el, text) {
- const cells = _.map(el.querySelectorAll('th'), cell => {
- let chars = CopyAsGFM.nodeToGFM(cell).length + 2;
-
- let before = '';
- let after = '';
- const alignment = cell.align || cell.style.textAlign;
-
- switch (alignment) {
- case 'center':
- before = ':';
- after = ':';
- chars -= 2;
- break;
- case 'right':
- after = ':';
- chars -= 1;
- break;
- default:
- break;
- }
-
- chars = Math.max(chars, 3);
-
- const middle = Array(chars + 1).join('-');
-
- return before + middle + after;
- });
-
- const separatorRow = `|${cells.join('|')}|`;
-
- return [text, separatorRow].join('\n');
- },
- tr(el) {
- const cellEls = el.querySelectorAll('td, th');
- if (cellEls.length === 0) return false;
-
- const cells = _.map(cellEls, cell => CopyAsGFM.nodeToGFM(cell));
- return `| ${cells.join(' | ')} |`;
- },
- },
-};
+import { getSelectedFragment } from '~/lib/utils/common_utils';
export class CopyAsGFM {
constructor() {
@@ -325,10 +10,10 @@ export class CopyAsGFM {
const isIOS = /\b(iPad|iPhone|iPod)(?=;)/.test(userAgent);
if (isIOS) return;
- $(document).on('copy', '.md, .wiki', e => {
+ $(document).on('copy', '.md', e => {
CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformGFMSelection);
});
- $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', e => {
+ $(document).on('copy', 'pre.code.highlight, table.code td.line_content', e => {
CopyAsGFM.copyAsGFM(e, CopyAsGFM.transformCodeSelection);
});
$(document).on('paste', '.js-gfm-input', CopyAsGFM.pasteGFM);
@@ -347,8 +32,24 @@ export class CopyAsGFM {
e.preventDefault();
e.stopPropagation();
+ const div = document.createElement('div');
+ div.appendChild(el.cloneNode(true));
+ const html = div.innerHTML;
+
clipboardData.setData('text/plain', el.textContent);
- clipboardData.setData('text/x-gfm', this.nodeToGFM(el));
+ clipboardData.setData('text/html', html);
+ // We are also setting this as fallback to transform the selection to gfm on paste
+ clipboardData.setData('text/x-gfm-html', html);
+
+ CopyAsGFM.nodeToGFM(el)
+ .then(res => {
+ clipboardData.setData('text/x-gfm', res);
+ })
+ .catch(() => {
+ // Not showing the error as Firefox might doesn't allow
+ // it or other browsers who have a time limit on the execution
+ // of the copy event
+ });
}
static pasteGFM(e) {
@@ -357,11 +58,28 @@ export class CopyAsGFM {
const text = clipboardData.getData('text/plain');
const gfm = clipboardData.getData('text/x-gfm');
- if (!gfm) return;
+ const gfmHtml = clipboardData.getData('text/x-gfm-html');
+ if (!gfm && !gfmHtml) return;
e.preventDefault();
- window.gl.utils.insertText(e.target, (textBefore, textAfter) => {
+ // We have the original selection already converted to gfm
+ if (gfm) {
+ CopyAsGFM.insertPastedText(e.target, text, gfm);
+ } else {
+ // Due to the async copy call we are not able to produce gfm so we transform the cached HTML
+ const div = document.createElement('div');
+ div.innerHTML = gfmHtml;
+ CopyAsGFM.nodeToGFM(div)
+ .then(transformedGfm => {
+ CopyAsGFM.insertPastedText(e.target, text, transformedGfm);
+ })
+ .catch(() => {});
+ }
+ }
+
+ static insertPastedText(target, text, gfm) {
+ window.gl.utils.insertText(target, textBefore => {
// If the text before the cursor contains an odd number of backticks,
// we are either inside an inline code span that starts with 1 backtick
// or a code block that starts with 3 backticks.
@@ -381,7 +99,7 @@ export class CopyAsGFM {
}
static transformGFMSelection(documentFragment) {
- const gfmElements = documentFragment.querySelectorAll('.md, .wiki');
+ const gfmElements = documentFragment.querySelectorAll('.md');
switch (gfmElements.length) {
case 0: {
return documentFragment;
@@ -443,75 +161,24 @@ export class CopyAsGFM {
return codeElement;
}
- static nodeToGFM(node, respectWhitespaceParam = false) {
- if (node.nodeType === Node.COMMENT_NODE) {
- return '';
- }
-
- if (node.nodeType === Node.TEXT_NODE) {
- return node.textContent;
- }
-
- const respectWhitespace =
- respectWhitespaceParam || (node.nodeName === 'PRE' || node.nodeName === 'CODE');
-
- const text = this.innerGFM(node, respectWhitespace);
-
- if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
- return text;
- }
-
- for (const filter in gfmRules) {
- const rules = gfmRules[filter];
-
- for (const selector in rules) {
- const func = rules[selector];
-
- if (!nodeMatchesSelector(node, selector)) continue;
-
- let result;
- if (func.length === 2) {
- // if `func` takes 2 arguments, it depends on text.
- // if there is no text, we don't need to generate GFM for this node.
- if (text.length === 0) continue;
-
- result = func(node, text);
- } else {
- result = func(node);
- }
-
- if (result === false) continue;
-
- return result;
- }
- }
-
- return text;
- }
-
- static innerGFM(parentNode, respectWhitespace = false) {
- const nodes = parentNode.childNodes;
-
- const clonedParentNode = parentNode.cloneNode(true);
- const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0);
-
- for (let i = 0; i < nodes.length; i += 1) {
- const node = nodes[i];
- const clonedNode = clonedNodes[i];
-
- const text = this.nodeToGFM(node, respectWhitespace);
-
- // `clonedNode.replaceWith(text)` is not yet widely supported
- clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode);
- }
-
- let nodeText = clonedParentNode.innerText || clonedParentNode.textContent;
-
- if (!respectWhitespace) {
- nodeText = nodeText.trim();
- }
-
- return nodeText;
+ static nodeToGFM(node) {
+ return Promise.all([
+ import(/* webpackChunkName: 'gfm_copy_extra' */ 'prosemirror-model'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './schema'),
+ import(/* webpackChunkName: 'gfm_copy_extra' */ './serializer'),
+ ])
+ .then(([prosemirrorModel, schema, markdownSerializer]) => {
+ const { DOMParser } = prosemirrorModel;
+ const wrapEl = document.createElement('div');
+ wrapEl.appendChild(node.cloneNode(true));
+ const doc = DOMParser.fromSchema(schema.default).parse(wrapEl);
+
+ const res = markdownSerializer.default.serialize(doc, {
+ tightLists: true,
+ });
+ return res;
+ })
+ .catch(() => {});
}
}
diff --git a/app/assets/javascripts/behaviors/markdown/editor_extensions.js b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
new file mode 100644
index 00000000000..47e5fc65c48
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/editor_extensions.js
@@ -0,0 +1,106 @@
+import Doc from './nodes/doc';
+import Paragraph from './nodes/paragraph';
+import Text from './nodes/text';
+
+import Blockquote from './nodes/blockquote';
+import CodeBlock from './nodes/code_block';
+import HardBreak from './nodes/hard_break';
+import Heading from './nodes/heading';
+import HorizontalRule from './nodes/horizontal_rule';
+import Image from './nodes/image';
+
+import Table from './nodes/table';
+import TableHead from './nodes/table_head';
+import TableBody from './nodes/table_body';
+import TableHeaderRow from './nodes/table_header_row';
+import TableRow from './nodes/table_row';
+import TableCell from './nodes/table_cell';
+
+import Emoji from './nodes/emoji';
+import Reference from './nodes/reference';
+
+import TableOfContents from './nodes/table_of_contents';
+import Video from './nodes/video';
+
+import BulletList from './nodes/bullet_list';
+import OrderedList from './nodes/ordered_list';
+import ListItem from './nodes/list_item';
+
+import DescriptionList from './nodes/description_list';
+import DescriptionTerm from './nodes/description_term';
+import DescriptionDetails from './nodes/description_details';
+
+import TaskList from './nodes/task_list';
+import OrderedTaskList from './nodes/ordered_task_list';
+import TaskListItem from './nodes/task_list_item';
+
+import Summary from './nodes/summary';
+import Details from './nodes/details';
+
+import Bold from './marks/bold';
+import Italic from './marks/italic';
+import Strike from './marks/strike';
+import InlineDiff from './marks/inline_diff';
+
+import Link from './marks/link';
+import Code from './marks/code';
+import MathMark from './marks/math';
+import InlineHTML from './marks/inline_html';
+
+// The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb transform
+// GitLab Flavored Markdown (GFM) to HTML.
+// The nodes and marks referenced here transform that same HTML to GFM to be copied to the clipboard.
+// Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML
+// from GFM should have a node or mark here.
+// The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb.
+
+export default [
+ new Doc(),
+ new Paragraph(),
+ new Text(),
+
+ new Blockquote(),
+ new CodeBlock(),
+ new HardBreak(),
+ new Heading({ maxLevel: 6 }),
+ new HorizontalRule(),
+ new Image(),
+
+ new Table(),
+ new TableHead(),
+ new TableBody(),
+ new TableHeaderRow(),
+ new TableRow(),
+ new TableCell(),
+
+ new Emoji(),
+ new Reference(),
+
+ new TableOfContents(),
+ new Video(),
+
+ new BulletList(),
+ new OrderedList(),
+ new ListItem(),
+
+ new DescriptionList(),
+ new DescriptionTerm(),
+ new DescriptionDetails(),
+
+ new TaskList(),
+ new OrderedTaskList(),
+ new TaskListItem(),
+
+ new Summary(),
+ new Details(),
+
+ new Bold(),
+ new Italic(),
+ new Strike(),
+ new InlineDiff(),
+
+ new Link(),
+ new Code(),
+ new MathMark(),
+ new InlineHTML(),
+];
diff --git a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
index 55c68139ded..b7200150925 100644
--- a/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
+++ b/app/assets/javascripts/behaviors/markdown/gfm_auto_complete.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import { parseBoolean } from '~/lib/utils/common_utils';
-import GfmAutoComplete from '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
export default function initGFMInput() {
$('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
diff --git a/app/assets/javascripts/behaviors/markdown/marks/bold.js b/app/assets/javascripts/behaviors/markdown/marks/bold.js
new file mode 100644
index 00000000000..b537954c1cb
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/bold.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { Bold as BaseBold } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Bold extends BaseBold {
+ get toMarkdown() {
+ return defaultMarkdownSerializer.marks.strong;
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/code.js b/app/assets/javascripts/behaviors/markdown/marks/code.js
new file mode 100644
index 00000000000..a760ee80dd0
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/code.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { Code as BaseCode } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Code extends BaseCode {
+ get toMarkdown() {
+ return defaultMarkdownSerializer.marks.code;
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
new file mode 100644
index 00000000000..ce425e80cd3
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_diff.js
@@ -0,0 +1,41 @@
+/* eslint-disable class-methods-use-this */
+
+import { Mark } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::InlineDiffFilter
+export default class InlineDiff extends Mark {
+ get name() {
+ return 'inline_diff';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ addition: {
+ default: true,
+ },
+ },
+ parseDOM: [
+ { tag: 'span.idiff.addition', attrs: { addition: true } },
+ { tag: 'span.idiff.deletion', attrs: { addition: false } },
+ ],
+ toDOM: node => [
+ 'span',
+ { class: `idiff left right ${node.attrs.addition ? 'addition' : 'deletion'}` },
+ 0,
+ ],
+ };
+ }
+
+ get toMarkdown() {
+ return {
+ mixable: true,
+ open(state, mark) {
+ return mark.attrs.addition ? '{+' : '{-';
+ },
+ close(state, mark) {
+ return mark.attrs.addition ? '+}' : '-}';
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/inline_html.js b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
new file mode 100644
index 00000000000..ebed8698e21
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/inline_html.js
@@ -0,0 +1,46 @@
+/* eslint-disable class-methods-use-this */
+
+import { Mark } from 'tiptap';
+import _ from 'underscore';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class InlineHTML extends Mark {
+ get name() {
+ return 'inline_html';
+ }
+
+ get schema() {
+ return {
+ excludes: '',
+ attrs: {
+ tag: {},
+ title: { default: null },
+ },
+ parseDOM: [
+ {
+ tag: 'sup, sub, kbd, q, samp, var',
+ getAttrs: el => ({ tag: el.nodeName.toLowerCase() }),
+ },
+ {
+ tag: 'abbr',
+ getAttrs: el => ({ tag: 'abbr', title: el.getAttribute('title') }),
+ },
+ ],
+ toDOM: node => [node.attrs.tag, { title: node.attrs.title }, 0],
+ };
+ }
+
+ get toMarkdown() {
+ return {
+ mixable: true,
+ open(state, mark) {
+ return `<${mark.attrs.tag}${
+ mark.attrs.title ? ` title="${state.esc(_.escape(mark.attrs.title))}"` : ''
+ }>`;
+ },
+ close(state, mark) {
+ return `</${mark.attrs.tag}>`;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/italic.js b/app/assets/javascripts/behaviors/markdown/marks/italic.js
new file mode 100644
index 00000000000..44b35c97739
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/italic.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { Italic as BaseItalic } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Italic extends BaseItalic {
+ get toMarkdown() {
+ return defaultMarkdownSerializer.marks.em;
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/link.js b/app/assets/javascripts/behaviors/markdown/marks/link.js
new file mode 100644
index 00000000000..5c23d6a5ceb
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/link.js
@@ -0,0 +1,21 @@
+/* eslint-disable class-methods-use-this */
+
+import { Link as BaseLink } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Link extends BaseLink {
+ get toMarkdown() {
+ return {
+ mixable: true,
+ open(state, mark, parent, index) {
+ const open = defaultMarkdownSerializer.marks.link.open(state, mark, parent, index);
+ return open === '<' ? '' : open;
+ },
+ close(state, mark, parent, index) {
+ const close = defaultMarkdownSerializer.marks.link.close(state, mark, parent, index);
+ return close === '>' ? '' : close;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/math.js b/app/assets/javascripts/behaviors/markdown/marks/math.js
new file mode 100644
index 00000000000..e582fb18f15
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/math.js
@@ -0,0 +1,41 @@
+/* eslint-disable class-methods-use-this */
+
+import { Mark } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MathFilter
+export default class MathMark extends Mark {
+ get name() {
+ return 'math';
+ }
+
+ get schema() {
+ return {
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::MathFilter
+ {
+ tag: 'code.code.math[data-math-style=inline]',
+ priority: 51,
+ },
+ // Matches HTML after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex',
+ contentElement: 'annotation[encoding="application/x-tex"]',
+ },
+ ],
+ toDOM: () => ['code', { class: 'code math', 'data-math-style': 'inline' }, 0],
+ };
+ }
+
+ get toMarkdown() {
+ return {
+ escape: false,
+ open(state, mark, parent, index) {
+ return `$${defaultMarkdownSerializer.marks.code.open(state, mark, parent, index)}`;
+ },
+ close(state, mark, parent, index) {
+ return `${defaultMarkdownSerializer.marks.code.close(state, mark, parent, index)}$`;
+ },
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/marks/strike.js b/app/assets/javascripts/behaviors/markdown/marks/strike.js
new file mode 100644
index 00000000000..c2951a40a4b
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/marks/strike.js
@@ -0,0 +1,15 @@
+/* eslint-disable class-methods-use-this */
+
+import { Strike as BaseStrike } from 'tiptap-extensions';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Strike extends BaseStrike {
+ get toMarkdown() {
+ return {
+ open: '~~',
+ close: '~~',
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
new file mode 100644
index 00000000000..b0bc8f79643
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/blockquote.js
@@ -0,0 +1,13 @@
+/* eslint-disable class-methods-use-this */
+
+import { Blockquote as BaseBlockquote } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Blockquote extends BaseBlockquote {
+ toMarkdown(state, node) {
+ if (!node.childCount) return;
+
+ defaultMarkdownSerializer.nodes.blockquote(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
new file mode 100644
index 00000000000..3b0792e1af8
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/bullet_list.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { BulletList as BaseBulletList } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class BulletList extends BaseBulletList {
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.bullet_list(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/code_block.js b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
new file mode 100644
index 00000000000..1e0c05eff08
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/code_block.js
@@ -0,0 +1,99 @@
+/* eslint-disable class-methods-use-this */
+
+import { CodeBlock as BaseCodeBlock } from 'tiptap-extensions';
+
+const PLAINTEXT_LANG = 'plaintext';
+
+// Transforms generated HTML back to GFM for:
+// - Banzai::Filter::SyntaxHighlightFilter
+// - Banzai::Filter::MathFilter
+// - Banzai::Filter::MermaidFilter
+// - Banzai::Filter::SuggestionFilter
+export default class CodeBlock extends BaseCodeBlock {
+ get schema() {
+ return {
+ content: 'text*',
+ marks: '',
+ group: 'block',
+ code: true,
+ defining: true,
+ attrs: {
+ lang: { default: PLAINTEXT_LANG },
+ },
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::SyntaxHighlightFilter, Banzai::Filter::MathFilter, Banzai::Filter::MermaidFilter, or Banzai::Filter::SuggestionFilter
+ {
+ tag: 'pre.code.highlight',
+ preserveWhitespace: 'full',
+ getAttrs: el => {
+ const lang = el.getAttribute('lang');
+ if (!lang || lang === '') return {};
+
+ return { lang };
+ },
+ },
+ // Matches HTML generated by Banzai::Filter::MathFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_math.js
+ {
+ tag: 'span.katex-display',
+ preserveWhitespace: 'full',
+ contentElement: 'annotation[encoding="application/x-tex"]',
+ attrs: { lang: 'math' },
+ },
+ // Matches HTML generated by Banzai::Filter::MermaidFilter,
+ // after being transformed by app/assets/javascripts/behaviors/markdown/render_mermaid.js
+ {
+ tag: 'svg.mermaid',
+ preserveWhitespace: 'full',
+ contentElement: 'text.source',
+ attrs: { lang: 'mermaid' },
+ },
+ // Matches HTML generated by Banzai::Filter::SuggestionFilter,
+ // after being transformed by app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+ {
+ tag: '.md-suggestion',
+ skip: true,
+ },
+ {
+ tag: '.md-suggestion-header',
+ ignore: true,
+ },
+ {
+ tag: '.md-suggestion-diff',
+ preserveWhitespace: 'full',
+ getContent: (el, schema) =>
+ [...el.querySelectorAll('.line_content.new span')].map(span =>
+ schema.text(span.innerText),
+ ),
+ attrs: { lang: 'suggestion' },
+ },
+ ],
+ toDOM: node => ['pre', { class: 'code highlight', lang: node.attrs.lang }, ['code', 0]],
+ };
+ }
+
+ toMarkdown(state, node) {
+ if (!node.childCount) return;
+
+ const {
+ textContent: text,
+ attrs: { lang },
+ } = node;
+
+ // Prefixes lines with 4 spaces if the code contains a line that starts with triple backticks
+ if (lang === PLAINTEXT_LANG && text.match(/^```/gm)) {
+ state.wrapBlock(' ', null, node, () => state.text(text, false));
+ return;
+ }
+
+ state.write('```');
+ if (lang !== PLAINTEXT_LANG) state.write(lang);
+
+ state.ensureNewLine();
+ state.text(text, false);
+ state.ensureNewLine();
+
+ state.write('```');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_details.js b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
new file mode 100644
index 00000000000..a4451d8ce8d
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_details.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class DescriptionDetails extends Node {
+ get name() {
+ return 'description_details';
+ }
+
+ get schema() {
+ return {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dd' }],
+ toDOM: () => ['dd', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.flushClose(1);
+ state.write('<dd>');
+ state.text(node.textContent, false);
+ state.write('</dd>');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_list.js b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
new file mode 100644
index 00000000000..6aa1aca29d7
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_list.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class DescriptionList extends Node {
+ get name() {
+ return 'description_list';
+ }
+
+ get schema() {
+ return {
+ content: '(description_term+ description_details+)+',
+ group: 'block',
+ parseDOM: [{ tag: 'dl' }],
+ toDOM: () => ['dl', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write('<dl>\n');
+ state.wrapBlock(' ', null, node, () => state.renderContent(node));
+ state.flushClose(1);
+ state.ensureNewLine();
+ state.write('</dl>');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/description_term.js b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
new file mode 100644
index 00000000000..89057ec6444
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/description_term.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class DescriptionTerm extends Node {
+ get name() {
+ return 'description_term';
+ }
+
+ get schema() {
+ return {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'dt' }],
+ toDOM: () => ['dt', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.flushClose(state.closed && state.closed.type === node.type ? 1 : 2);
+ state.write('<dt>');
+ state.text(node.textContent, false);
+ state.write('</dt>');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/details.js b/app/assets/javascripts/behaviors/markdown/nodes/details.js
new file mode 100644
index 00000000000..1c40dbb8168
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/details.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Details extends Node {
+ get name() {
+ return 'details';
+ }
+
+ get schema() {
+ return {
+ content: 'summary block*',
+ group: 'block',
+ parseDOM: [{ tag: 'details' }],
+ toDOM: () => ['details', { open: true, onclick: 'return false', tabindex: '-1' }, 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write('<details>\n');
+ state.renderContent(node);
+ state.flushClose(1);
+ state.ensureNewLine();
+ state.write('</details>');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/doc.js b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
new file mode 100644
index 00000000000..88b16fd85da
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/doc.js
@@ -0,0 +1,15 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+export default class Doc extends Node {
+ get name() {
+ return 'doc';
+ }
+
+ get schema() {
+ return {
+ content: 'block+',
+ };
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/emoji.js b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
new file mode 100644
index 00000000000..a7cc3e828f5
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/emoji.js
@@ -0,0 +1,41 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::EmojiFilter
+export default class Emoji extends Node {
+ get name() {
+ return 'emoji';
+ }
+
+ get schema() {
+ return {
+ inline: true,
+ group: 'inline',
+ attrs: {
+ name: {},
+ title: {},
+ moji: {},
+ },
+ parseDOM: [
+ {
+ tag: 'gl-emoji',
+ getAttrs: el => ({
+ name: el.dataset.name,
+ title: el.getAttribute('title'),
+ moji: el.textContent,
+ }),
+ },
+ ],
+ toDOM: node => [
+ 'gl-emoji',
+ { 'data-name': node.attrs.name, title: node.attrs.title },
+ node.attrs.moji,
+ ],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write(`:${node.attrs.name}:`);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
new file mode 100644
index 00000000000..59e5d8ab3e2
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/hard_break.js
@@ -0,0 +1,10 @@
+/* eslint-disable class-methods-use-this */
+
+import { HardBreak as BaseHardBreak } from 'tiptap-extensions';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class HardBreak extends BaseHardBreak {
+ toMarkdown(state) {
+ if (!state.atBlank()) state.write(' \n');
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/heading.js b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
new file mode 100644
index 00000000000..fec8608cf5d
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/heading.js
@@ -0,0 +1,13 @@
+/* eslint-disable class-methods-use-this */
+
+import { Heading as BaseHeading } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Heading extends BaseHeading {
+ toMarkdown(state, node) {
+ if (!node.childCount) return;
+
+ defaultMarkdownSerializer.nodes.heading(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
new file mode 100644
index 00000000000..695c7160bde
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/horizontal_rule.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { HorizontalRule as BaseHorizontalRule } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class HorizontalRule extends BaseHorizontalRule {
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.horizontal_rule(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/image.js b/app/assets/javascripts/behaviors/markdown/nodes/image.js
new file mode 100644
index 00000000000..c225a5ed876
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/image.js
@@ -0,0 +1,52 @@
+/* eslint-disable class-methods-use-this */
+
+import { Image as BaseImage } from 'tiptap-extensions';
+import { placeholderImage } from '~/lazy_loader';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+export default class Image extends BaseImage {
+ get schema() {
+ return {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
+ },
+ title: {
+ default: null,
+ },
+ },
+ group: 'inline',
+ inline: true,
+ draggable: true,
+ parseDOM: [
+ // Matches HTML generated by Banzai::Filter::ImageLinkFilter
+ {
+ tag: 'a.no-attachment-icon',
+ priority: 51,
+ skip: true,
+ },
+ // Matches HTML generated by Banzai::Filter::ImageLazyLoadFilter
+ {
+ tag: 'img[src]',
+ getAttrs: el => {
+ const imageSrc = el.src;
+ const imageUrl =
+ imageSrc && imageSrc !== placeholderImage ? imageSrc : el.dataset.src || '';
+
+ return {
+ src: imageUrl,
+ title: el.getAttribute('title'),
+ alt: el.getAttribute('alt'),
+ };
+ },
+ },
+ ],
+ toDOM: node => ['img', node.attrs],
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
new file mode 100644
index 00000000000..4237637ed9a
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/list_item.js
@@ -0,0 +1,11 @@
+/* eslint-disable class-methods-use-this */
+
+import { ListItem as BaseListItem } from 'tiptap-extensions';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class ListItem extends BaseListItem {
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.list_item(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
new file mode 100644
index 00000000000..4c1542d14ea
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_list.js
@@ -0,0 +1,10 @@
+/* eslint-disable class-methods-use-this */
+
+import { OrderedList as BaseOrderedList } from 'tiptap-extensions';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class OrderedList extends BaseOrderedList {
+ toMarkdown(state, node) {
+ state.renderList(node, ' ', () => '1. ');
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
new file mode 100644
index 00000000000..25c4976a1bc
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/ordered_task_list.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
+export default class OrderedTaskList extends Node {
+ get name() {
+ return 'ordered_task_list';
+ }
+
+ get schema() {
+ return {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: 51,
+ tag: 'ol.task-list',
+ },
+ ],
+ toDOM: () => ['ol', { class: 'task-list' }, 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.renderList(node, ' ', () => '1. ');
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
new file mode 100644
index 00000000000..dec3207b1bb
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/paragraph.js
@@ -0,0 +1,24 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Paragraph extends Node {
+ get name() {
+ return 'paragraph';
+ }
+
+ get schema() {
+ return {
+ content: 'inline*',
+ group: 'block',
+ parseDOM: [{ tag: 'p' }],
+ toDOM: () => ['p', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.paragraph(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/reference.js b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
new file mode 100644
index 00000000000..5d6bbeca833
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/reference.js
@@ -0,0 +1,52 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::ReferenceFilter and subclasses
+export default class Reference extends Node {
+ get name() {
+ return 'reference';
+ }
+
+ get schema() {
+ return {
+ inline: true,
+ group: 'inline',
+ atom: true,
+ attrs: {
+ className: {},
+ referenceType: {},
+ originalText: { default: null },
+ href: {},
+ text: {},
+ },
+ parseDOM: [
+ {
+ tag: 'a.gfm:not([data-link=true])',
+ priority: 51,
+ getAttrs: el => ({
+ className: el.className,
+ referenceType: el.dataset.referenceType,
+ originalText: el.dataset.original,
+ href: el.getAttribute('href'),
+ text: el.textContent,
+ }),
+ },
+ ],
+ toDOM: node => [
+ 'a',
+ {
+ class: node.attrs.className,
+ href: node.attrs.href,
+ 'data-reference-type': node.attrs.referenceType,
+ 'data-original': node.attrs.originalText,
+ },
+ node.attrs.text,
+ ],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write(node.attrs.originalText || node.attrs.text);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/summary.js b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
new file mode 100644
index 00000000000..2e36e316d71
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/summary.js
@@ -0,0 +1,27 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Summary extends Node {
+ get name() {
+ return 'summary';
+ }
+
+ get schema() {
+ return {
+ content: 'text*',
+ marks: '',
+ defining: true,
+ parseDOM: [{ tag: 'summary' }],
+ toDOM: () => ['summary', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write('<summary>');
+ state.text(node.textContent, false);
+ state.write('</summary>');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table.js b/app/assets/javascripts/behaviors/markdown/nodes/table.js
new file mode 100644
index 00000000000..a7fcb9227cd
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table.js
@@ -0,0 +1,25 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class Table extends Node {
+ get name() {
+ return 'table';
+ }
+
+ get schema() {
+ return {
+ content: 'table_head table_body',
+ group: 'block',
+ isolating: true,
+ parseDOM: [{ tag: 'table' }],
+ toDOM: () => ['table', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.renderContent(node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_body.js b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
new file mode 100644
index 00000000000..403556dc0c8
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_body.js
@@ -0,0 +1,24 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class TableBody extends Node {
+ get name() {
+ return 'table_body';
+ }
+
+ get schema() {
+ return {
+ content: 'table_row+',
+ parseDOM: [{ tag: 'tbody' }],
+ toDOM: () => ['tbody', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.flushClose(1);
+ state.renderContent(node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
new file mode 100644
index 00000000000..c63bfe10e39
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_cell.js
@@ -0,0 +1,35 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class TableCell extends Node {
+ get name() {
+ return 'table_cell';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ header: { default: false },
+ align: { default: null },
+ },
+ content: 'inline*',
+ isolating: true,
+ parseDOM: [
+ {
+ tag: 'td, th',
+ getAttrs: el => ({
+ header: el.tagName === 'TH',
+ align: el.getAttribute('align') || el.style.textAlign,
+ }),
+ },
+ ],
+ toDOM: node => [node.attrs.header ? 'th' : 'td', { align: node.attrs.align }, 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.renderInline(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_head.js b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
new file mode 100644
index 00000000000..4cb94bf088c
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_head.js
@@ -0,0 +1,24 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class TableHead extends Node {
+ get name() {
+ return 'table_head';
+ }
+
+ get schema() {
+ return {
+ content: 'table_header_row',
+ parseDOM: [{ tag: 'thead' }],
+ toDOM: () => ['thead', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.flushClose(1);
+ state.renderContent(node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
new file mode 100644
index 00000000000..e7eee636402
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_header_row.js
@@ -0,0 +1,43 @@
+/* eslint-disable class-methods-use-this */
+
+import TableRow from './table_row';
+
+const CENTER_ALIGN = 'center';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class TableHeaderRow extends TableRow {
+ get name() {
+ return 'table_header_row';
+ }
+
+ get schema() {
+ return {
+ content: 'table_cell+',
+ parseDOM: [
+ {
+ tag: 'thead tr',
+ priority: 51,
+ },
+ ],
+ toDOM: () => ['tr', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ const cellWidths = super.toMarkdown(state, node);
+
+ state.flushClose(1);
+
+ state.write('|');
+ node.forEach((cell, _, i) => {
+ if (i) state.write('|');
+
+ state.write(cell.attrs.align === CENTER_ALIGN ? ':' : '-');
+ state.write(state.repeat('-', cellWidths[i]));
+ state.write(cell.attrs.align === CENTER_ALIGN || cell.attrs.align === 'right' ? ':' : '-');
+ });
+ state.write('|');
+
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
new file mode 100644
index 00000000000..9a2e2c03213
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_of_contents.js
@@ -0,0 +1,34 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { __ } from '~/locale';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::TableOfContentsFilter
+export default class TableOfContents extends Node {
+ get name() {
+ return 'table_of_contents';
+ }
+
+ get schema() {
+ return {
+ group: 'block',
+ atom: true,
+ parseDOM: [
+ {
+ tag: 'ul.section-nav',
+ priority: 51,
+ },
+ {
+ tag: 'p.table-of-contents',
+ priority: 51,
+ },
+ ],
+ toDOM: () => ['p', { class: 'table-of-contents' }, __('Table of Contents')],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write('[[_TOC_]]');
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/table_row.js b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
new file mode 100644
index 00000000000..5852502773a
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/table_row.js
@@ -0,0 +1,38 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::MarkdownFilter
+export default class TableRow extends Node {
+ get name() {
+ return 'table_row';
+ }
+
+ get schema() {
+ return {
+ content: 'table_cell+',
+ parseDOM: [{ tag: 'tr' }],
+ toDOM: () => ['tr', 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ const cellWidths = [];
+
+ state.flushClose(1);
+
+ state.write('| ');
+ node.forEach((cell, _, i) => {
+ if (i) state.write(' | ');
+
+ const { length } = state.out;
+ state.render(cell, node, i);
+ cellWidths.push(state.out.length - length);
+ });
+ state.write(' |');
+
+ state.closeBlock(node);
+
+ return cellWidths;
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
new file mode 100644
index 00000000000..ab33bc21502
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list.js
@@ -0,0 +1,28 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
+export default class TaskList extends Node {
+ get name() {
+ return 'task_list';
+ }
+
+ get schema() {
+ return {
+ group: 'block',
+ content: '(task_list_item|list_item)+',
+ parseDOM: [
+ {
+ priority: 51,
+ tag: 'ul.task-list',
+ },
+ ],
+ toDOM: () => ['ul', { class: 'task-list' }, 0],
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.renderList(node, ' ', () => '* ');
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
new file mode 100644
index 00000000000..d0ee7333d5e
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/task_list_item.js
@@ -0,0 +1,49 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::TaskListFilter
+export default class TaskListItem extends Node {
+ get name() {
+ return 'task_list_item';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ done: {
+ default: false,
+ },
+ },
+ defining: true,
+ draggable: false,
+ content: 'paragraph block*',
+ parseDOM: [
+ {
+ priority: 51,
+ tag: 'li.task-list-item',
+ getAttrs: el => {
+ const checkbox = el.querySelector('input[type=checkbox].task-list-item-checkbox');
+ return { done: checkbox && checkbox.checked };
+ },
+ },
+ ],
+ toDOM(node) {
+ return [
+ 'li',
+ { class: 'task-list-item' },
+ [
+ 'input',
+ { type: 'checkbox', class: 'task-list-item-checkbox', checked: node.attrs.done },
+ ],
+ ['div', { class: 'todo-content' }, 0],
+ ];
+ },
+ };
+ }
+
+ toMarkdown(state, node) {
+ state.write(`[${node.attrs.done ? 'x' : ' '}] `);
+ state.renderContent(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/text.js b/app/assets/javascripts/behaviors/markdown/nodes/text.js
new file mode 100644
index 00000000000..84838c14999
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/text.js
@@ -0,0 +1,20 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+export default class Text extends Node {
+ get name() {
+ return 'text';
+ }
+
+ get schema() {
+ return {
+ group: 'inline',
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.text(state, node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/nodes/video.js b/app/assets/javascripts/behaviors/markdown/nodes/video.js
new file mode 100644
index 00000000000..516f983397d
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/nodes/video.js
@@ -0,0 +1,54 @@
+/* eslint-disable class-methods-use-this */
+
+import { Node } from 'tiptap';
+import { defaultMarkdownSerializer } from 'prosemirror-markdown';
+
+// Transforms generated HTML back to GFM for Banzai::Filter::VideoLinkFilter
+export default class Video extends Node {
+ get name() {
+ return 'video';
+ }
+
+ get schema() {
+ return {
+ attrs: {
+ src: {},
+ alt: {
+ default: null,
+ },
+ },
+ group: 'block',
+ draggable: true,
+ parseDOM: [
+ {
+ tag: '.video-container',
+ skip: true,
+ },
+ {
+ tag: '.video-container p',
+ priority: 51,
+ ignore: true,
+ },
+ {
+ tag: 'video[src]',
+ getAttrs: el => ({ src: el.getAttribute('src'), alt: el.dataset.title }),
+ },
+ ],
+ toDOM: node => [
+ 'video',
+ {
+ src: node.attrs.src,
+ width: '400',
+ controls: true,
+ 'data-setup': '{}',
+ 'data-title': node.attrs.alt,
+ },
+ ],
+ };
+ }
+
+ toMarkdown(state, node) {
+ defaultMarkdownSerializer.nodes.image(state, node);
+ state.closeBlock(node);
+ }
+}
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js
index fc9286d15e6..bfb073fdcdc 100644
--- a/app/assets/javascripts/behaviors/markdown/render_gfm.js
+++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js
@@ -4,6 +4,7 @@ import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
+import initMRPopovers from '../../mr_popover';
// Render GitLab flavoured Markdown
//
@@ -15,6 +16,7 @@ $.fn.renderGFM = function renderGFM() {
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
+ initMRPopovers(this.find('.gfm-merge_request').get());
return this;
};
diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
index 35380ca49fb..d0b7f3ff7a2 100644
--- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js
+++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js
@@ -1,4 +1,5 @@
import flash from '~/flash';
+import { sprintf, __ } from '../../locale';
// Renders diagrams and flowcharts from text using Mermaid in any element with the
// `js-render-mermaid` class.
@@ -14,6 +15,9 @@ import flash from '~/flash';
// </pre>
//
+// This is an arbitrary number; Can be iterated upon when suitable.
+const MAX_CHAR_LIMIT = 5000;
+
export default function renderMermaid($els) {
if (!$els.length) return;
@@ -34,6 +38,21 @@ export default function renderMermaid($els) {
$els.each((i, el) => {
const source = el.textContent;
+ /**
+ * Restrict the rendering to a certain amount of character to
+ * prevent mermaidjs from hanging up the entire thread and
+ * causing a DoS.
+ */
+ if (source && source.length > MAX_CHAR_LIMIT) {
+ el.textContent = sprintf(
+ __(
+ 'Cannot render the image. Maximum character count (%{charLimit}) has been exceeded.',
+ ),
+ { charLimit: MAX_CHAR_LIMIT },
+ );
+ return;
+ }
+
// Remove any extra spans added by the backend syntax highlighting.
Object.assign(el, { textContent: source });
diff --git a/app/assets/javascripts/behaviors/markdown/schema.js b/app/assets/javascripts/behaviors/markdown/schema.js
new file mode 100644
index 00000000000..163182ab778
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/schema.js
@@ -0,0 +1,24 @@
+import { Schema } from 'prosemirror-model';
+import editorExtensions from './editor_extensions';
+
+const nodes = editorExtensions
+ .filter(extension => extension.type === 'node')
+ .reduce(
+ (ns, { name, schema }) => ({
+ ...ns,
+ [name]: schema,
+ }),
+ {},
+ );
+
+const marks = editorExtensions
+ .filter(extension => extension.type === 'mark')
+ .reduce(
+ (ms, { name, schema }) => ({
+ ...ms,
+ [name]: schema,
+ }),
+ {},
+ );
+
+export default new Schema({ nodes, marks });
diff --git a/app/assets/javascripts/behaviors/markdown/serializer.js b/app/assets/javascripts/behaviors/markdown/serializer.js
new file mode 100644
index 00000000000..70dbd8bd206
--- /dev/null
+++ b/app/assets/javascripts/behaviors/markdown/serializer.js
@@ -0,0 +1,24 @@
+import { MarkdownSerializer } from 'prosemirror-markdown';
+import editorExtensions from './editor_extensions';
+
+const nodes = editorExtensions
+ .filter(extension => extension.type === 'node')
+ .reduce(
+ (ns, { name, toMarkdown }) => ({
+ ...ns,
+ [name]: toMarkdown,
+ }),
+ {},
+ );
+
+const marks = editorExtensions
+ .filter(extension => extension.type === 'mark')
+ .reduce(
+ (ms, { name, toMarkdown }) => ({
+ ...ms,
+ [name]: toMarkdown,
+ }),
+ {},
+ );
+
+export default new MarkdownSerializer(nodes, marks);
diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js
index 35f1bb6b080..35874140bf9 100644
--- a/app/assets/javascripts/behaviors/preview_markdown.js
+++ b/app/assets/javascripts/behaviors/preview_markdown.js
@@ -22,28 +22,25 @@ function MarkdownPreview() {}
// Minimum number of users referenced before triggering a warning
MarkdownPreview.prototype.referenceThreshold = 10;
-MarkdownPreview.prototype.emptyMessage = 'Nothing to preview.';
+MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.');
MarkdownPreview.prototype.ajaxCache = {};
MarkdownPreview.prototype.showPreview = function($form) {
var mdText;
- var markdownVersion;
- var url;
var preview = $form.find('.js-md-preview');
+ var url = preview.data('url');
if (preview.hasClass('md-preview-loading')) {
return;
}
mdText = $form.find('textarea.markdown-area').val();
- markdownVersion = $form.attr('data-markdown-version');
- url = this.versionedPreviewPath(preview.data('url'), markdownVersion);
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
} else {
- preview.addClass('md-preview-loading').text('Loading...');
+ preview.addClass('md-preview-loading').text(__('Loading...'));
this.fetchMarkdownPreview(
mdText,
url,
@@ -67,16 +64,6 @@ MarkdownPreview.prototype.showPreview = function($form) {
}
};
-MarkdownPreview.prototype.versionedPreviewPath = function(markdownPreviewPath, markdownVersion) {
- if (typeof markdownVersion === 'undefined') {
- return markdownPreviewPath;
- }
-
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
-};
-
MarkdownPreview.prototype.fetchMarkdownPreview = function(text, url, success) {
if (!url) {
return;
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index c1ea67f9293..530ab0bd4d9 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import '../commons/bootstrap';
import { isInIssuePage } from '../lib/utils/common_utils';
+import { __ } from '~/locale';
// Quick Submit behavior
//
@@ -65,7 +66,9 @@ $(document).on(
}
const $this = $(this);
- const title = isMac() ? 'You can also press &#8984;-Enter' : 'You can also press Ctrl-Enter';
+ const title = isMac()
+ ? __('You can also press &#8984;-Enter')
+ : __('You can also press Ctrl-Enter');
$this.tooltip({
container: 'body',
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
index 2918e1486a7..670f66b005e 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_issuable.js
@@ -1,6 +1,5 @@
import $ from 'jquery';
import Mousetrap from 'mousetrap';
-import _ from 'underscore';
import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
@@ -38,7 +37,7 @@ export default class ShortcutsIssuable extends Shortcuts {
}
// Sanity check: Make sure the selected text comes from a discussion : it can either contain a message...
- let foundMessage = !!documentFragment.querySelector('.md, .wiki');
+ let foundMessage = !!documentFragment.querySelector('.md');
// ... Or come from a message
if (!foundMessage) {
@@ -47,7 +46,7 @@ export default class ShortcutsIssuable extends Shortcuts {
let node = e;
do {
// Text nodes don't define the `matches` method
- if (node.matches && node.matches('.md, .wiki')) {
+ if (node.matches && node.matches('.md')) {
foundMessage = true;
}
node = node.parentNode;
@@ -63,28 +62,32 @@ export default class ShortcutsIssuable extends Shortcuts {
}
const el = CopyAsGFM.transformGFMSelection(documentFragment.cloneNode(true));
- const selected = CopyAsGFM.nodeToGFM(el);
-
- if (selected.trim() === '') {
- return false;
- }
-
- const quote = _.map(selected.split('\n'), val => `${`> ${val}`.trim()}\n`);
-
- // If replyField already has some content, add a newline before our quote
- const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
- $replyField
- .val((a, current) => `${current}${separator}${quote.join('')}\n`)
- .trigger('input')
- .trigger('change');
-
- // Trigger autosize
- const event = document.createEvent('Event');
- event.initEvent('autosize:update', true, false);
- $replyField.get(0).dispatchEvent(event);
+ const blockquoteEl = document.createElement('blockquote');
+ blockquoteEl.appendChild(el);
+ CopyAsGFM.nodeToGFM(blockquoteEl)
+ .then(text => {
+ if (text.trim() === '') {
+ return false;
+ }
+
+ // If replyField already has some content, add a newline before our quote
+ const separator = ($replyField.val().trim() !== '' && '\n\n') || '';
+ $replyField
+ .val((a, current) => `${current}${separator}${text}\n\n`)
+ .trigger('input')
+ .trigger('change');
+
+ // Trigger autosize
+ const event = document.createEvent('Event');
+ event.initEvent('autosize:update', true, false);
+ $replyField.get(0).dispatchEvent(event);
+
+ // Focus the input field
+ $replyField.focus();
- // Focus the input field
- $replyField.focus();
+ return false;
+ })
+ .catch(() => {});
return false;
}
diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
index fa9b2c9f755..bef1553703b 100644
--- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
+++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js
@@ -8,6 +8,7 @@ export default class ShortcutsNavigation extends Shortcuts {
Mousetrap.bind('g p', () => findAndFollowLink('.shortcuts-project'));
Mousetrap.bind('g v', () => findAndFollowLink('.shortcuts-project-activity'));
+ Mousetrap.bind('g r', () => findAndFollowLink('.shortcuts-project-releases'));
Mousetrap.bind('g f', () => findAndFollowLink('.shortcuts-tree'));
Mousetrap.bind('g c', () => findAndFollowLink('.shortcuts-commits'));
Mousetrap.bind('g j', () => findAndFollowLink('.shortcuts-builds'));
diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js
index b88e69a07bf..2e537d8c000 100644
--- a/app/assets/javascripts/blob/balsamiq_viewer.js
+++ b/app/assets/javascripts/blob/balsamiq_viewer.js
@@ -1,8 +1,9 @@
import Flash from '../flash';
import BalsamiqViewer from './balsamiq/balsamiq_viewer';
+import { __ } from '~/locale';
function onError() {
- const flash = new Flash('Balsamiq file could not be loaded.');
+ const flash = new Flash(__('Balsamiq file could not be loaded.'));
return flash;
}
diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js
index cd3251ad1ca..9010cd0c3c1 100644
--- a/app/assets/javascripts/blob/blob_file_dropzone.js
+++ b/app/assets/javascripts/blob/blob_file_dropzone.js
@@ -5,6 +5,7 @@ import Dropzone from 'dropzone';
import { visitUrl } from '../lib/utils/url_utility';
import { HIDDEN_CLASS } from '../lib/utils/constants';
import csrf from '../lib/utils/csrf';
+import { sprintf, __ } from '~/locale';
Dropzone.autoDiscover = false;
@@ -73,7 +74,7 @@ export default class BlobFileDropzone {
.html(errorMessage)
.text();
$('.dropzone-alerts')
- .html(`Error uploading file: "${stripped}"`)
+ .html(sprintf(__('Error uploading file: %{stripped}'), { stripped }))
.show();
this.removeFile(file);
},
@@ -84,7 +85,7 @@ export default class BlobFileDropzone {
e.stopPropagation();
if (dropzone[0].dropzone.getQueuedFiles().length === 0) {
// eslint-disable-next-line no-alert
- alert('Please select a file');
+ alert(__('Please select a file'));
return false;
}
toggleLoading(submitButton, submitButtonLoadingIcon, true);
diff --git a/app/assets/javascripts/blob/sketch/index.js b/app/assets/javascripts/blob/sketch/index.js
index 57c1baa9886..dbff03dc734 100644
--- a/app/assets/javascripts/blob/sketch/index.js
+++ b/app/assets/javascripts/blob/sketch/index.js
@@ -1,5 +1,6 @@
import JSZip from 'jszip';
import JSZipUtils from 'jszip-utils';
+import { __ } from '~/locale';
export default class SketchLoader {
constructor(container) {
@@ -56,10 +57,10 @@ export default class SketchLoader {
const errorMsg = document.createElement('p');
errorMsg.className = 'prepend-top-default append-bottom-default text-center';
- errorMsg.textContent = `
+ errorMsg.textContent = __(`
Cannot show preview. For previews on sketch files, they must have the file format
introduced by Sketch version 43 and above.
- `;
+ `);
this.container.appendChild(errorMsg);
this.removeLoadingIcon();
diff --git a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
index 4718b642617..659d57e6a6f 100644
--- a/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
+++ b/app/assets/javascripts/blob/template_selectors/dockerfile_selector.js
@@ -1,11 +1,12 @@
import FileTemplateSelector from '../file_template_selector';
+import { __ } from '~/locale';
export default class DockerfileSelector extends FileTemplateSelector {
constructor({ mediator }) {
super(mediator);
this.config = {
key: 'dockerfile',
- name: 'Dockerfile',
+ name: __('Dockerfile'),
pattern: /(Dockerfile)/,
type: 'dockerfiles',
dropdown: '.js-dockerfile-selector',
diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js
index d0359fc5fe9..d246a1f6064 100644
--- a/app/assets/javascripts/blob/viewer/index.js
+++ b/app/assets/javascripts/blob/viewer/index.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import Flash from '../../flash';
import { handleLocationHash } from '../../lib/utils/common_utils';
import axios from '../../lib/utils/axios_utils';
+import { __ } from '~/locale';
export default class BlobViewer {
constructor() {
@@ -26,7 +27,7 @@ export default class BlobViewer {
promise
.then(module => module.default(viewer))
.catch(error => {
- Flash('Error loading file viewer.');
+ Flash(__('Error loading file viewer.'));
throw error;
});
@@ -106,16 +107,19 @@ export default class BlobViewer {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
- this.copySourceBtn.setAttribute('title', 'Copy source to clipboard');
+ this.copySourceBtn.setAttribute('title', __('Copy source to clipboard'));
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
this.copySourceBtn.setAttribute(
'title',
- 'Wait for the source to load to copy it to the clipboard',
+ __('Wait for the source to load to copy it to the clipboard'),
);
this.copySourceBtn.classList.add('disabled');
} else {
- this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard');
+ this.copySourceBtn.setAttribute(
+ 'title',
+ __('Switch to the source to copy it to the clipboard'),
+ );
this.copySourceBtn.classList.add('disabled');
}
@@ -158,7 +162,7 @@ export default class BlobViewer {
this.toggleCopyButtonState();
})
- .catch(() => new Flash('Error loading viewer'));
+ .catch(() => new Flash(__('Error loading viewer')));
}
static loadViewer(viewerParam) {
diff --git a/app/assets/javascripts/blob_edit/blob_bundle.js b/app/assets/javascripts/blob_edit/blob_bundle.js
index b07f951346e..6aaf5bf7296 100644
--- a/app/assets/javascripts/blob_edit/blob_bundle.js
+++ b/app/assets/javascripts/blob_edit/blob_bundle.js
@@ -13,9 +13,10 @@ export default () => {
if (editBlobForm.length) {
const urlRoot = editBlobForm.data('relativeUrlRoot');
const assetsPath = editBlobForm.data('assetsPrefix');
- const filePath = editBlobForm.data('blobFilename');
+ const filePath = `${editBlobForm.data('blobFilename')}`;
const currentAction = $('.js-file-title').data('currentAction');
const projectId = editBlobForm.data('project-id');
+ const isMarkdown = editBlobForm.data('is-markdown');
const commitButton = $('.js-commit-button');
const cancelLink = $('.btn.btn-cancel');
@@ -27,7 +28,13 @@ export default () => {
window.onbeforeunload = null;
});
- new EditBlob(`${urlRoot}${assetsPath}`, filePath, currentAction, projectId);
+ new EditBlob({
+ assetsPath: `${urlRoot}${assetsPath}`,
+ filePath,
+ currentAction,
+ projectId,
+ isMarkdown,
+ });
new NewCommitForm(editBlobForm);
// returning here blocks page navigation
diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js
index 6e19548eed2..011898a5e7a 100644
--- a/app/assets/javascripts/blob_edit/edit_blob.js
+++ b/app/assets/javascripts/blob_edit/edit_blob.js
@@ -6,22 +6,31 @@ import createFlash from '~/flash';
import { __ } from '~/locale';
import TemplateSelectorMediator from '../blob/file_template_mediator';
import getModeByFileExtension from '~/lib/utils/ace_utils';
+import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown';
export default class EditBlob {
- constructor(assetsPath, aceMode, currentAction, projectId) {
- this.configureAceEditor(aceMode, assetsPath);
+ // The options object has:
+ // assetsPath, filePath, currentAction, projectId, isMarkdown
+ constructor(options) {
+ this.options = options;
+ this.configureAceEditor();
this.initModePanesAndLinks();
this.initSoftWrap();
- this.initFileSelectors(currentAction, projectId);
+ this.initFileSelectors();
}
- configureAceEditor(filePath, assetsPath) {
+ configureAceEditor() {
+ const { filePath, assetsPath, isMarkdown } = this.options;
ace.config.set('modePath', `${assetsPath}/ace`);
ace.config.loadModule('ace/ext/searchbox');
ace.config.loadModule('ace/ext/modelist');
this.editor = ace.edit('editor');
+ if (isMarkdown) {
+ addEditorMarkdownListeners(this.editor);
+ }
+
// This prevents warnings re: automatic scrolling being logged
this.editor.$blockScrolling = Infinity;
@@ -32,7 +41,8 @@ export default class EditBlob {
}
}
- initFileSelectors(currentAction, projectId) {
+ initFileSelectors() {
+ const { currentAction, projectId } = this.options;
this.fileTemplateMediator = new TemplateSelectorMediator({
currentAction,
editor: this.editor,
diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js
new file mode 100644
index 00000000000..3178bda93b8
--- /dev/null
+++ b/app/assets/javascripts/boards/boards_util.js
@@ -0,0 +1,7 @@
+export function getMilestone() {
+ return null;
+}
+
+export default {
+ getMilestone,
+};
diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js
index fb6e5291a61..45b9e57f9ab 100644
--- a/app/assets/javascripts/boards/components/board.js
+++ b/app/assets/javascripts/boards/components/board.js
@@ -54,7 +54,10 @@ export default Vue.extend({
return `${n__('%d issue', '%d issues', issuesSize)}`;
},
isNewIssueShown() {
- return this.list.type === 'backlog' || (!this.disabled && this.list.type !== 'closed');
+ return (
+ this.list.type === 'backlog' ||
+ (!this.disabled && this.list.type !== 'closed' && this.list.type !== 'blank')
+ );
},
},
watch: {
diff --git a/app/assets/javascripts/boards/components/board_blank_state.vue b/app/assets/javascripts/boards/components/board_blank_state.vue
index 667eea17d44..47a46502bff 100644
--- a/app/assets/javascripts/boards/components/board_blank_state.vue
+++ b/app/assets/javascripts/boards/components/board_blank_state.vue
@@ -60,11 +60,15 @@ export default {
</script>
<template>
- <div class="board-blank-state">
+ <div class="board-blank-state p-3">
<p>Add the following default lists to your Issue Board with one click:</p>
- <ul class="board-blank-state-list">
+ <ul class="list-unstyled board-blank-state-list">
<li v-for="(label, index) in predefinedLabels" :key="index">
- <span :style="{ backgroundColor: label.color }" class="label-color"> </span>
+ <span
+ :style="{ backgroundColor: label.color }"
+ class="label-color position-relative d-inline-block rounded"
+ >
+ </span>
{{ label.title }}
</li>
</ul>
diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue
index 30fbdb9e97f..b8882203cc7 100644
--- a/app/assets/javascripts/boards/components/board_card.vue
+++ b/app/assets/javascripts/boards/components/board_card.vue
@@ -83,10 +83,10 @@ export default {
}"
:index="index"
:data-issue-id="issue.id"
- class="board-card"
+ class="board-card p-3 rounded"
@mousedown="mouseDown"
@mousemove="mouseMove"
- @mouseup="showIssue($event);"
+ @mouseup="showIssue($event)"
>
<issue-card-inner
:list="list"
diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js
index a5f9d65e4d5..a06db359c94 100644
--- a/app/assets/javascripts/boards/components/board_delete.js
+++ b/app/assets/javascripts/boards/components/board_delete.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
export default Vue.extend({
props: {
@@ -13,7 +14,7 @@ export default Vue.extend({
$(this.$el).tooltip('hide');
// eslint-disable-next-line no-alert
- if (window.confirm('Are you sure you want to delete this list?')) {
+ if (window.confirm(__('Are you sure you want to delete this list?'))) {
this.list.destroy();
}
},
diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue
index f3f341ece5c..c9972d051aa 100644
--- a/app/assets/javascripts/boards/components/board_list.vue
+++ b/app/assets/javascripts/boards/components/board_list.vue
@@ -221,7 +221,10 @@ export default {
</script>
<template>
- <div class="board-list-component">
+ <div
+ :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }"
+ class="board-list-component position-relative h-100"
+ >
<div v-if="loading" class="board-list-loading text-center" aria-label="Loading issues">
<gl-loading-icon />
</div>
@@ -236,7 +239,7 @@ export default {
:data-board="list.id"
:data-board-type="list.type"
:class="{ 'is-smaller': showIssueForm }"
- class="board-list js-board-list"
+ class="board-list w-100 h-100 list-unstyled mb-0 p-1 js-board-list"
>
<board-card
v-for="(issue, index) in issues"
diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue
index 93bcb4e129e..dc1bdc23b5e 100644
--- a/app/assets/javascripts/boards/components/board_new_issue.vue
+++ b/app/assets/javascripts/boards/components/board_new_issue.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
+import { getMilestone } from 'ee_else_ce/boards/boards_util';
import eventHub from '../eventhub';
import ProjectSelect from './project_select.vue';
import ListIssue from '../models/issue';
@@ -51,11 +52,14 @@ export default {
const labels = this.list.label ? [this.list.label] : [];
const assignees = this.list.assignee ? [this.list.assignee] : [];
+ const milestone = getMilestone(this.list);
+
const issue = new ListIssue({
title: this.title,
labels,
subscribed: true,
assignees,
+ milestone,
project_id: this.selectedProject.id,
});
@@ -95,8 +99,8 @@ export default {
<template>
<div class="board-new-issue-form">
- <div class="board-card">
- <form @submit="submit($event);">
+ <div class="board-card position-relative p-3 rounded">
+ <form @submit="submit($event)">
<div v-if="error" class="flash-container">
<div class="flash-alert">An error occurred. Please try again.</div>
</div>
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index e637e1f1223..c587b276fa3 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -2,19 +2,21 @@
import $ from 'jquery';
import Vue from 'vue';
-import Flash from '../../flash';
-import { sprintf, __ } from '../../locale';
-import Sidebar from '../../right_sidebar';
-import eventHub from '../../sidebar/event_hub';
-import AssigneeTitle from '../../sidebar/components/assignees/assignee_title.vue';
-import Assignees from '../../sidebar/components/assignees/assignees.vue';
-import DueDateSelectors from '../../due_date_select';
+import Flash from '~/flash';
+import { sprintf, __ } from '~/locale';
+import Sidebar from '~/right_sidebar';
+import eventHub from '~/sidebar/event_hub';
+import DueDateSelectors from '~/due_date_select';
+import IssuableContext from '~/issuable_context';
+import LabelsSelect from '~/labels_select';
+import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
+import Assignees from '~/sidebar/components/assignees/assignees.vue';
+import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
+import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
+import MilestoneSelect from '~/milestone_select';
import RemoveBtn from './sidebar/remove_issue.vue';
-import IssuableContext from '../../issuable_context';
-import LabelsSelect from '../../labels_select';
-import Subscriptions from '../../sidebar/components/subscriptions/subscriptions.vue';
-import MilestoneSelect from '../../milestone_select';
import boardsStore from '../stores/boards_store';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default Vue.extend({
components: {
@@ -22,6 +24,7 @@ export default Vue.extend({
Assignees,
RemoveBtn,
Subscriptions,
+ TimeTracker,
},
props: {
currentUser: {
@@ -42,7 +45,7 @@ export default Vue.extend({
return Object.keys(this.issue).length;
},
milestoneTitle() {
- return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
+ return this.issue.milestone ? this.issue.milestone.title : __('No Milestone');
},
canRemove() {
return !this.list.preset;
@@ -138,5 +141,11 @@ export default Vue.extend({
Flash(__('An error occurred while saving assignees'));
});
},
+ showScopedLabels(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
+ helpLink() {
+ return boardsStore.scopedLabels.helpLink;
+ },
},
});
diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue
index 0f581c3d37d..17de7b2cf1e 100644
--- a/app/assets/javascripts/boards/components/issue_card_inner.vue
+++ b/app/assets/javascripts/boards/components/issue_card_inner.vue
@@ -1,13 +1,17 @@
<script>
+import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import eventHub from '../eventhub';
import IssueDueDate from './issue_due_date.vue';
import IssueTimeEstimate from './issue_time_estimate.vue';
import boardsStore from '../stores/boards_store';
+import IssueCardInnerScopedLabel from './issue_card_inner_scoped_label.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
components: {
@@ -16,10 +20,13 @@ export default {
TooltipOnTruncate,
IssueDueDate,
IssueTimeEstimate,
+ IssueCardWeight: () => import('ee_component/boards/components/issue_card_weight.vue'),
+ IssueCardInnerScopedLabel,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [issueCardInner],
props: {
issue: {
type: Object,
@@ -92,6 +99,12 @@ export default {
const { referencePath, groupId } = this.issue;
return !groupId ? referencePath.split('#')[0] : null;
},
+ orderedLabels() {
+ return _.sortBy(this.issue.labels, 'title');
+ },
+ helpLink() {
+ return boardsStore.scopedLabels.helpLink;
+ },
},
methods: {
isIndexLessThanlimit(index) {
@@ -125,14 +138,6 @@ export default {
this.applyFilter(filter);
},
- filterByWeight(weight) {
- if (!this.updateFilters) return;
-
- const issueWeight = encodeURIComponent(weight);
- const filter = `weight=${issueWeight}`;
-
- this.applyFilter(filter);
- },
applyFilter(filter) {
const filterPath = boardsStore.filter.path.split('&');
const filterIndex = filterPath.indexOf(filter);
@@ -155,12 +160,15 @@ export default {
color: label.textColor,
};
},
+ showScopedLabel(label) {
+ return boardsStore.scopedLabels.enabled && isScopedLabel(label);
+ },
},
};
</script>
<template>
<div>
- <div class="board-card-header">
+ <div class="d-flex board-card-header" dir="auto">
<h4 class="board-card-title append-bottom-0 prepend-top-0">
<icon
v-if="issue.confidential"
@@ -175,27 +183,37 @@ export default {
</h4>
</div>
<div v-if="showLabelFooter" class="board-card-labels prepend-top-4 d-flex flex-wrap">
- <button
- v-for="label in issue.labels"
- v-if="showLabel(label)"
- :key="label.id"
- v-gl-tooltip
- :style="labelStyle(label)"
- :title="label.description"
- class="badge color-label append-right-4 prepend-top-4"
- type="button"
- @click="filterByLabel(label);"
- >
- {{ label.title }}
- </button>
+ <template v-for="label in orderedLabels" v-if="showLabel(label)">
+ <issue-card-inner-scoped-label
+ v-if="showScopedLabel(label)"
+ :key="label.id"
+ :label="label"
+ :label-style="labelStyle(label)"
+ :scoped-labels-documentation-link="helpLink"
+ @scoped-label-click="filterByLabel($event)"
+ />
+
+ <button
+ v-else
+ :key="label.id"
+ v-gl-tooltip
+ :style="labelStyle(label)"
+ :title="label.description"
+ class="badge color-label append-right-4 prepend-top-4"
+ type="button"
+ @click="filterByLabel(label)"
+ >
+ {{ label.title }}
+ </button>
+ </template>
</div>
<div class="board-card-footer d-flex justify-content-between align-items-end">
<div
- class="d-flex align-items-start flex-wrap-reverse board-card-number-container js-board-card-number-container"
+ class="d-flex align-items-start flex-wrap-reverse board-card-number-container overflow-hidden js-board-card-number-container"
>
<span
v-if="issue.referencePath"
- class="board-card-number d-flex append-right-8 prepend-top-8"
+ class="board-card-number overflow-hidden d-flex append-right-8 prepend-top-8"
>
<tooltip-on-truncate
v-if="issueReferencePath"
@@ -209,10 +227,14 @@ export default {
<issue-due-date v-if="issue.dueDate" :date="issue.dueDate" /><issue-time-estimate
v-if="issue.timeEstimate"
:estimate="issue.timeEstimate"
+ /><issue-card-weight
+ v-if="issue.weight"
+ :weight="issue.weight"
+ @click="filterByWeight(issue.weight)"
/>
</span>
</div>
- <div class="board-card-assignee">
+ <div class="board-card-assignee d-flex">
<user-avatar-link
v-for="(assignee, index) in issue.assignees"
v-if="shouldRenderAssignee(index)"
diff --git a/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue
new file mode 100644
index 00000000000..fa4c68964cb
--- /dev/null
+++ b/app/assets/javascripts/boards/components/issue_card_inner_scoped_label.vue
@@ -0,0 +1,45 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span
+ class="d-inline-block position-relative scoped-label-wrapper append-right-4 prepend-top-4 board-label"
+ >
+ <a @click="$emit('scoped-label-click', label)">
+ <span :ref="'labelTitleRef'" :style="labelStyle" class="badge label color-label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
+ <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
+ ><br />
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+
+ <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
+ ><i class="fa fa-question-circle" :style="labelStyle"></i
+ ></gl-link>
+ </span>
+</template>
diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue
index e038198e6f0..3bc7f13a9e6 100644
--- a/app/assets/javascripts/boards/components/issue_due_date.vue
+++ b/app/assets/javascripts/boards/components/issue_due_date.vue
@@ -3,7 +3,12 @@ import dateFormat from 'dateformat';
import { GlTooltip } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
-import { getDayDifference, getTimeago, dateInWords } from '~/lib/utils/datetime_utility';
+import {
+ getDayDifference,
+ getTimeago,
+ dateInWords,
+ parsePikadayDate,
+} from '~/lib/utils/datetime_utility';
export default {
components: {
@@ -48,13 +53,13 @@ export default {
} else if (timeDifference === -1) {
return __('Yesterday');
} else if (timeDifference > 0 && timeDifference < 7) {
- return dateFormat(issueDueDate, 'dddd', true);
+ return dateFormat(issueDueDate, 'dddd');
}
return standardDateFormat;
},
issueDueDate() {
- return new Date(this.date);
+ return parsePikadayDate(this.date);
},
timeDifference() {
const today = new Date();
@@ -77,7 +82,11 @@ export default {
<template>
<span>
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
- <icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
+ <icon
+ :class="{ 'text-danger': isPastDue }"
+ class="board-card-info-icon align-top"
+ name="calendar"
+ />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue
index 5acc3025b2c..98c1d29db16 100644
--- a/app/assets/javascripts/boards/components/issue_time_estimate.vue
+++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue
@@ -28,7 +28,7 @@ export default {
<template>
<span>
<span ref="issueTimeEstimate" class="board-card-info card-number">
- <icon name="hourglass" css-classes="board-card-info-icon" /><time
+ <icon name="hourglass" css-classes="board-card-info-icon align-top" /><time
class="board-card-info-text"
>{{ timeEstimate }}</time
>
diff --git a/app/assets/javascripts/boards/components/modal/empty_state.vue b/app/assets/javascripts/boards/components/modal/empty_state.vue
index 08408eb0b52..091700de93f 100644
--- a/app/assets/javascripts/boards/components/modal/empty_state.vue
+++ b/app/assets/javascripts/boards/components/modal/empty_state.vue
@@ -42,10 +42,10 @@ export default {
</script>
<template>
- <section class="empty-state">
- <div class="row">
+ <section class="empty-state d-flex mt-0 h-100">
+ <div class="row w-100 my-auto mx-0">
<div class="col-12 col-md-6 order-md-last">
- <aside class="svg-content"><img :src="emptyStateSvg" /></aside>
+ <aside class="svg-content d-none d-md-block"><img :src="emptyStateSvg" /></aside>
</div>
<div class="col-12 col-md-6 order-md-first">
<div class="text-content">
@@ -58,7 +58,7 @@ export default {
v-if="activeTab === 'selected'"
class="btn btn-default"
type="button"
- @click="changeTab('all');"
+ @click="changeTab('all')"
>
Open issues
</button>
diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue
index b1bc7d87086..d4afd9d59da 100644
--- a/app/assets/javascripts/boards/components/modal/footer.vue
+++ b/app/assets/javascripts/boards/components/modal/footer.vue
@@ -71,7 +71,7 @@ export default {
<span class="inline add-issues-footer-to-list"> to list </span>
<lists-dropdown />
</div>
- <button class="btn btn-default float-right" type="button" @click="toggleModal(false);">
+ <button class="btn btn-default float-right" type="button" @click="toggleModal(false)">
Cancel
</button>
</footer>
diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue
index d0e285a149e..1cfa6d39362 100644
--- a/app/assets/javascripts/boards/components/modal/header.vue
+++ b/app/assets/javascripts/boards/components/modal/header.vue
@@ -50,22 +50,22 @@ export default {
</script>
<template>
<div>
- <header class="add-issues-header form-actions">
- <h2>
+ <header class="add-issues-header border-top-0 form-actions">
+ <h2 class="m-0">
Add issues
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
- @click="toggleModal(false);"
+ @click="toggleModal(false)"
>
<span aria-hidden="true">×</span>
</button>
</h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0" />
- <div v-if="showSearch" class="add-issues-search append-bottom-10">
+ <div v-if="showSearch" class="d-flex append-bottom-10">
<modal-filters :store="filter" />
<button
ref="selectAllBtn"
diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue
index 1e5761cf268..8e09e265cfb 100644
--- a/app/assets/javascripts/boards/components/modal/index.vue
+++ b/app/assets/javascripts/boards/components/modal/index.vue
@@ -143,8 +143,11 @@ export default {
};
</script>
<template>
- <div v-if="showAddIssuesModal" class="add-issues-modal">
- <div class="add-issues-container">
+ <div
+ v-if="showAddIssuesModal"
+ class="add-issues-modal d-flex position-fixed position-top-0 position-bottom-0 position-left-0 position-right-0 h-100"
+ >
+ <div class="add-issues-container d-flex flex-column m-auto rounded">
<modal-header
:project-id="projectId"
:milestone-path="milestonePath"
@@ -161,8 +164,10 @@ export default {
:new-issue-path="newIssuePath"
:empty-state-svg="emptyStateSvg"
/>
- <section v-if="loading || filterLoading" class="add-issues-list text-center">
- <div class="add-issues-list-loading"><gl-loading-icon /></div>
+ <section v-if="loading || filterLoading" class="add-issues-list d-flex h-100 text-center">
+ <div class="add-issues-list-loading w-100 align-self-center">
+ <gl-loading-icon size="md" />
+ </div>
</section>
<modal-footer />
</div>
diff --git a/app/assets/javascripts/boards/components/modal/list.vue b/app/assets/javascripts/boards/components/modal/list.vue
index 878bb002c6c..28d2019af2f 100644
--- a/app/assets/javascripts/boards/components/modal/list.vue
+++ b/app/assets/javascripts/boards/components/modal/list.vue
@@ -117,7 +117,7 @@ export default {
};
</script>
<template>
- <section ref="list" class="add-issues-list add-issues-list-columns">
+ <section ref="list" class="add-issues-list add-issues-list-columns d-flex h-100">
<div
v-if="issuesCount > 0 && issues.length === 0"
class="empty-state add-issues-empty-state-filter text-center"
@@ -129,8 +129,8 @@ export default {
<div v-for="issue in group" v-if="showIssue(issue)" :key="issue.id" class="board-card-parent">
<div
:class="{ 'is-active': issue.selected }"
- class="board-card"
- @click="toggleIssue($event, issue);"
+ class="board-card position-relative p-3 rounded"
+ @click="toggleIssue($event, issue)"
>
<issue-card-inner :issue="issue" :issue-link-base="issueLinkBase" :root-path="rootPath" />
<icon
diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
index 820d0679df5..3fbe8fe1be7 100644
--- a/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
+++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.vue
@@ -38,7 +38,7 @@ export default {
:class="{ 'is-active': list.id == selected.id }"
href="#"
role="button"
- @click.prevent="modal.selectedList = list;"
+ @click.prevent="modal.selectedList = list"
>
<span :style="{ backgroundColor: list.label.color }" class="dropdown-label-box"> </span>
{{ list.title }}
diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue
index 7b800a6ab97..2d2920e312e 100644
--- a/app/assets/javascripts/boards/components/modal/tabs.vue
+++ b/app/assets/javascripts/boards/components/modal/tabs.vue
@@ -21,12 +21,12 @@ export default {
<div class="top-area prepend-top-10 append-bottom-10">
<ul class="nav-links issues-state-filters">
<li :class="{ active: activeTab == 'all' }">
- <a href="#" role="button" @click.prevent="changeTab('all');">
+ <a href="#" role="button" @click.prevent="changeTab('all')">
Open issues <span class="badge badge-pill"> {{ issuesCount }} </span>
</a>
</li>
<li :class="{ active: activeTab == 'selected' }">
- <a href="#" role="button" @click.prevent="changeTab('selected');">
+ <a href="#" role="button" @click.prevent="changeTab('selected')">
Selected issues <span class="badge badge-pill"> {{ selectedCount }} </span>
</a>
</li>
diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js
index 10577da9305..a5ed695af35 100644
--- a/app/assets/javascripts/boards/components/new_list_dropdown.js
+++ b/app/assets/javascripts/boards/components/new_list_dropdown.js
@@ -8,7 +8,11 @@ import boardsStore from '../stores/boards_store';
$(document)
.off('created.label')
- .on('created.label', (e, label) => {
+ .on('created.label', (e, label, addNewList) => {
+ if (!addNewList) {
+ return;
+ }
+
boardsStore.new({
title: label.title,
position: boardsStore.state.lists.length - 2,
diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue
index d899b7fbd8c..8274647744f 100644
--- a/app/assets/javascripts/boards/components/project_select.vue
+++ b/app/assets/javascripts/boards/components/project_select.vue
@@ -82,7 +82,7 @@ export default {
<template>
<div>
<label class="label-bold prepend-top-10"> Project </label>
- <div ref="projectsDropdown" class="dropdown">
+ <div ref="projectsDropdown" class="dropdown dropdown-projects">
<button
class="dropdown-menu-toggle wide"
type="button"
diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js
index c14d69c5d18..6b54e8baefb 100644
--- a/app/assets/javascripts/boards/filtered_search_boards.js
+++ b/app/assets/javascripts/boards/filtered_search_boards.js
@@ -1,6 +1,8 @@
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container';
import FilteredSearchManager from '../filtered_search/filtered_search_manager';
import boardsStore from './stores/boards_store';
+import { isEE } from '~/lib/utils/common_utils';
export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
@@ -8,6 +10,8 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
page: 'boards',
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
+ isGroup: isEE(),
+ filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
this.store = store;
diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js
index f88e9b55988..4995a8d9367 100644
--- a/app/assets/javascripts/boards/index.js
+++ b/app/assets/javascripts/boards/index.js
@@ -24,7 +24,11 @@ import BoardSidebar from './components/board_sidebar';
import initNewListDropdown from './components/new_list_dropdown';
import BoardAddIssuesModal from './components/modal/index.vue';
import '~/vue_shared/vue_resource_interceptor';
-import { NavigationType, parseBoolean } from '~/lib/utils/common_utils';
+import {
+ NavigationType,
+ convertObjectPropsToCamelCase,
+ parseBoolean,
+} from '~/lib/utils/common_utils';
let issueBoardsApp;
@@ -58,6 +62,7 @@ export default () => {
state: boardsStore.state,
loading: true,
boardsEndpoint: $boardApp.dataset.boardsEndpoint,
+ recentBoardsEndpoint: $boardApp.dataset.recentBoardsEndpoint,
listsEndpoint: $boardApp.dataset.listsEndpoint,
boardId: $boardApp.dataset.boardId,
disabled: parseBoolean($boardApp.dataset.disabled),
@@ -75,6 +80,7 @@ export default () => {
created() {
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
+ recentBoardsEndpoint: this.recentBoardsEndpoint,
listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId,
@@ -117,7 +123,7 @@ export default () => {
this.loading = false;
})
.catch(() => {
- Flash('An error occurred while fetching the board lists. Please try again.');
+ Flash(__('An error occurred while fetching the board lists. Please try again.'));
});
},
methods: {
@@ -131,9 +137,25 @@ export default () => {
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data)
.then(data => {
+ const {
+ subscribed,
+ totalTimeSpent,
+ timeEstimate,
+ humanTimeEstimate,
+ humanTotalTimeSpent,
+ weight,
+ epic,
+ } = convertObjectPropsToCamelCase(data);
+
newIssue.setFetchingState('subscriptions', false);
newIssue.updateData({
- subscribed: data.subscribed,
+ humanTimeSpent: humanTotalTimeSpent,
+ timeSpent: totalTimeSpent,
+ humanTimeEstimate,
+ timeEstimate,
+ subscribed,
+ weight,
+ epic,
});
})
.catch(() => {
@@ -201,7 +223,7 @@ export default () => {
},
tooltipTitle() {
if (this.disabled) {
- return 'Please add a list to your board first';
+ return __('Please add a list to your board first');
}
return '';
diff --git a/app/assets/javascripts/boards/mixins/issue_card_inner.js b/app/assets/javascripts/boards/mixins/issue_card_inner.js
new file mode 100644
index 00000000000..8000237da6d
--- /dev/null
+++ b/app/assets/javascripts/boards/mixins/issue_card_inner.js
@@ -0,0 +1,5 @@
+export default {
+ methods: {
+ filterByWeight() {},
+ },
+};
diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js
index dd92d3c8552..f8ff20cb0cd 100644
--- a/app/assets/javascripts/boards/models/issue.js
+++ b/app/assets/javascripts/boards/models/issue.js
@@ -5,6 +5,7 @@
import Vue from 'vue';
import '~/vue_shared/models/label';
+import { isEE, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import IssueProject from './project';
import boardsStore from '../stores/boards_store';
@@ -28,7 +29,6 @@ class ListIssue {
this.referencePath = obj.reference_path;
this.path = obj.real_path;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
- this.milestone_id = obj.milestone_id;
this.project_id = obj.project_id;
this.timeEstimate = obj.time_estimate;
this.assignableLabelsEndpoint = obj.assignable_labels_endpoint;
@@ -39,6 +39,7 @@ class ListIssue {
if (obj.milestone) {
this.milestone = new ListMilestone(obj.milestone);
+ this.milestone_id = obj.milestone.id;
}
obj.labels.forEach(label => {
@@ -88,6 +89,19 @@ class ListIssue {
this.assignees = [];
}
+ addMilestone(milestone) {
+ const miletoneId = this.milestone ? this.milestone.id : null;
+ if (isEE && milestone.id !== miletoneId) {
+ this.milestone = new ListMilestone(milestone);
+ }
+ }
+
+ removeMilestone(removeMilestone) {
+ if (isEE && removeMilestone && removeMilestone.id === this.milestone.id) {
+ this.milestone = {};
+ }
+ }
+
getLists() {
return boardsStore.state.lists.filter(list => list.findIssue(this.id));
}
@@ -119,7 +133,17 @@ class ListIssue {
}
const projectPath = this.project ? this.project.path : '';
- return Vue.http.patch(`${this.path}.json`, data);
+ return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => {
+ /**
+ * Since post implementation of Scoped labels, server can reject
+ * same key-ed labels. To keep the UI and server Model consistent,
+ * we're just assigning labels that server echo's back to us when we
+ * PATCH the said object.
+ */
+ if (body) {
+ this.labels = convertObjectPropsToCamelCase(body.labels, { deep: true });
+ }
+ });
}
}
diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js
index dd3feedbc0e..7e5d0e0f888 100644
--- a/app/assets/javascripts/boards/models/list.js
+++ b/app/assets/javascripts/boards/models/list.js
@@ -4,8 +4,9 @@
import { __ } from '~/locale';
import ListLabel from '~/vue_shared/models/label';
import ListAssignee from '~/vue_shared/models/assignee';
-import { urlParamsToObject } from '~/lib/utils/common_utils';
+import { isEE, urlParamsToObject } from '~/lib/utils/common_utils';
import boardsStore from '../stores/boards_store';
+import ListMilestone from './milestone';
const PER_PAGE = 20;
@@ -51,6 +52,9 @@ class List {
} else if (obj.user) {
this.assignee = new ListAssignee(obj.user);
this.title = this.assignee.name;
+ } else if (isEE && obj.milestone) {
+ this.milestone = new ListMilestone(obj.milestone);
+ this.title = this.milestone.title;
}
if (!typeInfo.isBlank && this.id) {
@@ -69,12 +73,14 @@ class List {
}
save() {
- const entity = this.label || this.assignee;
+ const entity = this.label || this.assignee || this.milestone;
let entityType = '';
if (this.label) {
entityType = 'label_id';
- } else {
+ } else if (this.assignee) {
entityType = 'assignee_id';
+ } else if (isEE && this.milestone) {
+ entityType = 'milestone_id';
}
return gl.boardService
@@ -84,6 +90,7 @@ class List {
this.id = data.id;
this.type = data.list_type;
this.position = data.position;
+ this.label = data.label;
return this.getIssues();
});
@@ -192,6 +199,13 @@ class List {
issue.addAssignee(this.assignee);
}
+ if (isEE && this.milestone) {
+ if (listFrom && listFrom.type === 'milestone') {
+ issue.removeMilestone(listFrom.milestone);
+ }
+ issue.addMilestone(this.milestone);
+ }
+
if (listFrom) {
this.issuesSize += 1;
@@ -244,6 +258,7 @@ class List {
issue.project = data.project;
issue.path = data.real_path;
issue.referencePath = data.reference_path;
+ issue.assignableLabelsEndpoint = data.assignable_labels_endpoint;
if (this.issuesSize > 1) {
const moveBeforeId = this.issues[1].id;
diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js
index 17d15278a74..6f81d6bc6f8 100644
--- a/app/assets/javascripts/boards/models/milestone.js
+++ b/app/assets/javascripts/boards/models/milestone.js
@@ -1,7 +1,16 @@
-class ListMilestone {
+import { isEE } from '~/lib/utils/common_utils';
+
+export default class ListMilestone {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
+
+ if (isEE) {
+ this.path = obj.path;
+ this.state = obj.state;
+ this.webUrl = obj.web_url || obj.webUrl;
+ this.description = obj.description;
+ }
}
}
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
index 3de6eb056c2..7d463f17ab1 100644
--- a/app/assets/javascripts/boards/services/board_service.js
+++ b/app/assets/javascripts/boards/services/board_service.js
@@ -2,12 +2,13 @@ import axios from '../../lib/utils/axios_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
export default class BoardService {
- constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId }) {
+ constructor({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
this.boardsEndpoint = boardsEndpoint;
this.boardId = boardId;
this.listsEndpoint = listsEndpoint;
this.listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.bulkUpdatePath = bulkUpdatePath;
+ this.recentBoardsEndpoint = `${recentBoardsEndpoint}.json`;
}
generateBoardsPath(id) {
diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js
new file mode 100644
index 00000000000..51565c597e6
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/actions.js
@@ -0,0 +1,67 @@
+import { __ } from '~/locale';
+
+const notImplemented = () => {
+ throw new Error(__('Not implemented!'));
+};
+
+export default {
+ setEndpoints: () => {
+ notImplemented();
+ },
+
+ fetchLists: () => {
+ notImplemented();
+ },
+
+ generateDefaultLists: () => {
+ notImplemented();
+ },
+
+ createList: () => {
+ notImplemented();
+ },
+
+ updateList: () => {
+ notImplemented();
+ },
+
+ deleteList: () => {
+ notImplemented();
+ },
+
+ fetchIssuesForList: () => {
+ notImplemented();
+ },
+
+ moveIssue: () => {
+ notImplemented();
+ },
+
+ createNewIssue: () => {
+ notImplemented();
+ },
+
+ fetchBacklog: () => {
+ notImplemented();
+ },
+
+ bulkUpdateIssues: () => {
+ notImplemented();
+ },
+
+ fetchIssue: () => {
+ notImplemented();
+ },
+
+ toggleIssueSubscription: () => {
+ notImplemented();
+ },
+
+ showPage: () => {
+ notImplemented();
+ },
+
+ toggleEmptyState: () => {
+ notImplemented();
+ },
+};
diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js
index 802796208c2..70861fbf2b3 100644
--- a/app/assets/javascripts/boards/stores/boards_store.js
+++ b/app/assets/javascripts/boards/stores/boards_store.js
@@ -5,14 +5,26 @@ import $ from 'jquery';
import _ from 'underscore';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import BoardsStoreEE from 'ee_else_ce/boards/stores/boards_store_ee';
import { getUrlParamsArray, parseBoolean } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
const boardsStore = {
disabled: false,
+ scopedLabels: {
+ helpLink: '',
+ enabled: false,
+ },
filter: {
path: '',
},
- state: {},
+ state: {
+ currentBoard: {
+ labels: [],
+ },
+ currentPage: '',
+ reload: false,
+ },
detail: {
issue: {},
},
@@ -27,6 +39,10 @@ const boardsStore = {
issue: {},
};
},
+ showPage(page) {
+ this.state.reload = false;
+ this.state.currentPage = page;
+ },
addList(listObj, defaultAvatar) {
const list = new List(listObj, defaultAvatar);
this.state.lists.push(list);
@@ -63,7 +79,7 @@ const boardsStore = {
this.addList({
id: 'blank',
list_type: 'blank',
- title: 'Welcome to your Issue Board!',
+ title: __('Welcome to your Issue Board!'),
position: 0,
});
@@ -174,6 +190,8 @@ const boardsStore = {
},
};
+BoardsStoreEE.initEESpecific(boardsStore);
+
// hacks added in order to allow milestone_select to function properly
// TODO: remove these
diff --git a/app/assets/javascripts/boards/stores/boards_store_ee.js b/app/assets/javascripts/boards/stores/boards_store_ee.js
new file mode 100644
index 00000000000..09e3a938fbe
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/boards_store_ee.js
@@ -0,0 +1,5 @@
+// this is just to make ee_else_ce happy and will be cleaned up in https://gitlab.com/gitlab-org/gitlab-ce/issues/59807
+
+export default {
+ initEESpecific() {},
+};
diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js
new file mode 100644
index 00000000000..f70395a3771
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from 'ee_else_ce/boards/stores/state';
+import actions from 'ee_else_ce/boards/stores/actions';
+import mutations from 'ee_else_ce/boards/stores/mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state,
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js
new file mode 100644
index 00000000000..fcdfa6799b6
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/mutation_types.js
@@ -0,0 +1,21 @@
+export const SET_ENDPOINTS = 'SET_ENDPOINTS';
+export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST';
+export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS';
+export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR';
+export const REQUEST_UPDATE_LIST = 'REQUEST_UPDATE_LIST';
+export const RECEIVE_UPDATE_LIST_SUCCESS = 'RECEIVE_UPDATE_LIST_SUCCESS';
+export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR';
+export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST';
+export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS';
+export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR';
+export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE';
+export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS';
+export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR';
+export const REQUEST_MOVE_ISSUE = 'REQUEST_MOVE_ISSUE';
+export const RECEIVE_MOVE_ISSUE_SUCCESS = 'RECEIVE_MOVE_ISSUE_SUCCESS';
+export const RECEIVE_MOVE_ISSUE_ERROR = 'RECEIVE_MOVE_ISSUE_ERROR';
+export const REQUEST_UPDATE_ISSUE = 'REQUEST_UPDATE_ISSUE';
+export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS';
+export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR';
+export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE';
+export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE';
diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js
new file mode 100644
index 00000000000..8e61b93e824
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/mutations.js
@@ -0,0 +1,92 @@
+import * as mutationTypes from './mutation_types';
+import { __ } from '~/locale';
+
+const notImplemented = () => {
+ throw new Error(__('Not implemented!'));
+};
+
+export default {
+ [mutationTypes.SET_ENDPOINTS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_ADD_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_UPDATE_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_REMOVE_LIST]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_REMOVE_LIST_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_REMOVE_LIST_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_ADD_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_ADD_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_MOVE_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_MOVE_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_MOVE_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.REQUEST_UPDATE_ISSUE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_ISSUE_SUCCESS]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.RECEIVE_UPDATE_ISSUE_ERROR]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.SET_CURRENT_PAGE]: () => {
+ notImplemented();
+ },
+
+ [mutationTypes.TOGGLE_EMPTY_STATE]: () => {
+ notImplemented();
+ },
+};
diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js
new file mode 100644
index 00000000000..dd16abb01a5
--- /dev/null
+++ b/app/assets/javascripts/boards/stores/state.js
@@ -0,0 +1,3 @@
+export default () => ({
+ // ...
+});
diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js
index 7951348d8b2..93aacba0e8e 100644
--- a/app/assets/javascripts/breakpoints.js
+++ b/app/assets/javascripts/breakpoints.js
@@ -14,6 +14,9 @@ const BreakpointInstance = {
return breakpoint;
},
+ isDesktop() {
+ return ['lg', 'md'].includes(this.getBreakpointSize());
+ },
};
export default BreakpointInstance;
diff --git a/app/assets/javascripts/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
index ee0f7cda189..0390a3bf96a 100644
--- a/app/assets/javascripts/ci_variable_list/ci_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/ci_variable_list.js
@@ -26,6 +26,10 @@ export default class VariableList {
selector: '.js-ci-variable-input-id',
default: '',
},
+ variable_type: {
+ selector: '.js-ci-variable-input-variable-type',
+ default: 'env_var',
+ },
key: {
selector: '.js-ci-variable-input-key',
default: '',
@@ -36,7 +40,15 @@ export default class VariableList {
},
protected: {
selector: '.js-ci-variable-input-protected',
- default: 'false',
+ // use `attr` instead of `data` as we don't want the value to be
+ // converted. we need the value as a string.
+ default: $('.js-ci-variable-input-protected').attr('data-default'),
+ },
+ masked: {
+ selector: '.js-ci-variable-input-masked',
+ // use `attr` instead of `data` as we don't want the value to be
+ // converted. we need the value as a string.
+ default: $('.js-ci-variable-input-masked').attr('data-default'),
},
environment_scope: {
// We can't use a `.js-` class here because
@@ -86,13 +98,16 @@ export default class VariableList {
}
});
- // Always make sure there is an empty last row
- this.$container.on('input trigger-change', inputSelector, () => {
+ this.$container.on('input trigger-change', inputSelector, e => {
+ // Always make sure there is an empty last row
const $lastRow = this.$container.find('.js-row').last();
if (this.checkIfRowTouched($lastRow)) {
this.insertRow($lastRow);
}
+
+ // If masked, validate value against regex
+ this.validateMaskability($(e.currentTarget).closest('.js-row'));
});
}
@@ -169,12 +184,33 @@ export default class VariableList {
checkIfRowTouched($row) {
return Object.keys(this.inputMap).some(name => {
+ // Row should not qualify as touched if only switches have been touched
+ if (['protected', 'masked'].includes(name)) return false;
+
const entry = this.inputMap[name];
const $el = $row.find(entry.selector);
return $el.length && $el.val() !== entry.default;
});
}
+ validateMaskability($row) {
+ const invalidInputClass = 'gl-field-error-outline';
+
+ const maskableRegex = /^\w{8,}$/; // Eight or more alphanumeric characters plus underscores
+ const variableValue = $row.find(this.inputMap.secret_value.selector).val();
+ const isValueMaskable = maskableRegex.test(variableValue) || variableValue === '';
+ const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true';
+
+ // Show a validation error if the user wants to mask an unmaskable variable value
+ $row
+ .find(this.inputMap.secret_value.selector)
+ .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
+ $row
+ .find('.js-secret-value-placeholder')
+ .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable);
+ $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable);
+ }
+
toggleEnableRow(isEnabled = true) {
this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled);
this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled);
diff --git a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
index e7111c666a2..fdbefd8c313 100644
--- a/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
+++ b/app/assets/javascripts/ci_variable_list/native_form_variable_list.js
@@ -19,6 +19,7 @@ export default function setupNativeFormVariableList({ container, formField = 'va
const isTouched = variableList.checkIfRowTouched($lastRow);
if (!isTouched) {
$lastRow.find('input, textarea').attr('name', '');
+ $lastRow.find('select').attr('name', '');
}
});
}
diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js
index cf70a48f076..70af333a0dd 100644
--- a/app/assets/javascripts/clusters/clusters_bundle.js
+++ b/app/assets/javascripts/clusters/clusters_bundle.js
@@ -1,17 +1,21 @@
import Visibility from 'visibilityjs';
import Vue from 'vue';
-import initDismissableCallout from '~/dismissable_callout';
+import AccessorUtilities from '~/lib/utils/accessor';
+import { GlToast } from '@gitlab/ui';
+import PersistentUserCallout from '../persistent_user_callout';
import { s__, sprintf } from '../locale';
import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
-import { APPLICATION_STATUS, REQUEST_LOADING, REQUEST_SUCCESS, REQUEST_FAILURE } from './constants';
+import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
import setupToggleButtons from '../toggle_buttons';
+Vue.use(GlToast);
+
/**
* Cluster page has 2 separate parts:
* Toggle button and applications section
@@ -30,21 +34,26 @@ export default class Clusters {
installRunnerPath,
installJupyterPath,
installKnativePath,
+ updateKnativePath,
installPrometheusPath,
managePrometheusPath,
+ hasRbac,
clusterType,
clusterStatus,
clusterStatusReason,
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
+ clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
+ this.clusterId = clusterId;
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
+ this.store.updateRbac(hasRbac);
this.service = new ClustersService({
endpoint: statusPath,
installHelmEndpoint: installHelmPath,
@@ -54,6 +63,7 @@ export default class Clusters {
installPrometheusEndpoint: installPrometheusPath,
installJupyterEndpoint: installJupyterPath,
installKnativeEndpoint: installKnativePath,
+ updateKnativeEndpoint: updateKnativePath,
});
this.installApplication = this.installApplication.bind(this);
@@ -62,12 +72,20 @@ export default class Clusters {
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
+ this.unreachableContainer = document.querySelector('.js-cluster-api-unreachable');
+ this.authenticationFailureContainer = document.querySelector(
+ '.js-cluster-authentication-failure',
+ );
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
this.showTokenButton = document.querySelector('.js-show-cluster-token');
this.tokenField = document.querySelector('.js-cluster-token');
+ this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text');
+ this.ingressDomainSnippet = this.ingressDomainHelpText.querySelector(
+ '.js-ingress-domain-snippet',
+ );
- initDismissableCallout('.js-cluster-security-warning');
+ Clusters.initDismissableCallout();
initSettingsPanels();
setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
this.initApplications(clusterType);
@@ -102,20 +120,46 @@ export default class Clusters {
ingressHelpPath: this.state.ingressHelpPath,
managePrometheusPath: this.state.managePrometheusPath,
ingressDnsHelpPath: this.state.ingressDnsHelpPath,
+ rbac: this.state.rbac,
},
});
},
});
}
+ static initDismissableCallout() {
+ const callout = document.querySelector('.js-cluster-security-warning');
+ PersistentUserCallout.factory(callout);
+ }
+
+ addBannerCloseHandler(el, status) {
+ el.querySelector('.js-close-banner').addEventListener('click', () => {
+ el.classList.add('hidden');
+ this.setBannerDismissedState(status, true);
+ });
+ }
+
addListeners() {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
+ eventHub.$on('upgradeApplication', data => this.upgradeApplication(data));
+ eventHub.$on('dismissUpgradeSuccess', appId => this.dismissUpgradeSuccess(appId));
+ eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
+ eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
+ eventHub.$on('uninstallApplication', data => this.uninstallApplication(data));
+ // Add event listener to all the banner close buttons
+ this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
+ this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
}
removeListeners() {
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
eventHub.$off('installApplication', this.installApplication);
+ eventHub.$off('upgradeApplication', this.upgradeApplication);
+ eventHub.$off('dismissUpgradeSuccess', this.dismissUpgradeSuccess);
+ eventHub.$off('saveKnativeDomain');
+ eventHub.$off('setKnativeHostname');
+ eventHub.$off('uninstallApplication');
}
initPolling() {
@@ -156,6 +200,10 @@ export default class Clusters {
this.checkForNewInstalls(prevApplicationMap, this.store.state.applications);
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
+ this.toggleIngressDomainHelpText(
+ prevApplicationMap[INGRESS],
+ this.store.state.applications[INGRESS],
+ );
}
showToken() {
@@ -174,6 +222,8 @@ export default class Clusters {
this.errorContainer.classList.add('hidden');
this.successContainer.classList.add('hidden');
this.creatingContainer.classList.add('hidden');
+ this.unreachableContainer.classList.add('hidden');
+ this.authenticationFailureContainer.classList.add('hidden');
}
checkForNewInstalls(prevApplicationMap, newApplicationMap) {
@@ -197,9 +247,32 @@ export default class Clusters {
}
}
+ setBannerDismissedState(status, isDismissed) {
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ window.localStorage.setItem(
+ `cluster_${this.clusterId}_banner_dismissed`,
+ `${status}_${isDismissed}`,
+ );
+ }
+ }
+
+ isBannerDismissed(status) {
+ let bannerState;
+ if (AccessorUtilities.isLocalStorageAccessSafe()) {
+ bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`);
+ }
+
+ return bannerState === `${status}_true`;
+ }
+
updateContainer(prevStatus, status, error) {
this.hideAll();
+ if (this.isBannerDismissed(status)) {
+ return;
+ }
+ this.setBannerDismissedState(status, false);
+
// We poll all the time but only want the `created` banner to show when newly created
if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) {
switch (status) {
@@ -210,6 +283,12 @@ export default class Clusters {
this.errorContainer.classList.remove('hidden');
this.errorReasonContainer.textContent = error;
break;
+ case 'unreachable':
+ this.unreachableContainer.classList.remove('hidden');
+ break;
+ case 'authentication_failure':
+ this.authenticationFailureContainer.classList.remove('hidden');
+ break;
case 'scheduled':
case 'creating':
this.creatingContainer.classList.remove('hidden');
@@ -220,24 +299,68 @@ export default class Clusters {
}
}
- installApplication(data) {
- const appId = data.id;
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
+ installApplication({ id: appId, params }) {
this.store.updateAppProperty(appId, 'requestReason', null);
+ this.store.updateAppProperty(appId, 'statusReason', null);
+
+ this.store.installApplication(appId);
- this.service
- .installApplication(appId, data.params)
- .then(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
- })
- .catch(() => {
- this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE);
- this.store.updateAppProperty(
- appId,
- 'requestReason',
- s__('ClusterIntegration|Request to begin installing failed'),
- );
- });
+ return this.service.installApplication(appId, params).catch(() => {
+ this.store.notifyInstallFailure(appId);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin installing failed'),
+ );
+ });
+ }
+
+ uninstallApplication({ id: appId }) {
+ this.store.updateAppProperty(appId, 'requestReason', null);
+ this.store.updateAppProperty(appId, 'statusReason', null);
+
+ this.store.uninstallApplication(appId);
+
+ return this.service.uninstallApplication(appId).catch(() => {
+ this.store.notifyUninstallFailure(appId);
+ this.store.updateAppProperty(
+ appId,
+ 'requestReason',
+ s__('ClusterIntegration|Request to begin uninstalling failed'),
+ );
+ });
+ }
+
+ upgradeApplication(data) {
+ const appId = data.id;
+
+ this.store.updateApplication(appId);
+ this.service.installApplication(appId, data.params).catch(() => {
+ this.store.notifyUpdateFailure(appId);
+ });
+ }
+
+ dismissUpgradeSuccess(appId) {
+ this.store.acknowledgeSuccessfulUpdate(appId);
+ }
+
+ toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
+ if (externalIp !== newExternalIp) {
+ this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
+ this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`;
+ }
+ }
+
+ saveKnativeDomain(data) {
+ const appId = data.id;
+ this.store.updateAppProperty(appId, 'status', APPLICATION_STATUS.UPDATING);
+ this.service.updateApplication(appId, data.params);
+ }
+
+ setKnativeHostname(data) {
+ const appId = data.id;
+ this.store.updateAppProperty(appId, 'isEditingHostName', true);
+ this.store.updateAppProperty(appId, 'hostname', data.hostname);
}
destroy() {
diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue
index d4354dcfebd..5f7675bb432 100644
--- a/app/assets/javascripts/clusters/components/application_row.vue
+++ b/app/assets/javascripts/clusters/components/application_row.vue
@@ -1,20 +1,27 @@
<script>
/* eslint-disable vue/require-default-prop */
+import { GlLink, GlModalDirective } from '@gitlab/ui';
+import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import { s__, sprintf } from '../../locale';
import eventHub from '../event_hub';
import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
-import {
- APPLICATION_STATUS,
- REQUEST_LOADING,
- REQUEST_SUCCESS,
- REQUEST_FAILURE,
-} from '../constants';
+import UninstallApplicationButton from './uninstall_application_button.vue';
+import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
+
+import { APPLICATION_STATUS } from '../constants';
export default {
components: {
loadingButton,
identicon,
+ TimeagoTooltip,
+ GlLink,
+ UninstallApplicationButton,
+ UninstallApplicationConfirmationModal,
+ },
+ directives: {
+ GlModalDirective,
},
props: {
id: {
@@ -43,6 +50,11 @@ export default {
required: false,
default: false,
},
+ uninstallable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
status: {
type: String,
required: false,
@@ -51,14 +63,57 @@ export default {
type: String,
required: false,
},
- requestStatus: {
+ requestReason: {
type: String,
required: false,
},
- requestReason: {
+ installed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ installFailed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ version: {
+ type: String,
+ required: false,
+ },
+ chartRepo: {
type: String,
required: false,
},
+ upgradeAvailable: {
+ type: Boolean,
+ required: false,
+ },
+ updateSuccessful: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ updateFailed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ uninstallFailed: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ uninstallSuccessful: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ updateAcknowledged: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
installApplicationRequestParams: {
type: Object,
required: false,
@@ -72,11 +127,14 @@ export default {
isKnownStatus() {
return Object.values(APPLICATION_STATUS).includes(this.status);
},
- isInstalled() {
+ isInstalling() {
+ return this.status === APPLICATION_STATUS.INSTALLING;
+ },
+ canInstall() {
return (
- this.status === APPLICATION_STATUS.INSTALLED ||
- this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING
+ this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
+ this.status === APPLICATION_STATUS.INSTALLABLE ||
+ this.isUnknownStatus
);
},
hasLogo() {
@@ -89,13 +147,14 @@ export default {
rowJsClass() {
return `js-cluster-application-row-${this.id}`;
},
+ displayUninstallButton() {
+ return this.installed && this.uninstallable;
+ },
+ displayInstallButton() {
+ return !this.installed || !this.uninstallable;
+ },
installButtonLoading() {
- return (
- !this.status ||
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING ||
- this.requestStatus === REQUEST_LOADING
- );
+ return !this.status || this.isInstalling;
},
installButtonDisabled() {
// Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but
@@ -104,30 +163,17 @@ export default {
return (
((this.status !== APPLICATION_STATUS.INSTALLABLE &&
this.status !== APPLICATION_STATUS.ERROR) ||
- this.requestStatus === REQUEST_LOADING ||
- this.requestStatus === REQUEST_SUCCESS) &&
+ this.isInstalling) &&
this.isKnownStatus
);
},
installButtonLabel() {
let label;
- if (
- this.status === APPLICATION_STATUS.NOT_INSTALLABLE ||
- this.status === APPLICATION_STATUS.INSTALLABLE ||
- this.status === APPLICATION_STATUS.ERROR ||
- this.isUnknownStatus
- ) {
+ if (this.canInstall) {
label = s__('ClusterIntegration|Install');
- } else if (
- this.status === APPLICATION_STATUS.SCHEDULED ||
- this.status === APPLICATION_STATUS.INSTALLING
- ) {
+ } else if (this.isInstalling) {
label = s__('ClusterIntegration|Installing');
- } else if (
- this.status === APPLICATION_STATUS.INSTALLED ||
- this.status === APPLICATION_STATUS.UPDATED ||
- this.status === APPLICATION_STATUS.UPDATING
- ) {
+ } else if (this.installed) {
label = s__('ClusterIntegration|Installed');
}
@@ -140,14 +186,78 @@ export default {
return s__('ClusterIntegration|Manage');
},
hasError() {
- return this.status === APPLICATION_STATUS.ERROR || this.requestStatus === REQUEST_FAILURE;
+ return this.installFailed || this.uninstallFailed;
},
generalErrorDescription() {
- return sprintf(s__('ClusterIntegration|Something went wrong while installing %{title}'), {
+ let errorDescription;
+
+ if (this.installFailed) {
+ errorDescription = s__('ClusterIntegration|Something went wrong while installing %{title}');
+ } else if (this.uninstallFailed) {
+ errorDescription = s__(
+ 'ClusterIntegration|Something went wrong while uninstalling %{title}',
+ );
+ }
+
+ return sprintf(errorDescription, { title: this.title });
+ },
+ versionLabel() {
+ if (this.updateFailed) {
+ return s__('ClusterIntegration|Upgrade failed');
+ } else if (this.isUpgrading) {
+ return s__('ClusterIntegration|Upgrading');
+ }
+
+ return s__('ClusterIntegration|Upgraded');
+ },
+ upgradeFailureDescription() {
+ return s__('ClusterIntegration|Update failed. Please check the logs and try again.');
+ },
+ upgradeSuccessDescription() {
+ return sprintf(s__('ClusterIntegration|%{title} upgraded successfully.'), {
+ title: this.title,
+ });
+ },
+ upgradeButtonLabel() {
+ let label;
+ if (this.upgradeAvailable && !this.updateFailed && !this.isUpgrading) {
+ label = s__('ClusterIntegration|Upgrade');
+ } else if (this.isUpgrading) {
+ label = s__('ClusterIntegration|Updating');
+ } else if (this.updateFailed) {
+ label = s__('ClusterIntegration|Retry update');
+ }
+
+ return label;
+ },
+ isUpgrading() {
+ // Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
+ return this.status === APPLICATION_STATUS.UPDATING;
+ },
+ shouldShowUpgradeDetails() {
+ // This method only returns true when;
+ // Upgrade was successful OR Upgrade failed
+ // AND new upgrade is unavailable AND version information is present.
+ return (this.updateSuccessful || this.updateFailed) && !this.upgradeAvailable && this.version;
+ },
+ uninstallSuccessDescription() {
+ return sprintf(s__('ClusterIntegration|%{title} uninstalled successfully.'), {
title: this.title,
});
},
},
+ watch: {
+ updateSuccessful(updateSuccessful) {
+ if (updateSuccessful) {
+ this.$toast.show(this.upgradeSuccessDescription);
+ }
+ },
+ uninstallSuccessful(uninstallSuccessful) {
+ if (uninstallSuccessful) {
+ this.$toast.show(this.uninstallSuccessDescription);
+ }
+ },
+ },
methods: {
installClicked() {
eventHub.$emit('installApplication', {
@@ -155,6 +265,17 @@ export default {
params: this.installApplicationRequestParams,
});
},
+ upgradeClicked() {
+ eventHub.$emit('upgradeApplication', {
+ id: this.id,
+ params: this.installApplicationRequestParams,
+ });
+ },
+ uninstallConfirmed() {
+ eventHub.$emit('uninstallApplication', {
+ id: this.id,
+ });
+ },
},
};
</script>
@@ -163,7 +284,7 @@ export default {
<div
:class="[
rowJsClass,
- isInstalled && 'cluster-application-installed',
+ installed && 'cluster-application-installed',
disabled && 'cluster-application-disabled',
]"
class="cluster-application-row gl-responsive-table-row gl-responsive-table-row-col-span"
@@ -186,16 +307,12 @@ export default {
target="blank"
rel="noopener noreferrer"
class="js-cluster-application-title"
+ >{{ title }}</a
>
- {{ title }}
- </a>
- <span v-else class="js-cluster-application-title"> {{ title }} </span>
+ <span v-else class="js-cluster-application-title">{{ title }}</span>
</strong>
<slot name="description"></slot>
- <div
- v-if="hasError || isUnknownStatus"
- class="cluster-application-error text-danger prepend-top-10"
- >
+ <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10">
<p class="js-cluster-application-general-error-message append-bottom-0">
{{ generalErrorDescription }}
</p>
@@ -208,6 +325,37 @@ export default {
</li>
</ul>
</div>
+
+ <div
+ v-if="shouldShowUpgradeDetails"
+ class="form-text text-muted label p-0 js-cluster-application-upgrade-details"
+ >
+ {{ versionLabel }}
+ <span v-if="updateSuccessful">to</span>
+
+ <gl-link
+ v-if="updateSuccessful"
+ :href="chartRepo"
+ target="_blank"
+ class="js-cluster-application-upgrade-version"
+ >chart v{{ version }}</gl-link
+ >
+ </div>
+
+ <div
+ v-if="updateFailed && !isUpgrading"
+ class="bs-callout bs-callout-danger cluster-application-banner mt-2 mb-0 js-cluster-application-upgrade-failure-message"
+ >
+ {{ upgradeFailureDescription }}
+ </div>
+ <loading-button
+ v-if="upgradeAvailable || updateFailed || isUpgrading"
+ class="btn btn-primary js-cluster-application-upgrade-button mt-2"
+ :loading="isUpgrading"
+ :disabled="isUpgrading"
+ :label="upgradeButtonLabel"
+ @click="upgradeClicked"
+ />
</div>
<div
:class="{ 'section-25': showManageButton, 'section-15': !showManageButton }"
@@ -215,18 +363,30 @@ export default {
role="gridcell"
>
<div v-if="showManageButton" class="btn-group table-action-buttons">
- <a :href="manageLink" :class="{ disabled: disabled }" class="btn">
- {{ manageButtonLabel }}
- </a>
+ <a :href="manageLink" :class="{ disabled: disabled }" class="btn">{{
+ manageButtonLabel
+ }}</a>
</div>
<div class="btn-group table-action-buttons">
<loading-button
+ v-if="displayInstallButton"
:loading="installButtonLoading"
:disabled="disabled || installButtonDisabled"
:label="installButtonLabel"
class="js-cluster-application-install-button"
@click="installClicked"
/>
+ <uninstall-application-button
+ v-if="displayUninstallButton"
+ v-gl-modal-directive="'uninstall-' + id"
+ :status="status"
+ class="js-cluster-application-uninstall-button"
+ />
+ <uninstall-application-confirmation-modal
+ :application="id"
+ :application-title="title"
+ @confirm="uninstallConfirmed()"
+ />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue
index 489615f1f78..73760da9b98 100644
--- a/app/assets/javascripts/clusters/components/applications.vue
+++ b/app/assets/javascripts/clusters/components/applications.vue
@@ -1,6 +1,7 @@
<script>
import _ from 'underscore';
import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg';
+import { GlLoadingIcon } from '@gitlab/ui';
import elasticsearchLogo from 'images/cluster_app_logos/elasticsearch.png';
import gitlabLogo from 'images/cluster_app_logos/gitlab.png';
import helmLogo from 'images/cluster_app_logos/helm.png';
@@ -15,11 +16,15 @@ import { s__, sprintf } from '../../locale';
import applicationRow from './application_row.vue';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import { CLUSTER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import eventHub from '~/clusters/event_hub';
export default {
components: {
applicationRow,
clipboardButton,
+ LoadingButton,
+ GlLoadingIcon,
},
props: {
type: {
@@ -52,6 +57,11 @@ export default {
required: false,
default: '',
},
+ rbac: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data: () => ({
elasticsearchLogo,
@@ -81,53 +91,26 @@ export default {
ingressInstalled() {
return this.applications.ingress.status === APPLICATION_STATUS.INSTALLED;
},
- ingressExternalIp() {
- return this.applications.ingress.externalIp;
+ ingressExternalEndpoint() {
+ return this.applications.ingress.externalIp || this.applications.ingress.externalHostname;
},
certManagerInstalled() {
return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED;
},
ingressDescription() {
- const extraCostParagraph = sprintf(
- _.escape(
- s__(
- `ClusterIntegration|%{boldNotice} This will add some extra resources
- like a load balancer, which may incur additional costs depending on
- the hosting provider your Kubernetes cluster is installed on. If you are using
- Google Kubernetes Engine, you can %{pricingLink}.`,
- ),
- ),
- {
- boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`,
- pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="_blank" rel="noopener noreferrer">
- ${_.escape(s__('ClusterIntegration|check the pricing here'))}</a>`,
- },
- false,
- );
-
- const externalIpParagraph = sprintf(
+ return sprintf(
_.escape(
s__(
- `ClusterIntegration|After installing Ingress, you will need to point your wildcard DNS
- at the generated external IP address in order to view your app after it is deployed. %{ingressHelpLink}`,
+ `ClusterIntegration|Installing Ingress may incur additional costs. Learn more about %{pricingLink}.`,
),
),
{
- ingressHelpLink: `<a href="${this.ingressHelpPath}">
- ${_.escape(s__('ClusterIntegration|More information'))}
- </a>`,
+ pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`,
},
false,
);
-
- return `
- <p>
- ${extraCostParagraph}
- </p>
- <p class="settings-message append-bottom-0">
- ${externalIpParagraph}
- </p>
- `;
},
certManagerDescription() {
return sprintf(
@@ -168,16 +151,70 @@ export default {
jupyterHostname() {
return this.applications.jupyter.hostname;
},
+ knative() {
+ return this.applications.knative;
+ },
knativeInstalled() {
- return this.applications.knative.status === APPLICATION_STATUS.INSTALLED;
+ return (
+ this.knative.status === APPLICATION_STATUS.INSTALLED ||
+ this.knativeUpgrading ||
+ this.knativeUpgradeFailed ||
+ this.knative.status === APPLICATION_STATUS.UPDATED
+ );
},
- knativeExternalIp() {
- return this.applications.knative.externalIp;
+ knativeUpgrading() {
+ return (
+ this.knative.status === APPLICATION_STATUS.UPDATING ||
+ this.knative.status === APPLICATION_STATUS.SCHEDULED
+ );
+ },
+ knativeUpgradeFailed() {
+ return this.knative.status === APPLICATION_STATUS.UPDATE_ERRORED;
+ },
+ knativeExternalEndpoint() {
+ return this.knative.externalIp || this.knative.externalHostname;
+ },
+ knativeDescription() {
+ return sprintf(
+ _.escape(
+ s__(
+ `ClusterIntegration|Installing Knative may incur additional costs. Learn more about %{pricingLink}.`,
+ ),
+ ),
+ {
+ pricingLink: `<strong><a href="https://cloud.google.com/compute/pricing#lb"
+ target="_blank" rel="noopener noreferrer">
+ ${_.escape(s__('ClusterIntegration|pricing'))}</a></strong>`,
+ },
+ false,
+ );
+ },
+ canUpdateKnativeEndpoint() {
+ return this.knativeExternalEndpoint && !this.knativeUpgradeFailed && !this.knativeUpgrading;
+ },
+ knativeHostname: {
+ get() {
+ return this.knative.hostname;
+ },
+ set(hostname) {
+ eventHub.$emit('setKnativeHostname', {
+ id: 'knative',
+ hostname,
+ });
+ },
},
},
created() {
this.helmInstallIllustration = helmInstallIllustration;
},
+ methods: {
+ saveKnativeDomain() {
+ eventHub.$emit('saveKnativeDomain', {
+ id: 'knative',
+ params: { hostname: this.knative.hostname },
+ });
+ },
+ },
};
</script>
@@ -187,9 +224,9 @@ export default {
<p class="append-bottom-0">
{{
s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.
- Helm Tiller is required to install any of the following applications.`)
+ Helm Tiller is required to install any of the following applications.`)
}}
- <a :href="helpPath"> {{ __('More information') }} </a>
+ <a :href="helpPath">{{ __('More information') }}</a>
</p>
<div class="cluster-application-list prepend-top-10">
@@ -201,15 +238,20 @@ export default {
:status-reason="applications.helm.statusReason"
:request-status="applications.helm.requestStatus"
:request-reason="applications.helm.requestReason"
+ :installed="applications.helm.installed"
+ :install-failed="applications.helm.installFailed"
+ :uninstallable="applications.helm.uninstallable"
+ :uninstall-successful="applications.helm.uninstallSuccessful"
+ :uninstall-failed="applications.helm.uninstallFailed"
class="rounded-top"
title-link="https://docs.helm.sh/"
>
<div slot="description">
{{
s__(`ClusterIntegration|Helm streamlines installing
- and managing Kubernetes applications.
- Tiller runs inside of your Kubernetes Cluster,
- and manages releases of your charts.`)
+ and managing Kubernetes applications.
+ Tiller runs inside of your Kubernetes Cluster,
+ and manages releases of your charts.`)
}}
</div>
</application-row>
@@ -217,7 +259,7 @@ export default {
<div class="svg-container" v-html="helmInstallIllustration"></div>
{{
s__(`ClusterIntegration|You must first install Helm Tiller before
- installing the applications below`)
+ installing the applications below`)
}}
</div>
<application-row
@@ -228,6 +270,11 @@ export default {
:status-reason="applications.ingress.statusReason"
:request-status="applications.ingress.requestStatus"
:request-reason="applications.ingress.requestReason"
+ :installed="applications.ingress.installed"
+ :install-failed="applications.ingress.installFailed"
+ :uninstallable="applications.ingress.uninstallable"
+ :uninstall-successful="applications.ingress.uninstallSuccessful"
+ :uninstall-failed="applications.ingress.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/"
>
@@ -235,58 +282,62 @@ export default {
<p>
{{
s__(`ClusterIntegration|Ingress gives you a way to route
- requests to services based on the request host or path,
- centralizing a number of services into a single entrypoint.`)
+ requests to services based on the request host or path,
+ centralizing a number of services into a single entrypoint.`)
}}
</p>
<template v-if="ingressInstalled">
<div class="form-group">
- <label for="ingress-ip-address">
- {{ s__('ClusterIntegration|Ingress IP Address') }}
- </label>
- <div v-if="ingressExternalIp" class="input-group">
+ <label for="ingress-endpoint">{{ s__('ClusterIntegration|Ingress Endpoint') }}</label>
+ <div v-if="ingressExternalEndpoint" class="input-group">
<input
- id="ingress-ip-address"
- :value="ingressExternalIp"
+ id="ingress-endpoint"
+ :value="ingressExternalEndpoint"
type="text"
- class="form-control js-ip-address"
+ class="form-control js-endpoint"
readonly
/>
<span class="input-group-append">
<clipboard-button
- :text="ingressExternalIp"
- :title="s__('ClusterIntegration|Copy Ingress IP Address to clipboard')"
+ :text="ingressExternalEndpoint"
+ :title="s__('ClusterIntegration|Copy Ingress Endpoint to clipboard')"
class="input-group-text js-clipboard-btn"
/>
</span>
</div>
- <input v-else type="text" class="form-control js-ip-address" readonly value="?" />
+ <div v-else class="input-group">
+ <input type="text" class="form-control js-endpoint" readonly />
+ <gl-loading-icon
+ class="position-absolute align-self-center ml-2 js-ingress-ip-loading-icon"
+ />
+ </div>
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Point a wildcard DNS to this
- generated IP address in order to access
+ generated endpoint in order to access
your application after it has been deployed.`)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
</p>
</div>
- <p v-if="!ingressExternalIp" class="settings-message js-no-ip-message">
+ <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message">
{{
- s__(`ClusterIntegration|The IP address is in
+ s__(`ClusterIntegration|The endpoint is in
the process of being assigned. Please check your Kubernetes
cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
}}
-
- <a :href="ingressHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
</p>
</template>
- <div v-html="ingressDescription"></div>
+ <template v-if="!ingressInstalled">
+ <div class="bs-callout bs-callout-info" v-html="ingressDescription"></div>
+ </template>
</div>
</application-row>
<application-row
@@ -297,7 +348,12 @@ export default {
:status-reason="applications.cert_manager.statusReason"
:request-status="applications.cert_manager.requestStatus"
:request-reason="applications.cert_manager.requestReason"
+ :installed="applications.cert_manager.installed"
+ :install-failed="applications.cert_manager.installFailed"
:install-application-request-params="{ email: applications.cert_manager.email }"
+ :uninstallable="applications.cert_manager.uninstallable"
+ :uninstall-successful="applications.cert_manager.uninstallSuccessful"
+ :uninstall-failed="applications.cert_manager.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://cert-manager.readthedocs.io/en/latest/#"
>
@@ -305,9 +361,9 @@ export default {
<div slot="description">
<p v-html="certManagerDescription"></p>
<div class="form-group">
- <label for="cert-manager-issuer-email">
- {{ s__('ClusterIntegration|Issuer Email') }}
- </label>
+ <label for="cert-manager-issuer-email">{{
+ s__('ClusterIntegration|Issuer Email')
+ }}</label>
<div class="input-group">
<input
v-model="applications.cert_manager.email"
@@ -319,22 +375,20 @@ export default {
<p class="form-text text-muted">
{{
s__(`ClusterIntegration|Issuers represent a certificate authority.
- You must provide an email address for your Issuer. `)
+ You must provide an email address for your Issuer. `)
}}
<a
href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email"
target="_blank"
rel="noopener noreferrer"
+ >{{ __('More information') }}</a
>
- {{ __('More information') }}
- </a>
</p>
</div>
</div>
</template>
</application-row>
<application-row
- v-if="isProjectCluster"
id="prometheus"
:logo-url="prometheusLogo"
:title="applications.prometheus.title"
@@ -343,13 +397,17 @@ export default {
:status-reason="applications.prometheus.statusReason"
:request-status="applications.prometheus.requestStatus"
:request-reason="applications.prometheus.requestReason"
+ :installed="applications.prometheus.installed"
+ :install-failed="applications.prometheus.installFailed"
+ :uninstallable="applications.prometheus.uninstallable"
+ :uninstall-successful="applications.prometheus.uninstallSuccessful"
+ :uninstall-failed="applications.prometheus.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://prometheus.io/docs/introduction/overview/"
>
<div slot="description" v-html="prometheusDescription"></div>
</application-row>
<application-row
- v-if="isProjectCluster"
id="runner"
:logo-url="gitlabLogo"
:title="applications.runner.title"
@@ -357,15 +415,25 @@ export default {
:status-reason="applications.runner.statusReason"
:request-status="applications.runner.requestStatus"
:request-reason="applications.runner.requestReason"
+ :version="applications.runner.version"
+ :chart-repo="applications.runner.chartRepo"
+ :upgrade-available="applications.runner.upgradeAvailable"
+ :installed="applications.runner.installed"
+ :install-failed="applications.runner.installFailed"
+ :update-successful="applications.runner.updateSuccessful"
+ :update-failed="applications.runner.updateFailed"
+ :uninstallable="applications.runner.uninstallable"
+ :uninstall-successful="applications.runner.uninstallSuccessful"
+ :uninstall-failed="applications.runner.uninstallFailed"
:disabled="!helmInstalled"
title-link="https://docs.gitlab.com/runner/"
>
<div slot="description">
{{
- s__(`ClusterIntegration|GitLab Runner connects to this
- project's repository and executes CI/CD jobs,
- pushing results back and deploying,
- applications to production.`)
+ s__(`ClusterIntegration|GitLab Runner connects to the
+ repository and executes CI/CD jobs,
+ pushing results back and deploying
+ applications to production.`)
}}
</div>
</application-row>
@@ -378,6 +446,11 @@ export default {
:status-reason="applications.jupyter.statusReason"
:request-status="applications.jupyter.requestStatus"
:request-reason="applications.jupyter.requestReason"
+ :installed="applications.jupyter.installed"
+ :install-failed="applications.jupyter.installFailed"
+ :uninstallable="applications.jupyter.uninstallable"
+ :uninstall-successful="applications.jupyter.uninstallSuccessful"
+ :uninstall-failed="applications.jupyter.uninstallFailed"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
:disabled="!helmInstalled"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
@@ -386,18 +459,16 @@ export default {
<p>
{{
s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
- manages, and proxies multiple instances of the single-user
- Jupyter notebook server. JupyterHub can be used to serve
- notebooks to a class of students, a corporate data science group,
- or a scientific research group.`)
+ manages, and proxies multiple instances of the single-user
+ Jupyter notebook server. JupyterHub can be used to serve
+ notebooks to a class of students, a corporate data science group,
+ or a scientific research group.`)
}}
</p>
- <template v-if="ingressExternalIp">
+ <template v-if="ingressExternalEndpoint">
<div class="form-group">
- <label for="jupyter-hostname">
- {{ s__('ClusterIntegration|Jupyter Hostname') }}
- </label>
+ <label for="jupyter-hostname">{{ s__('ClusterIntegration|Jupyter Hostname') }}</label>
<div class="input-group">
<input
@@ -420,9 +491,9 @@ export default {
s__(`ClusterIntegration|Replace this with your own hostname if you want.
If you do so, point hostname to Ingress IP Address from above.`)
}}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
</p>
</div>
</template>
@@ -437,90 +508,115 @@ export default {
:status-reason="applications.knative.statusReason"
:request-status="applications.knative.requestStatus"
:request-reason="applications.knative.requestReason"
+ :installed="applications.knative.installed"
+ :install-failed="applications.knative.installFailed"
:install-application-request-params="{ hostname: applications.knative.hostname }"
+ :uninstallable="applications.knative.uninstallable"
+ :uninstall-successful="applications.knative.uninstallSuccessful"
+ :uninstall-failed="applications.knative.uninstallFailed"
:disabled="!helmInstalled"
+ v-bind="applications.knative"
title-link="https://github.com/knative/docs"
>
<div slot="description">
+ <span v-if="!rbac">
+ <p v-if="!rbac" class="rbac-notice bs-callout bs-callout-info append-bottom-0">
+ {{
+ s__(`ClusterIntegration|You must have an RBAC-enabled cluster
+ to install Knative.`)
+ }}
+ <a :href="helpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
+ </p>
+ <br />
+ </span>
<p>
{{
s__(`ClusterIntegration|Knative extends Kubernetes to provide
- a set of middleware components that are essential to build modern,
- source-centric, and container-based applications that can run
- anywhere: on premises, in the cloud, or even in a third-party data center.`)
+ a set of middleware components that are essential to build modern,
+ source-centric, and container-based applications that can run
+ anywhere: on premises, in the cloud, or even in a third-party data center.`)
}}
</p>
- <template v-if="knativeInstalled">
- <div class="form-group">
- <label for="knative-domainname">
- {{ s__('ClusterIntegration|Knative Domain Name:') }}
- </label>
- <input
- id="knative-domainname"
- v-model="applications.knative.hostname"
- type="text"
- class="form-control js-domainname"
- readonly
- />
- </div>
- </template>
- <template v-else-if="helmInstalled">
- <div class="form-group">
- <label for="knative-domainname">
- {{ s__('ClusterIntegration|Knative Domain Name:') }}
- </label>
- <input
- id="knative-domainname"
- v-model="applications.knative.hostname"
- type="text"
- class="form-control js-domainname"
- />
- </div>
- </template>
- <template v-if="knativeInstalled">
- <div class="form-group">
- <label for="knative-ip-address">
- {{ s__('ClusterIntegration|Knative IP Address:') }}
- </label>
- <div v-if="knativeExternalIp" class="input-group">
+ <div class="row">
+ <template v-if="knativeInstalled || (helmInstalled && rbac)">
+ <div
+ :class="{ 'col-md-6': knativeInstalled, 'col-12': helmInstalled && rbac }"
+ class="form-group col-sm-12 mb-0"
+ >
+ <label for="knative-domainname">
+ <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong>
+ </label>
<input
- id="knative-ip-address"
- :value="knativeExternalIp"
+ id="knative-domainname"
+ v-model="knativeHostname"
type="text"
- class="form-control js-ip-address"
- readonly
+ class="form-control js-knative-domainname"
/>
- <span class="input-group-append">
- <clipboard-button
- :text="knativeExternalIp"
- :title="s__('ClusterIntegration|Copy Knative IP Address to clipboard')"
- class="input-group-text js-clipboard-btn"
+ </div>
+ </template>
+ <template v-if="knativeInstalled">
+ <div class="form-group col-sm-12 col-md-6 pl-md-0 mb-0 mt-3 mt-md-0">
+ <label for="knative-endpoint">
+ <strong>{{ s__('ClusterIntegration|Knative Endpoint:') }}</strong>
+ </label>
+ <div v-if="knativeExternalEndpoint" class="input-group">
+ <input
+ id="knative-endpoint"
+ :value="knativeExternalEndpoint"
+ type="text"
+ class="form-control js-knative-endpoint"
+ readonly
/>
- </span>
+ <span class="input-group-append">
+ <clipboard-button
+ :text="knativeExternalEndpoint"
+ :title="s__('ClusterIntegration|Copy Knative Endpoint to clipboard')"
+ class="input-group-text js-knative-endpoint-clipboard-btn"
+ />
+ </span>
+ </div>
+ <div v-else class="input-group">
+ <input type="text" class="form-control js-endpoint" readonly />
+ <gl-loading-icon
+ class="position-absolute align-self-center ml-2 js-knative-ip-loading-icon"
+ />
+ </div>
</div>
- <input v-else type="text" class="form-control js-ip-address" readonly value="?" />
- </div>
- <p v-if="!knativeExternalIp" class="settings-message js-no-ip-message">
- {{
- s__(`ClusterIntegration|The IP address is in
- the process of being assigned. Please check your Kubernetes
- cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
- }}
- </p>
+ <p class="form-text text-muted col-12">
+ {{
+ s__(
+ `ClusterIntegration|To access your application after deployment, point a wildcard DNS to the Knative Endpoint.`,
+ )
+ }}
+ <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">{{
+ __('More information')
+ }}</a>
+ </p>
- <p>
- {{
- s__(`ClusterIntegration|Point a wildcard DNS to this
- generated IP address in order to access
- your application after it has been deployed.`)
- }}
- <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer">
- {{ __('More information') }}
- </a>
- </p>
- </template>
+ <p
+ v-if="!knativeExternalEndpoint"
+ class="settings-message js-no-knative-endpoint-message mt-2 mr-3 mb-0 ml-3"
+ >
+ {{
+ s__(`ClusterIntegration|The endpoint is in
+ the process of being assigned. Please check your Kubernetes
+ cluster or Quotas on Google Kubernetes Engine if it takes a long time.`)
+ }}
+ </p>
+
+ <button
+ v-if="canUpdateKnativeEndpoint"
+ class="btn btn-success js-knative-save-domain-button mt-3 ml-3"
+ @click="saveKnativeDomain"
+ >
+ {{ s__('ClusterIntegration|Save changes') }}
+ </button>
+ </template>
+ </div>
</div>
</application-row>
</div>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_button.vue b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
new file mode 100644
index 00000000000..ef4bcbe14dd
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/uninstall_application_button.vue
@@ -0,0 +1,33 @@
+<script>
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { APPLICATION_STATUS } from '~/clusters/constants';
+
+const { UPDATING, UNINSTALLING } = APPLICATION_STATUS;
+
+export default {
+ components: {
+ LoadingButton,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return [UNINSTALLING, UPDATING].includes(this.status);
+ },
+ loading() {
+ return this.status === UNINSTALLING;
+ },
+ label() {
+ return this.loading ? this.__('Uninstalling') : this.__('Uninstall');
+ },
+ },
+};
+</script>
+
+<template>
+ <loading-button :label="label" :disabled="disabled" :loading="loading" />
+</template>
diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
new file mode 100644
index 00000000000..65827f1cb6a
--- /dev/null
+++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlModal } from '@gitlab/ui';
+import { sprintf, s__ } from '~/locale';
+import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click';
+import { INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants';
+
+const CUSTOM_APP_WARNING_TEXT = {
+ [INGRESS]: s__(
+ 'ClusterIntegration|The associated load balancer and IP will be deleted and cannot be restored.',
+ ),
+ [CERT_MANAGER]: s__(
+ 'ClusterIntegration|The associated certifcate will be deleted and cannot be restored.',
+ ),
+ [PROMETHEUS]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'),
+ [RUNNER]: s__('ClusterIntegration|Any running pipelines will be canceled.'),
+ [KNATIVE]: s__('ClusterIntegration|The associated IP will be deleted and cannot be restored.'),
+ [JUPYTER]: '',
+};
+
+export default {
+ components: {
+ GlModal,
+ },
+ mixins: [trackUninstallButtonClickMixin],
+ props: {
+ application: {
+ type: String,
+ required: true,
+ },
+ applicationTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ title() {
+ return sprintf(s__('ClusterIntegration|Uninstall %{appTitle}'), {
+ appTitle: this.applicationTitle,
+ });
+ },
+ warningText() {
+ return sprintf(
+ s__('ClusterIntegration|You are about to uninstall %{appTitle} from your cluster.'),
+ {
+ appTitle: this.applicationTitle,
+ },
+ );
+ },
+ customAppWarningText() {
+ return CUSTOM_APP_WARNING_TEXT[this.application];
+ },
+ modalId() {
+ return `uninstall-${this.application}`;
+ },
+ },
+ methods: {
+ confirmUninstall() {
+ this.trackUninstallButtonClick(this.application);
+ this.$emit('confirm');
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ ok-variant="danger"
+ cancel-variant="light"
+ :ok-title="title"
+ :modal-id="modalId"
+ :title="title"
+ @ok="confirmUninstall()"
+ >{{ warningText }} {{ customAppWarningText }}</gl-modal
+ >
+</template>
diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js
index e31afadf186..8fd752092c9 100644
--- a/app/assets/javascripts/clusters/constants.js
+++ b/app/assets/javascripts/clusters/constants.js
@@ -7,21 +7,44 @@ export const CLUSTER_TYPE = {
// These need to match what is returned from the server
export const APPLICATION_STATUS = {
+ NO_STATUS: null,
NOT_INSTALLABLE: 'not_installable',
INSTALLABLE: 'installable',
SCHEDULED: 'scheduled',
INSTALLING: 'installing',
INSTALLED: 'installed',
- UPDATED: 'updated',
UPDATING: 'updating',
+ UPDATED: 'updated',
+ UPDATE_ERRORED: 'update_errored',
+ UNINSTALLING: 'uninstalling',
+ UNINSTALL_ERRORED: 'uninstall_errored',
ERROR: 'errored',
};
+/*
+ * The application cannot be in any of the following states without
+ * not being installed.
+ */
+export const APPLICATION_INSTALLED_STATUSES = [
+ APPLICATION_STATUS.INSTALLED,
+ APPLICATION_STATUS.UPDATING,
+ APPLICATION_STATUS.UNINSTALLING,
+];
+
// These are only used client-side
-export const REQUEST_LOADING = 'request-loading';
-export const REQUEST_SUCCESS = 'request-success';
-export const REQUEST_FAILURE = 'request-failure';
+
+export const UPDATE_EVENT = 'update';
+export const INSTALL_EVENT = 'install';
+export const UNINSTALL_EVENT = 'uninstall';
+
+export const HELM = 'helm';
export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
export const KNATIVE = 'knative';
+export const RUNNER = 'runner';
export const CERT_MANAGER = 'cert_manager';
+export const PROMETHEUS = 'prometheus';
+
+export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS];
+
+export const INGRESS_DOMAIN_SUFFIX = '.nip.io';
diff --git a/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js
new file mode 100644
index 00000000000..18f65b234d3
--- /dev/null
+++ b/app/assets/javascripts/clusters/mixins/track_uninstall_button_click.js
@@ -0,0 +1,5 @@
+export default {
+ methods: {
+ trackUninstallButtonClick: () => {},
+ },
+};
diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js
new file mode 100644
index 00000000000..14b80a116a7
--- /dev/null
+++ b/app/assets/javascripts/clusters/services/application_state_machine.js
@@ -0,0 +1,175 @@
+import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT, UNINSTALL_EVENT } from '../constants';
+
+const {
+ NO_STATUS,
+ SCHEDULED,
+ NOT_INSTALLABLE,
+ INSTALLABLE,
+ INSTALLING,
+ INSTALLED,
+ ERROR,
+ UPDATING,
+ UPDATED,
+ UPDATE_ERRORED,
+ UNINSTALLING,
+ UNINSTALL_ERRORED,
+} = APPLICATION_STATUS;
+
+const applicationStateMachine = {
+ /* When the application initially loads, it will have `NO_STATUS`
+ * It will transition from `NO_STATUS` once the async backend call is completed
+ */
+ [NO_STATUS]: {
+ on: {
+ [SCHEDULED]: {
+ target: INSTALLING,
+ },
+ [NOT_INSTALLABLE]: {
+ target: NOT_INSTALLABLE,
+ },
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ },
+ [INSTALLING]: {
+ target: INSTALLING,
+ },
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ [ERROR]: {
+ target: INSTALLABLE,
+ effects: {
+ installFailed: true,
+ },
+ },
+ [UPDATING]: {
+ target: UPDATING,
+ },
+ [UPDATED]: {
+ target: INSTALLED,
+ },
+ [UPDATE_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ updateFailed: true,
+ },
+ },
+ [UNINSTALLING]: {
+ target: UNINSTALLING,
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
+ },
+ },
+ [NOT_INSTALLABLE]: {
+ on: {
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ },
+ },
+ },
+ [INSTALLABLE]: {
+ on: {
+ [INSTALL_EVENT]: {
+ target: INSTALLING,
+ effects: {
+ installFailed: false,
+ },
+ },
+ // This is possible in artificial environments for E2E testing
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ },
+ },
+ [INSTALLING]: {
+ on: {
+ [INSTALLED]: {
+ target: INSTALLED,
+ },
+ [ERROR]: {
+ target: INSTALLABLE,
+ effects: {
+ installFailed: true,
+ },
+ },
+ },
+ },
+ [INSTALLED]: {
+ on: {
+ [UPDATE_EVENT]: {
+ target: UPDATING,
+ effects: {
+ updateFailed: false,
+ updateSuccessful: false,
+ },
+ },
+ [UNINSTALL_EVENT]: {
+ target: UNINSTALLING,
+ effects: {
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+ },
+ },
+ },
+ },
+ [UPDATING]: {
+ on: {
+ [UPDATED]: {
+ target: INSTALLED,
+ effects: {
+ updateSuccessful: true,
+ updateAcknowledged: false,
+ },
+ },
+ [UPDATE_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ updateFailed: true,
+ },
+ },
+ },
+ },
+ [UNINSTALLING]: {
+ on: {
+ [INSTALLABLE]: {
+ target: INSTALLABLE,
+ effects: {
+ uninstallSuccessful: true,
+ },
+ },
+ [UNINSTALL_ERRORED]: {
+ target: INSTALLED,
+ effects: {
+ uninstallFailed: true,
+ },
+ },
+ },
+ },
+};
+
+/**
+ * Determines an application new state based on the application current state
+ * and an event. If the application current state cannot handle a given event,
+ * the current state is returned.
+ *
+ * @param {*} application
+ * @param {*} event
+ */
+const transitionApplicationState = (application, event) => {
+ const newState = applicationStateMachine[application.status].on[event];
+
+ return newState
+ ? {
+ ...application,
+ status: newState.target,
+ ...newState.effects,
+ }
+ : application;
+};
+
+export default transitionApplicationState;
diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js
index 89dda4b7902..01f3732de7e 100644
--- a/app/assets/javascripts/clusters/services/clusters_service.js
+++ b/app/assets/javascripts/clusters/services/clusters_service.js
@@ -12,6 +12,9 @@ export default class ClusterService {
jupyter: this.options.installJupyterEndpoint,
knative: this.options.installKnativeEndpoint,
};
+ this.appUpdateEndpointMap = {
+ knative: this.options.updateKnativeEndpoint,
+ };
}
fetchData() {
@@ -22,6 +25,14 @@ export default class ClusterService {
return axios.post(this.appInstallEndpointMap[appId], params);
}
+ updateApplication(appId, params) {
+ return axios.patch(this.appUpdateEndpointMap[appId], params);
+ }
+
+ uninstallApplication(appId, params) {
+ return axios.delete(this.appInstallEndpointMap[appId], params);
+ }
+
static updateCluster(endpoint, data) {
return axios.put(endpoint, data);
}
diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js
index c750daab112..1b4d7e8372c 100644
--- a/app/assets/javascripts/clusters/stores/clusters_store.js
+++ b/app/assets/javascripts/clusters/stores/clusters_store.js
@@ -1,5 +1,31 @@
import { s__ } from '../../locale';
-import { INGRESS, JUPYTER, KNATIVE, CERT_MANAGER } from '../constants';
+import { parseBoolean } from '../../lib/utils/common_utils';
+import {
+ INGRESS,
+ JUPYTER,
+ KNATIVE,
+ CERT_MANAGER,
+ RUNNER,
+ APPLICATION_INSTALLED_STATUSES,
+ APPLICATION_STATUS,
+ INSTALL_EVENT,
+ UPDATE_EVENT,
+ UNINSTALL_EVENT,
+} from '../constants';
+import transitionApplicationState from '../services/application_state_machine';
+
+const isApplicationInstalled = appStatus => APPLICATION_INSTALLED_STATUSES.includes(appStatus);
+
+const applicationInitialState = {
+ status: null,
+ statusReason: null,
+ requestReason: null,
+ installed: false,
+ installFailed: false,
+ uninstallable: false,
+ uninstallFailed: false,
+ uninstallSuccessful: false,
+};
export default class ClusterStore {
constructor() {
@@ -7,61 +33,50 @@ export default class ClusterStore {
helpPath: null,
ingressHelpPath: null,
status: null,
+ rbac: false,
statusReason: null,
applications: {
helm: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Helm Tiller'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
},
ingress: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Ingress'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
externalIp: null,
+ externalHostname: null,
},
cert_manager: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Cert-Manager'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
email: null,
},
runner: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|GitLab Runner'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
+ version: null,
+ chartRepo: 'https://gitlab.com/charts/gitlab-runner',
+ upgradeAvailable: null,
+ updateAcknowledged: true,
+ updateSuccessful: false,
+ updateFailed: false,
},
prometheus: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Prometheus'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
},
jupyter: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|JupyterHub'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
hostname: null,
},
knative: {
+ ...applicationInitialState,
title: s__('ClusterIntegration|Knative'),
- status: null,
- statusReason: null,
- requestStatus: null,
- requestReason: null,
hostname: null,
+ isEditingHostName: false,
externalIp: null,
+ externalHostname: null,
},
},
};
@@ -81,10 +96,48 @@ export default class ClusterStore {
this.state.status = status;
}
+ updateRbac(rbac) {
+ this.state.rbac = parseBoolean(rbac);
+ }
+
updateStatusReason(reason) {
this.state.statusReason = reason;
}
+ installApplication(appId) {
+ this.handleApplicationEvent(appId, INSTALL_EVENT);
+ }
+
+ notifyInstallFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.ERROR);
+ }
+
+ updateApplication(appId) {
+ this.handleApplicationEvent(appId, UPDATE_EVENT);
+ }
+
+ notifyUpdateFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.UPDATE_ERRORED);
+ }
+
+ uninstallApplication(appId) {
+ this.handleApplicationEvent(appId, UNINSTALL_EVENT);
+ }
+
+ notifyUninstallFailure(appId) {
+ this.handleApplicationEvent(appId, APPLICATION_STATUS.UNINSTALL_ERRORED);
+ }
+
+ handleApplicationEvent(appId, event) {
+ const currentAppState = this.state.applications[appId];
+
+ this.state.applications[appId] = transitionApplicationState(currentAppState, event);
+ }
+
+ acknowledgeSuccessfulUpdate(appId) {
+ this.state.applications[appId].updateAcknowledged = true;
+ }
+
updateAppProperty(appId, prop, value) {
this.state.applications[appId][prop] = value;
}
@@ -94,16 +147,28 @@ export default class ClusterStore {
this.state.statusReason = serverState.status_reason;
serverState.applications.forEach(serverAppEntry => {
- const { name: appId, status, status_reason: statusReason } = serverAppEntry;
+ const {
+ name: appId,
+ status,
+ status_reason: statusReason,
+ version,
+ update_available: upgradeAvailable,
+ can_uninstall: uninstallable,
+ } = serverAppEntry;
+ const currentApplicationState = this.state.applications[appId] || {};
+ const nextApplicationState = transitionApplicationState(currentApplicationState, status);
this.state.applications[appId] = {
- ...(this.state.applications[appId] || {}),
- status,
+ ...currentApplicationState,
+ ...nextApplicationState,
statusReason,
+ installed: isApplicationInstalled(nextApplicationState.status),
+ uninstallable,
};
if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
+ this.state.applications.ingress.externalHostname = serverAppEntry.external_hostname;
} else if (appId === CERT_MANAGER) {
this.state.applications.cert_manager.email =
this.state.applications.cert_manager.email || serverAppEntry.email;
@@ -114,10 +179,17 @@ export default class ClusterStore {
? `jupyter.${this.state.applications.ingress.externalIp}.nip.io`
: '');
} else if (appId === KNATIVE) {
- this.state.applications.knative.hostname =
- serverAppEntry.hostname || this.state.applications.knative.hostname;
+ if (!this.state.applications.knative.isEditingHostName) {
+ this.state.applications.knative.hostname =
+ serverAppEntry.hostname || this.state.applications.knative.hostname;
+ }
this.state.applications.knative.externalIp =
serverAppEntry.external_ip || this.state.applications.knative.externalIp;
+ this.state.applications.knative.externalHostname =
+ serverAppEntry.external_hostname || this.state.applications.knative.externalHostname;
+ } else if (appId === RUNNER) {
+ this.state.applications.runner.version = version;
+ this.state.applications.runner.upgradeAvailable = upgradeAvailable;
}
});
}
diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js
index d4ecfa4aa93..bc666aef54b 100644
--- a/app/assets/javascripts/commit/image_file.js
+++ b/app/assets/javascripts/commit/image_file.js
@@ -71,29 +71,39 @@ export default class ImageFile {
// eslint-disable-next-line class-methods-use-this
initDraggable($el, padding, callback) {
var dragging = false;
- var $body = $('body');
- var $offsetEl = $el.parent();
-
- $el.off('mousedown').on('mousedown', function() {
+ const $body = $('body');
+ const $offsetEl = $el.parent();
+ const dragStart = function() {
dragging = true;
$body.css('user-select', 'none');
- });
+ };
+ const dragStop = function() {
+ dragging = false;
+ $body.css('user-select', '');
+ };
+ const dragMove = function(e) {
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - ($offsetEl.offset().left + padding);
+ if (!dragging) return;
+
+ callback(e, left);
+ };
+
+ $el
+ .off('mousedown')
+ .off('touchstart')
+ .on('mousedown', dragStart)
+ .on('touchstart', dragStart);
$body
.off('mouseup')
.off('mousemove')
- .on('mouseup', function() {
- dragging = false;
- $body.css('user-select', '');
- })
- .on('mousemove', function(e) {
- var left;
- if (!dragging) return;
-
- left = e.pageX - ($offsetEl.offset().left + padding);
-
- callback(e, left);
- });
+ .off('touchend')
+ .off('touchmove')
+ .on('mouseup', dragStop)
+ .on('touchend', dragStop)
+ .on('mousemove', dragMove)
+ .on('touchmove', dragMove);
}
prepareFrames(view) {
diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js
index fba30aea9ae..e5e1cbb1e62 100644
--- a/app/assets/javascripts/commons/bootstrap.js
+++ b/app/assets/javascripts/commons/bootstrap.js
@@ -16,3 +16,63 @@ $.fn.extend({
.removeClass('disabled');
},
});
+
+/*
+ Starting with bootstrap 4.3.1, bootstrap sanitizes html used for tooltips / popovers.
+ This extends the default whitelists with more elements / attributes:
+ https://getbootstrap.com/docs/4.3/getting-started/javascript/#sanitizer
+ */
+const whitelist = $.fn.tooltip.Constructor.Default.whiteList;
+
+const inputAttributes = ['value', 'type'];
+
+const dataAttributes = [
+ 'data-toggle',
+ 'data-placement',
+ 'data-container',
+ 'data-title',
+ 'data-class',
+ 'data-clipboard-text',
+ 'data-placement',
+];
+
+// Whitelisting data attributes
+whitelist['*'] = [
+ ...whitelist['*'],
+ ...dataAttributes,
+ 'title',
+ 'width height',
+ 'abbr',
+ 'datetime',
+ 'name',
+ 'width',
+ 'height',
+];
+
+// Whitelist missing elements:
+whitelist.label = ['for'];
+whitelist.button = [...inputAttributes];
+whitelist.input = [...inputAttributes];
+
+whitelist.tt = [];
+whitelist.samp = [];
+whitelist.kbd = [];
+whitelist.var = [];
+whitelist.dfn = [];
+whitelist.cite = [];
+whitelist.big = [];
+whitelist.address = [];
+whitelist.dl = [];
+whitelist.dt = [];
+whitelist.dd = [];
+whitelist.abbr = [];
+whitelist.acronym = [];
+whitelist.blockquote = [];
+whitelist.del = [];
+whitelist.ins = [];
+whitelist['gl-emoji'] = [];
+
+// Whitelisting SVG tags and attributes
+whitelist.svg = ['viewBox'];
+whitelist.use = ['xlink:href'];
+whitelist.path = ['d'];
diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js
index a7ed175f7a4..2f268419bff 100644
--- a/app/assets/javascripts/commons/jquery.js
+++ b/app/assets/javascripts/commons/jquery.js
@@ -3,8 +3,7 @@ import 'jquery';
// common jQuery plugins
import 'jquery-ujs';
import 'vendor/jquery.endless-scroll';
-import 'vendor/jquery.caret';
-import 'vendor/jquery.atwho';
+import 'jquery.caret'; // must be imported before at.js
+import 'at.js';
import 'vendor/jquery.scrollTo';
import 'jquery.waitforimages';
-import 'select2/select2';
diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js
index bffc025ced3..9216d4ab372 100644
--- a/app/assets/javascripts/commons/polyfills.js
+++ b/app/assets/javascripts/commons/polyfills.js
@@ -6,7 +6,9 @@ import 'core-js/fn/array/from';
import 'core-js/fn/array/includes';
import 'core-js/fn/object/assign';
import 'core-js/fn/object/values';
+import 'core-js/fn/object/entries';
import 'core-js/fn/promise';
+import 'core-js/fn/promise/finally';
import 'core-js/fn/string/code-point-at';
import 'core-js/fn/string/from-code-point';
import 'core-js/fn/string/includes';
diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js
index 10f02739ec8..b62ec8a651b 100644
--- a/app/assets/javascripts/contextual_sidebar.js
+++ b/app/assets/javascripts/contextual_sidebar.js
@@ -4,6 +4,12 @@ import _ from 'underscore';
import bp from './breakpoints';
import { parseBoolean } from '~/lib/utils/common_utils';
+// NOTE: at 1200px nav sidebar should not overlap the content
+// https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24555#note_134136110
+const NAV_SIDEBAR_BREAKPOINT = 1200;
+
+export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed';
+
export default class ContextualSidebar {
constructor() {
this.initDomElements();
@@ -13,6 +19,9 @@ export default class ContextualSidebar {
initDomElements() {
this.$page = $('.layout-page');
this.$sidebar = $('.nav-sidebar');
+
+ if (!this.$sidebar.length) return;
+
this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar);
this.$overlay = $('.mobile-overlay');
this.$openSidebar = $('.toggle-mobile-nav');
@@ -21,48 +30,67 @@ export default class ContextualSidebar {
}
bindEvents() {
- document.addEventListener('click', e => {
- if (
- !e.target.closest('.nav-sidebar') &&
- (bp.getBreakpointSize() === 'sm' || bp.getBreakpointSize() === 'md')
- ) {
- this.toggleCollapsedSidebar(true);
- }
- });
+ if (!this.$sidebar.length) return;
+
this.$openSidebar.on('click', () => this.toggleSidebarNav(true));
this.$closeSidebar.on('click', () => this.toggleSidebarNav(false));
this.$overlay.on('click', () => this.toggleSidebarNav(false));
this.$sidebarToggle.on('click', () => {
- const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
- this.toggleCollapsedSidebar(value);
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
+ this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile'));
+ } else {
+ const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop');
+ this.toggleCollapsedSidebar(value, true);
+ }
+ });
+ this.$page.on('transitionstart transitionend', () => {
+ $(document).trigger('content.resize');
});
$(window).on('resize', () => _.debounce(this.render(), 100));
}
+ // TODO: use the breakpoints from breakpoints.js once they have been updated for bootstrap 4
+ // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation
+ static isDesktopBreakpoint = () => bp.windowWidth() >= NAV_SIDEBAR_BREAKPOINT;
static setCollapsedCookie(value) {
- if (bp.getBreakpointSize() !== 'lg') {
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
return;
}
Cookies.set('sidebar_collapsed', value, { expires: 365 * 10 });
}
toggleSidebarNav(show) {
- this.$sidebar.toggleClass('sidebar-expanded-mobile', show);
- this.$overlay.toggleClass('mobile-nav-open', show);
+ const breakpoint = bp.getBreakpointSize();
+ const dbp = ContextualSidebar.isDesktopBreakpoint();
+
+ this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show);
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false);
+ this.$overlay.toggleClass(
+ 'mobile-nav-open',
+ breakpoint === 'xs' || breakpoint === 'sm' ? show : false,
+ );
this.$sidebar.removeClass('sidebar-collapsed-desktop');
}
- toggleCollapsedSidebar(collapsed) {
+ toggleCollapsedSidebar(collapsed, saveCookie) {
const breakpoint = bp.getBreakpointSize();
+ const dbp = ContextualSidebar.isDesktopBreakpoint();
if (this.$sidebar.length) {
this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed);
- this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed);
+ this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false);
+ this.$page.toggleClass(
+ 'page-with-icon-sidebar',
+ breakpoint === 'xs' || breakpoint === 'sm' ? true : collapsed,
+ );
}
- ContextualSidebar.setCollapsedCookie(collapsed);
- this.toggleSidebarOverflow();
+ if (saveCookie) {
+ ContextualSidebar.setCollapsedCookie(collapsed);
+ }
+
+ requestIdleCallback(() => this.toggleSidebarOverflow());
}
toggleSidebarOverflow() {
@@ -74,13 +102,13 @@ export default class ContextualSidebar {
}
render() {
- const breakpoint = bp.getBreakpointSize();
+ if (!this.$sidebar.length) return;
- if (breakpoint === 'sm' || breakpoint === 'md') {
- this.toggleCollapsedSidebar(true);
- } else if (breakpoint === 'lg') {
+ if (!ContextualSidebar.isDesktopBreakpoint()) {
+ this.toggleSidebarNav(false);
+ } else {
const collapse = parseBoolean(Cookies.get('sidebar_collapsed'));
- this.toggleCollapsedSidebar(collapse);
+ this.toggleCollapsedSidebar(collapse, true);
}
}
}
diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js
index 28ca7d97314..eac0e37bcaa 100644
--- a/app/assets/javascripts/create_label.js
+++ b/app/assets/javascripts/create_label.js
@@ -14,6 +14,7 @@ export default class CreateLabelDropdown {
this.$newLabelField = $('#new_label_name', this.$el);
this.$newColorField = $('#new_label_color', this.$el);
this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el);
+ this.$addList = $('.js-add-list', this.$el);
this.$newLabelError = $('.js-label-error', this.$el);
this.$newLabelCreateButton = $('.js-new-label-btn', this.$el);
this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el);
@@ -21,6 +22,8 @@ export default class CreateLabelDropdown {
this.$newLabelError.hide();
this.$newLabelCreateButton.disable();
+ this.addListDefault = this.$addList.is(':checked');
+
this.cleanBinding();
this.addBinding();
}
@@ -83,6 +86,8 @@ export default class CreateLabelDropdown {
this.$newColorField.val('').trigger('change');
+ this.$addList.prop('checked', this.addListDefault);
+
this.$colorPreview
.css('background-color', '')
.parent()
@@ -116,9 +121,9 @@ export default class CreateLabelDropdown {
this.$newLabelError.html(errors).show();
} else {
+ const addNewList = this.$addList.is(':checked');
this.$dropdownBack.trigger('click');
-
- $(document).trigger('created.label', label);
+ $(document).trigger('created.label', [label, addNewList]);
}
},
);
diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js
index 02aa507ba03..8f5cece0788 100644
--- a/app/assets/javascripts/create_merge_request_dropdown.js
+++ b/app/assets/javascripts/create_merge_request_dropdown.js
@@ -118,7 +118,7 @@ export default class CreateMergeRequestDropdown {
this.branchCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash('Failed to create a branch for this issue. Please try again.'));
+ .catch(() => Flash(__('Failed to create a branch for this issue. Please try again.')));
}
createMergeRequest() {
@@ -130,7 +130,7 @@ export default class CreateMergeRequestDropdown {
this.mergeRequestCreated = true;
window.location.href = data.url;
})
- .catch(() => Flash('Failed to create Merge Request. Please try again.'));
+ .catch(() => Flash(__('Failed to create Merge Request. Please try again.')));
}
disable() {
@@ -227,7 +227,7 @@ export default class CreateMergeRequestDropdown {
.catch(() => {
this.unavailable();
this.disable();
- new Flash('Failed to get ref.');
+ new Flash(__('Failed to get ref.'));
this.isGettingRef = false;
diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
index 4de425b48e7..3f0a9f2602c 100644
--- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
+++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js
@@ -12,6 +12,7 @@ import stageStagingComponent from './components/stage_staging_component.vue';
import stageTestComponent from './components/stage_test_component.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
+import { __ } from '~/locale';
Vue.use(Translate);
@@ -61,7 +62,7 @@ export default () => {
methods: {
handleError() {
this.store.setErrorState(true);
- return new Flash('There was an error while fetching cycle analytics data.');
+ return new Flash(__('There was an error while fetching cycle analytics data.'));
},
initDropdown() {
const $dropdown = $('.js-ca-dropdown');
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index f01e6f2a639..6ffb8c4e1c0 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -113,7 +113,7 @@ export default {
<div class="gl-responsive-table-row deploy-key">
<div class="table-section section-40">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Deploy key') }}</div>
- <div class="table-mobile-content">
+ <div class="table-mobile-content qa-key">
<strong class="title qa-key-title"> {{ deployKey.title }} </strong>
<div class="fingerprint qa-key-fingerprint">{{ deployKey.fingerprint }}</div>
</div>
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 4ae4ceabc21..f66e07ba31a 100644
--- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
const CommentAndResolveBtn = Vue.extend({
props: {
@@ -31,15 +32,15 @@ const CommentAndResolveBtn = Vue.extend({
buttonText: function() {
if (this.isDiscussionResolved) {
if (this.textareaIsEmpty) {
- return 'Unresolve discussion';
+ return __('Unresolve discussion');
} else {
- return 'Comment & unresolve discussion';
+ return __('Comment & unresolve discussion');
}
} else {
if (this.textareaIsEmpty) {
- return 'Resolve discussion';
+ return __('Resolve discussion');
} else {
- return 'Comment & resolve discussion';
+ return __('Comment & resolve discussion');
}
}
},
diff --git a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
index 5bdeaaade68..b5a781cbc92 100644
--- a/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
+++ b/app/assets/javascripts/diff_notes/components/diff_note_avatars.js
@@ -5,6 +5,7 @@ import Vue from 'vue';
import collapseIcon from '../icons/collapse_icon.svg';
import Notes from '../../notes';
import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue';
+import { n__ } from '~/locale';
const DiffNoteAvatars = Vue.extend({
components: {
@@ -44,7 +45,7 @@ const DiffNoteAvatars = Vue.extend({
if (this.discussion) {
const extra = this.discussion.notesCount() - this.shownAvatars;
- return `${extra} more comment${extra > 1 ? 's' : ''}`;
+ return n__('%d more comment', '%d more comments', extra);
}
return '';
diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
index 8542a6e718a..fe4088cadda 100644
--- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
+++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Vue from 'vue';
+import { __ } from '~/locale';
import DiscussionMixins from '../mixins/discussion';
@@ -23,9 +24,9 @@ const JumpToDiscussion = Vue.extend({
computed: {
buttonText: function() {
if (this.discussionId) {
- return 'Jump to next unresolved discussion';
+ return __('Jump to next unresolved discussion');
} else {
- return 'Jump to first unresolved discussion';
+ return __('Jump to first unresolved discussion');
}
},
allResolved: function() {
diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js
index a69b34b0db8..87e7dd18e0c 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js
@@ -4,6 +4,7 @@
import $ from 'jquery';
import Vue from 'vue';
import Flash from '../../flash';
+import { sprintf, __ } from '~/locale';
const ResolveBtn = Vue.extend({
props: {
@@ -55,12 +56,14 @@ const ResolveBtn = Vue.extend({
},
buttonText() {
if (this.isResolved) {
- return `Resolved by ${this.resolvedByName}`;
+ return sprintf(__('Resolved by %{resolvedByName}'), {
+ resolvedByName: this.resolvedByName,
+ });
} else if (this.canResolve) {
- return 'Mark as resolved';
+ return __('Mark as resolved');
}
- return 'Unable to resolve';
+ return __('Unable to resolve');
},
isResolved() {
if (this.note) {
@@ -132,7 +135,8 @@ const ResolveBtn = Vue.extend({
this.updateTooltip();
})
.catch(
- () => new Flash('An error occurred when trying to resolve a comment. Please try again.'),
+ () =>
+ new Flash(__('An error occurred when trying to resolve a comment. Please try again.')),
);
},
},
diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
index 6fcad187b35..4b204fdfeb0 100644
--- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
+++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js
@@ -3,6 +3,7 @@
/* global ResolveService */
import Vue from 'vue';
+import { __ } from '~/locale';
const ResolveDiscussionBtn = Vue.extend({
props: {
@@ -41,9 +42,9 @@ const ResolveDiscussionBtn = Vue.extend({
},
buttonText: function() {
if (this.isDiscussionResolved) {
- return 'Unresolve discussion';
+ return __('Unresolve discussion');
} else {
- return 'Resolve discussion';
+ return __('Resolve discussion');
}
},
loading: function() {
diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js
index e69eaad4423..0687028ca54 100644
--- a/app/assets/javascripts/diff_notes/services/resolve.js
+++ b/app/assets/javascripts/diff_notes/services/resolve.js
@@ -3,6 +3,7 @@
import Vue from 'vue';
import Flash from '../../flash';
import '../../vue_shared/vue_resource_interceptor';
+import { __ } from '~/locale';
window.gl = window.gl || {};
@@ -49,7 +50,8 @@ class ResolveServiceClass {
discussion.updateHeadline(data);
})
.catch(
- () => new Flash('An error occurred when trying to resolve a discussion. Please try again.'),
+ () =>
+ new Flash(__('An error occurred when trying to resolve a discussion. Please try again.')),
);
}
diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue
index d4c1b07093d..11d6672cacf 100644
--- a/app/assets/javascripts/diffs/components/app.vue
+++ b/app/assets/javascripts/diffs/components/app.vue
@@ -4,6 +4,8 @@ import Icon from '~/vue_shared/components/icon.vue';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
+import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
+import Mousetrap from 'mousetrap';
import eventHub from '../../notes/event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
@@ -11,6 +13,15 @@ import NoChanges from './no_changes.vue';
import HiddenFilesWarning from './hidden_files_warning.vue';
import CommitWidget from './commit_widget.vue';
import TreeList from './tree_list.vue';
+import {
+ TREE_LIST_WIDTH_STORAGE_KEY,
+ INITIAL_TREE_WIDTH,
+ MIN_TREE_WIDTH,
+ MAX_TREE_WIDTH,
+ TREE_HIDE_STATS_WIDTH,
+ MR_TREE_SHOW_KEY,
+ CENTERED_LIMITED_CONTAINER_CLASSES,
+} from '../constants';
export default {
name: 'DiffsApp',
@@ -23,6 +34,7 @@ export default {
CommitWidget,
TreeList,
GlLoadingIcon,
+ PanelResizer,
},
props: {
endpoint: {
@@ -52,10 +64,19 @@ export default {
required: false,
default: '',
},
+ isFluidLayout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
+ const treeWidth =
+ parseInt(localStorage.getItem(TREE_LIST_WIDTH_STORAGE_KEY), 10) || INITIAL_TREE_WIDTH;
+
return {
assignedDiscussions: false,
+ treeWidth,
};
},
computed: {
@@ -74,7 +95,7 @@ export default {
emailPatchPath: state => state.diffs.emailPatchPath,
}),
...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion']),
- ...mapGetters('diffs', ['isParallelView']),
+ ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
targetBranch() {
return {
@@ -96,6 +117,12 @@ export default {
this.startVersion.version_index === this.mergeRequestDiff.version_index)
);
},
+ hideFileStats() {
+ return this.treeWidth <= TREE_HIDE_STATS_WIDTH;
+ },
+ isLimitedContainer() {
+ return !this.showTreeList && !this.isParallelView && !this.isFluidLayout;
+ },
},
watch: {
diffViewType() {
@@ -129,6 +156,14 @@ export default {
created() {
this.adjustView();
eventHub.$once('fetchedNotesData', this.setDiscussions);
+ eventHub.$once('fetchDiffData', this.fetchData);
+ eventHub.$on('refetchDiffData', this.refetchDiffData);
+ this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
+ },
+ beforeDestroy() {
+ eventHub.$off('fetchDiffData', this.fetchData);
+ eventHub.$off('refetchDiffData', this.refetchDiffData);
+ this.removeEventListeners();
},
methods: {
...mapActions(['startTaskList']),
@@ -138,10 +173,21 @@ export default {
'startRenderDiffsQueue',
'assignDiscussionsToDiff',
'setHighlightedRow',
+ 'cacheTreeListWidth',
+ 'scrollToFile',
+ 'toggleShowTreeList',
]),
- fetchData() {
+ refetchDiffData() {
+ this.assignedDiscussions = false;
+ this.fetchData(false);
+ },
+ fetchData(toggleTree = true) {
this.fetchDiffFiles()
.then(() => {
+ if (toggleTree) {
+ this.hideTreeListIfJustOneFile();
+ }
+
requestIdleCallback(
() => {
this.setDiscussions();
@@ -174,12 +220,47 @@ export default {
adjustView() {
if (this.shouldShow) {
this.$nextTick(() => {
- window.mrTabs.resetViewContainer();
- window.mrTabs.expandViewContainer(this.showTreeList);
+ this.setEventListeners();
});
+ } else {
+ this.removeEventListeners();
+ }
+ },
+ setEventListeners() {
+ Mousetrap.bind(['[', 'k', ']', 'j'], (e, combo) => {
+ switch (combo) {
+ case '[':
+ case 'k':
+ this.jumpToFile(-1);
+ break;
+ case ']':
+ case 'j':
+ this.jumpToFile(+1);
+ break;
+ default:
+ break;
+ }
+ });
+ },
+ removeEventListeners() {
+ Mousetrap.unbind(['[', 'k', ']', 'j']);
+ },
+ jumpToFile(step) {
+ const targetIndex = this.currentDiffIndex + step;
+ if (targetIndex >= 0 && targetIndex < this.diffFiles.length) {
+ this.scrollToFile(this.diffFiles[targetIndex].file_path);
+ }
+ },
+ hideTreeListIfJustOneFile() {
+ const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
+
+ if ((storedTreeShow === null && this.diffFiles.length <= 1) || storedTreeShow === 'false') {
+ this.toggleShowTreeList(false);
}
},
},
+ minTreeWidth: MIN_TREE_WIDTH,
+ maxTreeWidth: MAX_TREE_WIDTH,
};
</script>
@@ -191,6 +272,7 @@ export default {
:merge-request-diffs="mergeRequestDiffs"
:merge-request-diff="mergeRequestDiff"
:target-branch="targetBranch"
+ :is-limited-container="isLimitedContainer"
/>
<hidden-files-warning
@@ -205,8 +287,27 @@ export default {
:data-can-create-note="getNoteableData.current_user.can_create_note"
class="files d-flex prepend-top-default"
>
- <div v-show="showTreeList" class="diff-tree-list"><tree-list /></div>
- <div class="diff-files-holder">
+ <div
+ v-show="showTreeList"
+ :style="{ width: `${treeWidth}px` }"
+ class="diff-tree-list js-diff-tree-list"
+ >
+ <panel-resizer
+ :size.sync="treeWidth"
+ :start-size="treeWidth"
+ :min-size="$options.minTreeWidth"
+ :max-size="$options.maxTreeWidth"
+ side="right"
+ @resize-end="cacheTreeListWidth"
+ />
+ <tree-list :hide-file-stats="hideFileStats" />
+ </div>
+ <div
+ class="diff-files-holder"
+ :class="{
+ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
+ }"
+ >
<commit-widget v-if="commit" :commit="commit" />
<template v-if="renderDiffFiles">
<diff-file
diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue
index ebc4a83af4d..a767379d662 100644
--- a/app/assets/javascripts/diffs/components/commit_item.vue
+++ b/app/assets/javascripts/diffs/components/commit_item.vue
@@ -5,6 +5,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CIIcon from '~/vue_shared/components/ci_icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue';
+import initUserPopovers from '../../user_popovers';
/**
* CommitItem
@@ -35,20 +36,30 @@ export default {
},
},
computed: {
+ author() {
+ return this.commit.author || {};
+ },
authorName() {
- return (this.commit.author && this.commit.author.name) || this.commit.author_name;
+ return this.author.name || this.commit.author_name;
+ },
+ authorClass() {
+ return this.author.name ? 'js-user-link' : '';
+ },
+ authorId() {
+ return this.author.id ? this.author.id : '';
},
authorUrl() {
- return (
- (this.commit.author && this.commit.author.web_url) || `mailto:${this.commit.author_email}`
- );
+ return this.author.web_url || `mailto:${this.commit.author_email}`;
},
authorAvatar() {
- return (
- (this.commit.author && this.commit.author.avatar_url) || this.commit.author_gravatar_url
- );
+ return this.author.avatar_url || this.commit.author_gravatar_url;
},
},
+ created() {
+ this.$nextTick(() => {
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ });
+ },
};
</script>
@@ -81,7 +92,13 @@ export default {
</button>
<div class="commiter">
- <a :href="authorUrl" v-text="authorName"></a> {{ s__('CommitWidget|authored') }}
+ <a
+ :href="authorUrl"
+ :class="authorClass"
+ :data-user-id="authorId"
+ v-text="authorName"
+ ></a>
+ {{ s__('CommitWidget|authored') }}
<time-ago-tooltip :time="commit.authored_date" />
</div>
@@ -96,9 +113,10 @@ export default {
<commit-pipeline-status
v-if="commit.pipeline_status_path"
:endpoint="commit.pipeline_status_path"
+ class="d-inline-flex"
/>
<div class="commit-sha-group">
- <div class="label label-monospace" v-text="commit.short_id"></div>
+ <div class="label label-monospace monospace" v-text="commit.short_id"></div>
<clipboard-button
:text="commit.id"
:title="__('Copy commit SHA to clipboard')"
diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue
index f75345d31f8..363ebad1594 100644
--- a/app/assets/javascripts/diffs/components/compare_versions.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions.vue
@@ -2,9 +2,12 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
-import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility';
+import { polyfillSticky } from '~/lib/utils/sticky';
import Icon from '~/vue_shared/components/icon.vue';
import CompareVersionsDropdown from './compare_versions_dropdown.vue';
+import SettingsDropdown from './settings_dropdown.vue';
+import DiffStats from './diff_stats.vue';
+import { CENTERED_LIMITED_CONTAINER_CLASSES } from '../constants';
export default {
components: {
@@ -12,6 +15,8 @@ export default {
Icon,
GlLink,
GlButton,
+ SettingsDropdown,
+ DiffStats,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -31,29 +36,43 @@ export default {
required: false,
default: null,
},
+ isLimitedContainer: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
- ...mapState('diffs', ['commit', 'showTreeList', 'startVersion', 'latestVersionPath']),
- ...mapGetters('diffs', ['isInlineView', 'isParallelView', 'hasCollapsedFile']),
+ ...mapGetters('diffs', ['hasCollapsedFile', 'diffFilesLength']),
+ ...mapState('diffs', [
+ 'commit',
+ 'showTreeList',
+ 'startVersion',
+ 'latestVersionPath',
+ 'addedLines',
+ 'removedLines',
+ ]),
comparableDiffs() {
return this.mergeRequestDiffs.slice(1);
},
- toggleWhitespaceText() {
- if (this.isWhitespaceVisible()) {
- return __('Hide whitespace changes');
- }
- return __('Show whitespace changes');
- },
- toggleWhitespacePath() {
- if (this.isWhitespaceVisible()) {
- return mergeUrlParams({ w: 1 }, window.location.href);
- }
-
- return mergeUrlParams({ w: 0 }, window.location.href);
- },
showDropdowns() {
return !this.commit && this.mergeRequestDiffs.length;
},
+ fileTreeIcon() {
+ return this.showTreeList ? 'collapse-left' : 'expand-left';
+ },
+ toggleFileBrowserTitle() {
+ return this.showTreeList ? __('Hide file browser') : __('Show file browser');
+ },
+ baseVersionPath() {
+ return this.mergeRequestDiff.base_version_path;
+ },
+ },
+ created() {
+ this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
+ },
+ mounted() {
+ polyfillSticky(this.$el);
},
methods: {
...mapActions('diffs', [
@@ -62,16 +81,18 @@ export default {
'expandAllFiles',
'toggleShowTreeList',
]),
- isWhitespaceVisible() {
- return getParameterValues('w')[0] !== '1';
- },
},
};
</script>
<template>
- <div class="mr-version-controls">
- <div class="mr-version-menus-container content-block">
+ <div class="mr-version-controls border-top border-bottom">
+ <div
+ class="mr-version-menus-container content-block"
+ :class="{
+ [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer,
+ }"
+ >
<button
v-gl-tooltip.hover
type="button"
@@ -79,10 +100,10 @@ export default {
:class="{
active: showTreeList,
}"
- :title="__('Toggle file browser')"
+ :title="toggleFileBrowserTitle"
@click="toggleShowTreeList"
>
- <icon name="hamburger" />
+ <icon :name="fileTreeIcon" />
</button>
<div v-if="showDropdowns" class="d-flex align-items-center compare-versions-container">
Changes between
@@ -95,6 +116,7 @@ export default {
and
<compare-versions-dropdown
:other-versions="comparableDiffs"
+ :base-version-path="baseVersionPath"
:start-version="startVersion"
:target-branch="targetBranch"
class="mr-version-compare-dropdown"
@@ -105,6 +127,11 @@ export default {
<gl-link :href="commit.commit_url" class="monospace">{{ commit.short_id }}</gl-link>
</div>
<div class="inline-parallel-buttons d-none d-md-flex ml-auto">
+ <diff-stats
+ :diff-files-length="diffFilesLength"
+ :added-lines="addedLines"
+ :removed-lines="removedLines"
+ />
<gl-button
v-if="commit || startVersion"
:href="latestVersionPath"
@@ -112,34 +139,10 @@ export default {
>
{{ __('Show latest version') }}
</gl-button>
- <a v-show="hasCollapsedFile" class="btn btn-default append-right-8" @click="expandAllFiles">
+ <gl-button v-show="hasCollapsedFile" class="append-right-8" @click="expandAllFiles">
{{ __('Expand all') }}
- </a>
- <a :href="toggleWhitespacePath" class="btn btn-default qa-toggle-whitespace">
- {{ toggleWhitespaceText }}
- </a>
- <div class="btn-group prepend-left-8">
- <button
- id="inline-diff-btn"
- :class="{ active: isInlineView }"
- type="button"
- class="btn js-inline-diff-button"
- data-view-type="inline"
- @click="setInlineDiffViewType"
- >
- {{ __('Inline') }}
- </button>
- <button
- id="parallel-diff-btn"
- :class="{ active: isParallelView }"
- type="button"
- class="btn js-parallel-diff-button"
- data-view-type="parallel"
- @click="setParallelDiffViewType"
- >
- {{ __('Side-by-side') }}
- </button>
- </div>
+ </gl-button>
+ <settings-dropdown />
</div>
</div>
</div>
diff --git a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
index 8da02ed0b7c..80aec84f574 100644
--- a/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
+++ b/app/assets/javascripts/diffs/components/compare_versions_dropdown.vue
@@ -34,14 +34,13 @@ export default {
required: false,
default: false,
},
+ baseVersionPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
},
computed: {
- baseVersion() {
- return {
- name: 'hii',
- versionIndex: -1,
- };
- },
targetVersions() {
if (this.mergeRequestVersion) {
return this.otherVersions;
@@ -62,6 +61,9 @@ export default {
);
},
href(version) {
+ if (this.isBase(version)) {
+ return this.baseVersionPath;
+ }
if (this.showCommitCount) {
return version.version_path;
}
@@ -129,7 +131,7 @@ export default {
</strong>
</div>
<div>
- <small class="commit-sha"> {{ version.truncated_commit_sha }} </small>
+ <small class="commit-sha"> {{ version.short_commit_sha }} </small>
</div>
<div>
<small>
@@ -139,7 +141,7 @@ export default {
<time-ago
v-if="version.created_at"
:time="version.created_at"
- class="js-timeago js-timeago-render"
+ class="js-timeago"
/>
</small>
</div>
diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue
index 42d09e44768..2b3d6d1a3fa 100644
--- a/app/assets/javascripts/diffs/components/diff_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_content.vue
@@ -1,7 +1,11 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
-import EmptyFileViewer from '~/vue_shared/components/diff_viewer/viewers/empty_file.vue';
+import NotDiffableViewer from '~/vue_shared/components/diff_viewer/viewers/not_diffable.vue';
+import NoPreviewViewer from '~/vue_shared/components/diff_viewer/viewers/no_preview.vue';
import InlineDiffView from './inline_diff_view.vue';
import ParallelDiffView from './parallel_diff_view.vue';
import NoteForm from '../../notes/components/note_form.vue';
@@ -9,17 +13,22 @@ import ImageDiffOverlay from './image_diff_overlay.vue';
import DiffDiscussions from './diff_discussions.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '../constants';
import { getDiffMode } from '../store/utils';
+import { diffViewerModes } from '~/ide/constants';
export default {
components: {
+ GlLoadingIcon,
InlineDiffView,
ParallelDiffView,
DiffViewer,
NoteForm,
DiffDiscussions,
ImageDiffOverlay,
- EmptyFileViewer,
+ NotDiffableViewer,
+ NoPreviewViewer,
+ DiffFileDrafts: () => import('ee_component/batch_comments/components/diff_file_drafts.vue'),
},
+ mixins: [diffLineNoteFormMixin, draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -42,14 +51,26 @@ export default {
diffMode() {
return getDiffMode(this.diffFile);
},
+ diffViewerMode() {
+ return this.diffFile.viewer.name;
+ },
isTextFile() {
- return this.diffFile.viewer.name === 'text';
+ return this.diffViewerMode === diffViewerModes.text;
+ },
+ noPreview() {
+ return this.diffViewerMode === diffViewerModes.no_preview;
+ },
+ notDiffable() {
+ return this.diffViewerMode === diffViewerModes.not_diffable;
},
diffFileCommentForm() {
- return this.getCommentFormForDiffFile(this.diffFile.file_hash);
+ return this.getCommentFormForDiffFile(this.diffFileHash);
},
showNotesContainer() {
- return this.diffFile.discussions.length || this.diffFileCommentForm;
+ return this.imageDiscussions.length || this.diffFileCommentForm;
+ },
+ diffFileHash() {
+ return this.diffFile.file_hash;
},
},
methods: {
@@ -77,9 +98,8 @@ export default {
<div class="diff-content">
<div class="diff-viewer">
<template v-if="isTextFile">
- <empty-file-viewer v-if="diffFile.empty" />
<inline-diff-view
- v-else-if="isInlineView"
+ v-if="isInlineView"
:diff-file="diffFile"
:diff-lines="diffFile.highlighted_diff_lines || []"
:help-page-path="helpPagePath"
@@ -90,23 +110,27 @@ export default {
:diff-lines="diffFile.parallel_diff_lines || []"
:help-page-path="helpPagePath"
/>
+ <gl-loading-icon v-if="diffFile.renderingLines" size="md" class="mt-3" />
</template>
+ <not-diffable-viewer v-else-if="notDiffable" />
+ <no-preview-viewer v-else-if="noPreview" />
<diff-viewer
v-else
:diff-mode="diffMode"
+ :diff-viewer-mode="diffViewerMode"
:new-path="diffFile.new_path"
:new-sha="diffFile.diff_refs.head_sha"
:old-path="diffFile.old_path"
:old-sha="diffFile.diff_refs.base_sha"
- :file-hash="diffFile.file_hash"
+ :file-hash="diffFileHash"
:project-path="projectPath"
:a-mode="diffFile.a_mode"
:b-mode="diffFile.b_mode"
>
<image-diff-overlay
slot="image-overlay"
- :discussions="diffFile.discussions"
- :file-hash="diffFile.file_hash"
+ :discussions="imageDiscussions"
+ :file-hash="diffFileHash"
:can-comment="getNoteableData.current_user.can_create_note"
/>
<div v-if="showNotesContainer" class="note-container">
@@ -117,14 +141,16 @@ export default {
:should-collapse-discussions="true"
:render-avatar-badge="true"
/>
+ <diff-file-drafts :file-hash="diffFileHash" class="diff-file-discussions" />
<note-form
v-if="diffFileCommentForm"
ref="noteForm"
:is-editing="false"
:save-button-title="__('Comment')"
class="diff-comment-form new-note discussion-form discussion-form-container"
+ @handleFormUpdateAddToReview="addToReview"
@handleFormUpdate="handleSaveNote"
- @cancelForm="closeDiffFileCommentForm(diffFile.file_hash);"
+ @cancelForm="closeDiffFileCommentForm(diffFileHash)"
/>
</div>
</diff-viewer>
diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue
index b2021cd6061..4c73eea4049 100644
--- a/app/assets/javascripts/diffs/components/diff_discussions.vue
+++ b/app/assets/javascripts/diffs/components/diff_discussions.vue
@@ -68,7 +68,7 @@ export default {
}"
type="button"
class="js-diff-notes-toggle"
- @click="toggleDiscussion({ discussionId: discussion.id });"
+ @click="toggleDiscussion({ discussionId: discussion.id })"
>
<icon v-if="discussion.expanded" name="collapse" class="collapse-icon" />
<template v-else>
diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue
index 449f7007077..f5876a73eff 100644
--- a/app/assets/javascripts/diffs/components/diff_file.vue
+++ b/app/assets/javascripts/diffs/components/diff_file.vue
@@ -7,6 +7,7 @@ import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
+import { diffViewerErrors } from '~/ide/constants';
export default {
components: {
@@ -33,15 +34,13 @@ export default {
return {
isLoadingCollapsedDiff: false,
forkMessageVisible: false,
+ isCollapsed: this.file.viewer.collapsed || false,
};
},
computed: {
...mapState('diffs', ['currentDiffFileId']),
...mapGetters(['isNotesFetched']),
...mapGetters('diffs', ['getDiffFileDiscussions']),
- isCollapsed() {
- return this.file.collapsed || false;
- },
viewBlobLink() {
return sprintf(
__('You can %{linkStart}view the blob%{linkEnd} instead.'),
@@ -52,17 +51,6 @@ export default {
false,
);
},
- showExpandMessage() {
- return (
- this.isCollapsed ||
- (!this.file.highlighted_diff_lines &&
- !this.isLoadingCollapsedDiff &&
- !this.file.too_large &&
- this.file.text &&
- !this.file.renamed_file &&
- !this.file.mode_changed)
- );
- },
showLoadingIcon() {
return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed);
},
@@ -73,25 +61,41 @@ export default {
this.file.parallel_diff_lines.length > 0
);
},
+ isFileTooLarge() {
+ return this.file.viewer.error === diffViewerErrors.too_large;
+ },
+ errorMessage() {
+ return this.file.viewer.error_message;
+ },
},
watch: {
- 'file.collapsed': function fileCollapsedWatch(newVal, oldVal) {
+ isCollapsed: function fileCollapsedWatch(newVal, oldVal) {
if (!newVal && oldVal && !this.hasDiffLines) {
this.handleLoadCollapsedDiff();
}
+
+ this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal });
+ },
+ 'file.viewer.collapsed': function setIsCollapsed(newVal) {
+ this.isCollapsed = newVal;
},
},
created() {
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
},
methods: {
- ...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']),
+ ...mapActions('diffs', [
+ 'loadCollapsedDiff',
+ 'assignDiscussionsToDiff',
+ 'setRenderIt',
+ 'setFileCollapsed',
+ ]),
handleToggle() {
if (!this.hasDiffLines) {
this.handleLoadCollapsedDiff();
} else {
- this.file.collapsed = !this.file.collapsed;
- this.file.renderIt = true;
+ this.isCollapsed = !this.isCollapsed;
+ this.setRenderIt(this.file);
}
},
handleLoadCollapsedDiff() {
@@ -100,8 +104,8 @@ export default {
this.loadCollapsedDiff(this.file)
.then(() => {
this.isLoadingCollapsedDiff = false;
- this.file.collapsed = false;
- this.file.renderIt = true;
+ this.isCollapsed = false;
+ this.setRenderIt(this.file);
})
.then(() => {
requestIdleCallback(
@@ -164,24 +168,26 @@ export default {
Cancel
</button>
</div>
-
- <diff-content
- v-if="!isCollapsed && file.renderIt"
- :class="{ hidden: isCollapsed || file.too_large }"
- :diff-file="file"
- :help-page-path="helpPagePath"
- />
<gl-loading-icon v-if="showLoadingIcon" class="diff-content loading" />
- <div v-else-if="showExpandMessage" class="nothing-here-block diff-collapsed">
- {{ __('This diff is collapsed.') }}
- <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
- __('Click to expand it.')
- }}</a>
- </div>
- <div v-if="file.too_large" class="nothing-here-block diff-collapsed js-too-large-diff">
- {{ __('This source diff could not be displayed because it is too large.') }}
- <span v-html="viewBlobLink"></span>
- </div>
+ <template v-else>
+ <div :id="`diff-content-${file.file_hash}`">
+ <div v-if="errorMessage" class="diff-viewer">
+ <div class="nothing-here-block" v-html="errorMessage"></div>
+ </div>
+ <div v-else-if="isCollapsed" class="nothing-here-block diff-collapsed">
+ {{ __('This diff is collapsed.') }}
+ <a class="click-to-expand js-click-to-expand" href="#" @click.prevent="handleToggle">{{
+ __('Click to expand it.')
+ }}</a>
+ </div>
+ <diff-content
+ v-else
+ :class="{ hidden: isCollapsed || isFileTooLarge }"
+ :diff-file="file"
+ :help-page-path="helpPagePath"
+ />
+ </div>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue
index f75a01b023b..392de1c9f23 100644
--- a/app/assets/javascripts/diffs/components/diff_file_header.vue
+++ b/app/assets/javascripts/diffs/components/diff_file_header.vue
@@ -1,21 +1,28 @@
<script>
import _ from 'underscore';
import { mapActions, mapGetters } from 'vuex';
-import { polyfillSticky } from '~/lib/utils/sticky';
+import { polyfillSticky, stickyMonitor } from '~/lib/utils/sticky';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
-import { GlTooltipDirective } from '@gitlab/ui';
+import { GlButton, GlTooltipDirective, GlTooltip, GlLoadingIcon } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { __, s__, sprintf } from '~/locale';
+import { diffViewerModes } from '~/ide/constants';
import EditButton from './edit_button.vue';
+import DiffStats from './diff_stats.vue';
+import { scrollToElement, contentTop } from '~/lib/utils/common_utils';
export default {
components: {
+ GlTooltip,
+ GlLoadingIcon,
+ GlButton,
ClipboardButton,
EditButton,
Icon,
FileIcon,
+ DiffStats,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -60,6 +67,9 @@ export default {
hasExpandedDiscussions() {
return this.diffHasExpandedDiscussions(this.diffFile);
},
+ diffContentIDSelector() {
+ return `#diff-content-${this.diffFile.file_hash}`;
+ },
icon() {
if (this.diffFile.submodule) {
return 'archive';
@@ -71,6 +81,11 @@ export default {
if (this.diffFile.submodule) {
return this.diffFile.submodule_tree_url || this.diffFile.submodule_link;
}
+
+ if (!this.discussionPath) {
+ return this.diffContentIDSelector;
+ }
+
return this.discussionPath;
},
filePath() {
@@ -97,9 +112,7 @@ export default {
const truncatedContentSha = _.escape(truncateSha(this.diffFile.content_sha));
return sprintf(
s__('MergeRequests|View file @ %{commitId}'),
- {
- commitId: `<span class="commit-sha">${truncatedContentSha}</span>`,
- },
+ { commitId: truncatedContentSha },
false,
);
},
@@ -116,12 +129,29 @@ export default {
gfmCopyText() {
return `\`${this.diffFile.file_path}\``;
},
+ isFileRenamed() {
+ return this.diffFile.viewer.name === diffViewerModes.renamed;
+ },
+ isModeChanged() {
+ return this.diffFile.viewer.name === diffViewerModes.mode_changed;
+ },
+ showExpandDiffToFullFileEnabled() {
+ return gon.features.expandDiffFullFile && !this.diffFile.is_fully_expanded;
+ },
+ expandDiffToFullFileTitle() {
+ if (this.diffFile.isShowingFullFile) {
+ return s__('MRDiff|Show changes only');
+ }
+ return s__('MRDiff|Show full file');
+ },
},
mounted() {
polyfillSticky(this.$refs.header);
+ const fileHeaderHeight = this.$refs.header.clientHeight;
+ stickyMonitor(this.$refs.header, contentTop() - fileHeaderHeight - 1, false);
},
methods: {
- ...mapActions('diffs', ['toggleFileDiscussions']),
+ ...mapActions('diffs', ['toggleFileDiscussions', 'toggleFullDiff']),
handleToggleFile(e, checkTarget) {
if (
!checkTarget ||
@@ -137,6 +167,18 @@ export default {
handleToggleDiscussions() {
this.toggleFileDiscussions(this.diffFile);
},
+ handleFileNameClick(e) {
+ const isLinkToOtherPage =
+ this.diffFile.submodule_tree_url || this.diffFile.submodule_link || this.discussionPath;
+
+ if (!isLinkToOtherPage) {
+ e.preventDefault();
+ const selector = this.diffContentIDSelector;
+
+ scrollToElement(document.querySelector(selector));
+ window.location.hash = selector;
+ }
+ },
},
};
</script>
@@ -145,7 +187,7 @@ export default {
<div
ref="header"
class="js-file-title file-title file-title-flex-parent"
- @click="handleToggleFile($event, true);"
+ @click="handleToggleFile($event, true)"
>
<div class="file-header-content">
<icon
@@ -156,14 +198,21 @@ export default {
class="diff-toggle-caret append-right-5"
@click.stop="handleToggle"
/>
- <a v-once ref="titleWrapper" :href="titleLink" class="append-right-4 js-title-wrapper">
+ <a
+ v-once
+ id="diffFile.file_path"
+ ref="titleWrapper"
+ class="append-right-4 js-title-wrapper"
+ :href="titleLink"
+ @click="handleFileNameClick"
+ >
<file-icon
:file-name="filePath"
:size="18"
aria-hidden="true"
css-classes="js-file-icon append-right-5"
/>
- <span v-if="diffFile.renamed_file">
+ <span v-if="isFileRenamed">
<strong
v-gl-tooltip
:title="diffFile.old_path"
@@ -191,7 +240,7 @@ export default {
css-class="btn-default btn-transparent btn-clipboard"
/>
- <small v-if="diffFile.mode_changed" ref="fileMode">
+ <small v-if="isModeChanged" ref="fileMode">
{{ diffFile.a_mode }} → {{ diffFile.b_mode }}
</small>
@@ -202,48 +251,71 @@ export default {
v-if="!diffFile.submodule && addMergeRequestButtons"
class="file-actions d-none d-sm-block"
>
- <template v-if="diffFile.blob && diffFile.blob.readable_text">
- <button
- :disabled="!diffHasDiscussions(diffFile)"
- :class="{ active: hasExpandedDiscussions }"
- :title="s__('MergeRequests|Toggle comments for this file')"
- class="js-btn-vue-toggle-comments btn"
- type="button"
- @click="handleToggleDiscussions"
- >
- <icon name="comment" />
- </button>
-
- <edit-button
- v-if="!diffFile.deleted_file"
- :can-current-user-fork="canCurrentUserFork"
- :edit-path="diffFile.edit_path"
- :can-modify-blob="diffFile.can_modify_blob"
- @showForkMessage="showForkMessage"
- />
- </template>
+ <diff-stats :added-lines="diffFile.added_lines" :removed-lines="diffFile.removed_lines" />
+ <div class="btn-group" role="group">
+ <template v-if="diffFile.blob && diffFile.blob.readable_text">
+ <button
+ :disabled="!diffHasDiscussions(diffFile)"
+ :class="{ active: hasExpandedDiscussions }"
+ :title="s__('MergeRequests|Toggle comments for this file')"
+ class="js-btn-vue-toggle-comments btn"
+ type="button"
+ @click="handleToggleDiscussions"
+ >
+ <icon name="comment" />
+ </button>
- <a
- v-if="diffFile.replaced_view_path"
- :href="diffFile.replaced_view_path"
- class="btn view-file js-view-file"
- v-html="viewReplacedFileButtonText"
- >
- </a>
- <a :href="diffFile.view_path" class="btn view-file js-view-file" v-html="viewFileButtonText">
- </a>
+ <edit-button
+ v-if="!diffFile.deleted_file"
+ :can-current-user-fork="canCurrentUserFork"
+ :edit-path="diffFile.edit_path"
+ :can-modify-blob="diffFile.can_modify_blob"
+ @showForkMessage="showForkMessage"
+ />
+ </template>
- <a
- v-if="diffFile.external_url"
- v-gl-tooltip.hover
- :href="diffFile.external_url"
- :title="`View on ${diffFile.formatted_external_url}`"
- target="_blank"
- rel="noopener noreferrer"
- class="btn btn-file-option"
- >
- <icon name="external-link" />
- </a>
+ <a
+ v-if="diffFile.replaced_view_path"
+ :href="diffFile.replaced_view_path"
+ class="btn view-file js-view-replaced-file"
+ v-html="viewReplacedFileButtonText"
+ >
+ </a>
+ <gl-button
+ v-if="!diffFile.is_fully_expanded"
+ ref="expandDiffToFullFileButton"
+ v-gl-tooltip.hover
+ :title="expandDiffToFullFileTitle"
+ class="expand-file js-expand-file"
+ @click="toggleFullDiff(diffFile.file_path)"
+ >
+ <gl-loading-icon v-if="diffFile.isLoadingFullFile" color="dark" inline />
+ <icon v-else-if="diffFile.isShowingFullFile" name="doc-changes" />
+ <icon v-else name="doc-expand" />
+ </gl-button>
+ <gl-button
+ ref="viewButton"
+ v-gl-tooltip.hover
+ :href="diffFile.view_path"
+ target="blank"
+ class="view-file js-view-file-button"
+ :title="viewFileButtonText"
+ >
+ <icon name="doc-text" />
+ </gl-button>
+
+ <a
+ v-if="diffFile.external_url"
+ v-gl-tooltip.hover
+ :href="diffFile.external_url"
+ :title="`View on ${diffFile.formatted_external_url}`"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="btn btn-file-option js-external-url"
+ >
+ <icon name="external-link" />
+ </a>
+ </div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
index c0613d80d37..1281f9b17ef 100644
--- a/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_gutter_content.vue
@@ -84,8 +84,6 @@ export default {
},
shouldShowCommentButton() {
return (
- this.isLoggedIn &&
- this.showCommentButton &&
this.isHover &&
!this.isMatchLine &&
!this.isContextLine &&
@@ -102,6 +100,9 @@ export default {
}
return this.showCommentButton && this.hasDiscussions;
},
+ shouldRenderCommentButton() {
+ return this.isLoggedIn && this.showCommentButton;
+ },
},
methods: {
...mapActions('diffs', ['loadMoreLines', 'showCommentForm', 'setHighlightedRow']),
@@ -167,6 +168,7 @@ export default {
>
<template v-else>
<button
+ v-if="shouldRenderCommentButton"
v-show="shouldShowCommentButton"
type="button"
class="add-diff-note js-add-diff-note-button qa-diff-comment"
@@ -179,7 +181,7 @@ export default {
v-if="lineNumber"
:data-linenumber="lineNumber"
:href="lineHref"
- @click="setHighlightedRow(lineCode);"
+ @click="setHighlightedRow(lineCode)"
>
</a>
<diff-gutter-avatars v-if="shouldShowAvatarsOnGutter" :discussions="line.discussions" />
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index e7569ba7b84..41670b45798 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -1,6 +1,7 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import { s__ } from '~/locale';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import noteForm from '../../notes/components/note_form.vue';
import autosave from '../../notes/mixins/autosave';
import { DIFF_NOTE_TYPE } from '../constants';
@@ -9,7 +10,7 @@ export default {
components: {
noteForm,
},
- mixins: [autosave],
+ mixins: [autosave, diffLineNoteFormMixin],
props: {
diffFileHash: {
type: String,
@@ -28,6 +29,11 @@ export default {
type: Object,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
...mapState({
@@ -42,10 +48,13 @@ export default {
noteableType: this.noteableType,
noteTargetLine: this.noteTargetLine,
diffViewType: this.diffViewType,
- diffFile: this.getDiffFileByHash(this.diffFileHash),
+ diffFile: this.diffFile,
linePosition: this.linePosition,
};
},
+ diffFile() {
+ return this.getDiffFileByHash(this.diffFileHash);
+ },
},
mounted() {
if (this.isLoggedIn) {
@@ -95,8 +104,11 @@ export default {
:is-editing="true"
:line-code="line.line_code"
:line="line"
+ :help-page-path="helpPagePath"
+ :diff-file="diffFile"
save-button-title="Comment"
class="diff-comment-form"
+ @handleFormUpdateAddToReview="addToReview"
@cancelForm="handleCancelCommentForm"
@handleFormUpdate="handleSaveNote"
/>
diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue
new file mode 100644
index 00000000000..2e5855380af
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/diff_stats.vue
@@ -0,0 +1,52 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { n__ } from '~/locale';
+
+export default {
+ components: { Icon },
+ props: {
+ addedLines: {
+ type: Number,
+ required: true,
+ },
+ removedLines: {
+ type: Number,
+ required: true,
+ },
+ diffFilesLength: {
+ type: Number,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ filesText() {
+ return n__('File', 'Files', this.diffFilesLength);
+ },
+ isCompareVersionsHeader() {
+ return Boolean(this.diffFilesLength);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="diff-stats"
+ :class="{
+ 'is-compare-versions-header d-none d-lg-inline-flex': isCompareVersionsHeader,
+ 'd-inline-flex': !isCompareVersionsHeader,
+ }"
+ >
+ <div v-if="diffFilesLength !== null" class="diff-stats-group">
+ <icon name="doc-code" class="diff-stats-icon text-secondary" />
+ <strong>{{ diffFilesLength }} {{ filesText }}</strong>
+ </div>
+ <div class="diff-stats-group cgreen">
+ <icon name="file-addition" class="diff-stats-icon" /> <strong>{{ addedLines }}</strong>
+ </div>
+ <div class="diff-stats-group cred">
+ <icon name="file-deletion" class="diff-stats-icon" /> <strong>{{ removedLines }}</strong>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue
index d174b13e133..0f3e9208d21 100644
--- a/app/assets/javascripts/diffs/components/diff_table_cell.vue
+++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue
@@ -89,17 +89,19 @@ export default {
classNameMap() {
const { type } = this.line;
- return {
- hll: this.isHighlighted,
- [type]: type,
- [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
- [LINE_HOVER_CLASS_NAME]:
- this.isLoggedIn &&
- this.isHover &&
- !this.isMatchLine &&
- !this.isContextLine &&
- !this.isMetaLine,
- };
+ return [
+ type,
+ {
+ hll: this.isHighlighted,
+ [LINE_UNFOLD_CLASS_NAME]: this.isMatchLine,
+ [LINE_HOVER_CLASS_NAME]:
+ this.isLoggedIn &&
+ this.isHover &&
+ !this.isMatchLine &&
+ !this.isContextLine &&
+ !this.isMetaLine,
+ },
+ ];
},
lineNumber() {
return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line;
diff --git a/app/assets/javascripts/diffs/components/edit_button.vue b/app/assets/javascripts/diffs/components/edit_button.vue
index 5d38d545ce8..f0cc5de4b33 100644
--- a/app/assets/javascripts/diffs/components/edit_button.vue
+++ b/app/assets/javascripts/diffs/components/edit_button.vue
@@ -1,5 +1,15 @@
<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
props: {
editPath: {
type: String,
@@ -17,12 +27,7 @@ export default {
},
methods: {
handleEditClick(evt) {
- if (!this.canCurrentUserFork || this.canModifyBlob) {
- // if we can Edit, do default Edit button behavior
- return;
- }
-
- if (this.canCurrentUserFork) {
+ if (this.canCurrentUserFork && !this.canModifyBlob) {
evt.preventDefault();
this.$emit('showForkMessage');
}
@@ -32,5 +37,13 @@ export default {
</script>
<template>
- <a :href="editPath" class="btn btn-default js-edit-blob" @click="handleEditClick"> Edit </a>
+ <gl-button
+ v-gl-tooltip.bottom
+ :href="editPath"
+ :title="__('Edit file')"
+ class="js-edit-blob"
+ @click.native="handleEditClick"
+ >
+ <icon name="pencil" />
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/diffs/components/image_diff_overlay.vue b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
index d30e64312aa..703a281308e 100644
--- a/app/assets/javascripts/diffs/components/image_diff_overlay.vue
+++ b/app/assets/javascripts/diffs/components/image_diff_overlay.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
+import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -8,6 +9,7 @@ export default {
components: {
Icon,
},
+ mixins: [imageDiffMixin],
props: {
discussions: {
type: [Array, Object],
@@ -48,7 +50,6 @@ export default {
},
},
methods: {
- ...mapActions(['toggleDiscussion']),
...mapActions('diffs', ['openDiffFileCommentForm']),
getImageDimensions() {
return {
@@ -97,7 +98,7 @@ export default {
v-if="canComment"
type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
- @click="clickedImage($event.offsetX, $event.offsetY);"
+ @click="clickedImage($event.offsetX, $event.offsetY)"
>
<span class="sr-only"> {{ __('Add image comment') }} </span>
</button>
@@ -105,15 +106,15 @@ export default {
v-for="(discussion, index) in allDiscussions"
:key="discussion.id"
:style="getPosition(discussion)"
- :class="badgeClass"
+ :class="[badgeClass, { 'is-draft': discussion.isDraft }]"
:disabled="!shouldToggleDiscussion"
class="js-image-badge"
type="button"
- @click="toggleDiscussion({ discussionId: discussion.id });"
+ @click="clickedToggle(discussion)"
>
<icon v-if="showCommentIcon" name="image-comment-dark" />
<template v-else>
- {{ index + 1 }}
+ {{ toggleText(discussion, index) }}
</template>
</button>
<button
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index 814ee0b7c02..1faa0493e79 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -41,7 +41,7 @@ export default {
<template>
<tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes_content" colspan="3">
+ <td class="notes-content" colspan="3">
<div class="content">
<diff-discussions
v-if="line.discussions.length"
@@ -54,6 +54,7 @@ export default {
:diff-file-hash="diffFileHash"
:line="line"
:note-target-line="line"
+ :help-page-path="helpPagePath"
/>
</div>
</td>
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index c764cbeb8e0..2d5262baeec 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -1,12 +1,11 @@
<script>
-import { mapGetters, mapActions, mapState } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import DiffTableCell from './diff_table_cell.vue';
import {
NEW_LINE_TYPE,
OLD_LINE_TYPE,
CONTEXT_LINE_TYPE,
CONTEXT_LINE_CLASS_NAME,
- PARALLEL_DIFF_VIEW_TYPE,
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
} from '../constants';
@@ -45,16 +44,16 @@ export default {
return this.line.line_code !== null && this.line.line_code === state.diffs.highlightedRow;
},
}),
- ...mapGetters('diffs', ['isInlineView']),
isContextLine() {
return this.line.type === CONTEXT_LINE_TYPE;
},
classNameMap() {
- return {
- [this.line.type]: this.line.type,
- [CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
- [PARALLEL_DIFF_VIEW_TYPE]: this.isParallelView,
- };
+ return [
+ this.line.type,
+ {
+ [CONTEXT_LINE_CLASS_NAME]: this.isContextLine,
+ },
+ ];
},
inlineRowId() {
return this.line.line_code || `${this.fileHash}_${this.line.old_line}_${this.line.new_line}`;
diff --git a/app/assets/javascripts/diffs/components/inline_diff_view.vue b/app/assets/javascripts/diffs/components/inline_diff_view.vue
index 9310e2b7ca9..8c76a555b62 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_view.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import inlineDiffTableRow from './inline_diff_table_row.vue';
import inlineDiffCommentRow from './inline_diff_comment_row.vue';
@@ -7,7 +8,10 @@ export default {
components: {
inlineDiffCommentRow,
inlineDiffTableRow,
+ InlineDraftCommentRow: () =>
+ import('ee_component/batch_comments/components/inline_draft_comment_row.vue'),
},
+ mixins: [draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -49,11 +53,16 @@ export default {
:is-bottom="index + 1 === diffLinesLength"
/>
<inline-diff-comment-row
- :key="`icr-${index}`"
+ :key="`icr-${line.line_code || index}`"
:diff-file-hash="diffFile.file_hash"
:line="line"
:help-page-path="helpPagePath"
/>
+ <inline-draft-comment-row
+ v-if="shouldRenderDraftRow(diffFile.file_hash, line)"
+ :key="`draft_${index}`"
+ :draft="draftForLine(diffFile.file_hash, line)"
+ />
</template>
</tbody>
</table>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index a65cf025cde..d2e54edca85 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -87,7 +87,7 @@ export default {
<template>
<tr v-if="shouldRender" :class="className" class="notes_holder">
- <td class="notes_content parallel old" colspan="2">
+ <td class="notes-content parallel old" colspan="2">
<div v-if="shouldRenderDiscussionsOnLeft" class="content">
<diff-discussions
v-if="line.left.discussions.length"
@@ -101,10 +101,11 @@ export default {
:diff-file-hash="diffFileHash"
:line="line.left"
:note-target-line="line.left"
+ :help-page-path="helpPagePath"
line-position="left"
/>
</td>
- <td class="notes_content parallel new" colspan="2">
+ <td class="notes-content parallel new" colspan="2">
<div v-if="shouldRenderDiscussionsOnRight" class="content">
<diff-discussions
v-if="line.right.discussions.length"
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index caf0df8a4e3..c60246bf8ef 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -140,7 +140,7 @@ export default {
:id="line.left.line_code"
:class="parallelViewLeftLineType"
class="line_content parallel left-side"
- @mousedown.native="handleParallelLineMouseDown"
+ @mousedown="handleParallelLineMouseDown"
v-html="line.left.rich_text"
></td>
</template>
@@ -171,7 +171,7 @@ export default {
},
]"
class="line_content parallel right-side"
- @mousedown.native="handleParallelLineMouseDown"
+ @mousedown="handleParallelLineMouseDown"
v-html="line.right.rich_text"
></td>
</template>
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_view.vue b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
index e6bc0daebb3..41a80d99850 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_view.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_view.vue
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
+import draftCommentsMixin from 'ee_else_ce/diffs/mixins/draft_comments';
import parallelDiffTableRow from './parallel_diff_table_row.vue';
import parallelDiffCommentRow from './parallel_diff_comment_row.vue';
@@ -7,7 +8,10 @@ export default {
components: {
parallelDiffTableRow,
parallelDiffCommentRow,
+ ParallelDraftCommentRow: () =>
+ import('ee_component/batch_comments/components/parallel_draft_comment_row.vue'),
},
+ mixins: [draftCommentsMixin],
props: {
diffFile: {
type: Object,
@@ -34,30 +38,34 @@ export default {
</script>
<template>
- <div
+ <table
:class="$options.userColorScheme"
:data-commit-id="commitId"
class="code diff-wrap-lines js-syntax-highlight text-file"
>
- <table>
- <tbody>
- <template v-for="(line, index) in diffLines">
- <parallel-diff-table-row
- :key="index"
- :file-hash="diffFile.file_hash"
- :context-lines-path="diffFile.context_lines_path"
- :line="line"
- :is-bottom="index + 1 === diffLinesLength"
- />
- <parallel-diff-comment-row
- :key="`dcr-${index}`"
- :line="line"
- :diff-file-hash="diffFile.file_hash"
- :line-index="index"
- :help-page-path="helpPagePath"
- />
- </template>
- </tbody>
- </table>
- </div>
+ <tbody>
+ <template v-for="(line, index) in diffLines">
+ <parallel-diff-table-row
+ :key="line.line_code"
+ :file-hash="diffFile.file_hash"
+ :context-lines-path="diffFile.context_lines_path"
+ :line="line"
+ :is-bottom="index + 1 === diffLinesLength"
+ />
+ <parallel-diff-comment-row
+ :key="`dcr-${line.line_code || index}`"
+ :line="line"
+ :diff-file-hash="diffFile.file_hash"
+ :line-index="index"
+ :help-page-path="helpPagePath"
+ />
+ <parallel-draft-comment-row
+ v-if="shouldRenderParallelDraftRow(diffFile.file_hash, line)"
+ :key="`drafts-${index}`"
+ :line="line"
+ :diff-file-content-sha="diffFile.file_hash"
+ />
+ </template>
+ </tbody>
+ </table>
</template>
diff --git a/app/assets/javascripts/diffs/components/settings_dropdown.vue b/app/assets/javascripts/diffs/components/settings_dropdown.vue
new file mode 100644
index 00000000000..0129763161a
--- /dev/null
+++ b/app/assets/javascripts/diffs/components/settings_dropdown.vue
@@ -0,0 +1,92 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+ computed: {
+ ...mapGetters('diffs', ['isInlineView', 'isParallelView']),
+ ...mapState('diffs', ['renderTreeList', 'showWhitespace']),
+ },
+ methods: {
+ ...mapActions('diffs', [
+ 'setInlineDiffViewType',
+ 'setParallelDiffViewType',
+ 'setRenderTreeList',
+ 'setShowWhitespace',
+ ]),
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown">
+ <button
+ type="button"
+ class="btn btn-default js-show-diff-settings"
+ data-toggle="dropdown"
+ data-display="static"
+ >
+ <icon name="settings" /> <icon name="arrow-down" />
+ </button>
+ <div class="dropdown-menu dropdown-menu-right p-2 pt-3 pb-3">
+ <div>
+ <span class="bold d-block mb-1">{{ __('File browser') }}</span>
+ <div class="btn-group d-flex">
+ <gl-button
+ :class="{ active: !renderTreeList }"
+ class="w-100 js-list-view"
+ @click="setRenderTreeList(false)"
+ >
+ {{ __('List view') }}
+ </gl-button>
+ <gl-button
+ :class="{ active: renderTreeList }"
+ class="w-100 js-tree-view"
+ @click="setRenderTreeList(true)"
+ >
+ {{ __('Tree view') }}
+ </gl-button>
+ </div>
+ </div>
+ <div class="mt-2">
+ <span class="bold d-block mb-1">{{ __('Compare changes') }}</span>
+ <div class="btn-group d-flex js-diff-view-buttons">
+ <gl-button
+ id="inline-diff-btn"
+ :class="{ active: isInlineView }"
+ class="w-100 js-inline-diff-button"
+ data-view-type="inline"
+ @click="setInlineDiffViewType"
+ >
+ {{ __('Inline') }}
+ </gl-button>
+ <gl-button
+ id="parallel-diff-btn"
+ :class="{ active: isParallelView }"
+ class="w-100 js-parallel-diff-button"
+ data-view-type="parallel"
+ @click="setParallelDiffViewType"
+ >
+ {{ __('Side-by-side') }}
+ </gl-button>
+ </div>
+ </div>
+ <div class="mt-2">
+ <label class="mb-0">
+ <input
+ id="show-whitespace"
+ type="checkbox"
+ :checked="showWhitespace"
+ @change="setShowWhitespace({ showWhitespace: $event.target.checked, pushState: true })"
+ />
+ {{ __('Show whitespace changes') }}
+ </label>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue
index f40a7b25fde..30be2e68e76 100644
--- a/app/assets/javascripts/diffs/components/tree_list.vue
+++ b/app/assets/javascripts/diffs/components/tree_list.vue
@@ -1,13 +1,11 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
+import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileRowStats from './file_row_stats.vue';
-const treeListStorageKey = 'mr_diff_tree_list';
-
export default {
directives: {
GlTooltip: GlTooltipDirective,
@@ -16,54 +14,53 @@ export default {
Icon,
FileRow,
},
+ props: {
+ hideFileStats: {
+ type: Boolean,
+ required: true,
+ },
+ },
data() {
- const treeListStored = localStorage.getItem(treeListStorageKey);
- const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true;
-
return {
search: '',
- renderTreeList,
- focusSearch: false,
};
},
computed: {
- ...mapState('diffs', ['tree', 'addedLines', 'removedLines']),
- ...mapGetters('diffs', ['allBlobs', 'diffFilesLength']),
+ ...mapState('diffs', ['tree', 'renderTreeList']),
+ ...mapGetters('diffs', ['allBlobs']),
filteredTreeList() {
const search = this.search.toLowerCase().trim();
- if (search === '') return this.renderTreeList ? this.tree : this.allBlobs;
-
- return this.allBlobs.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
- },
- rowDisplayTextKey() {
- if (this.renderTreeList && this.search.trim() === '') {
- return 'name';
+ if (search === '') {
+ return this.renderTreeList ? this.tree : this.allBlobs;
}
- return 'path';
+ return this.allBlobs.reduce((acc, folder) => {
+ const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
+
+ if (tree.length) {
+ return acc.concat({
+ ...folder,
+ tree,
+ });
+ }
+
+ return acc;
+ }, []);
+ },
+ fileRowExtraComponent() {
+ return this.hideFileStats ? null : FileRowStats;
},
},
methods: {
...mapActions('diffs', ['toggleTreeOpen', 'scrollToFile']),
clearSearch() {
this.search = '';
- this.toggleFocusSearch(false);
- },
- toggleRenderTreeList(toggle) {
- this.renderTreeList = toggle;
- localStorage.setItem(treeListStorageKey, this.renderTreeList);
- },
- toggleFocusSearch(toggle) {
- this.focusSearch = toggle;
- },
- blurSearch() {
- if (this.search.trim() === '') {
- this.toggleFocusSearch(false);
- }
},
},
- FileRowStats,
+ searchPlaceholder: sprintf(s__('MergeRequest|Filter files or search with %{modifier_key}+p'), {
+ modifier_key: /Mac/i.test(navigator.userAgent) ? 'cmd' : 'ctrl',
+ }),
};
</script>
@@ -72,13 +69,14 @@ export default {
<div class="append-bottom-8 position-relative tree-list-search d-flex">
<div class="flex-fill d-flex">
<icon name="search" class="position-absolute tree-list-icon" />
+ <label for="diff-tree-search" class="sr-only">{{ $options.searchPlaceholder }}</label>
<input
+ id="diff-tree-search"
v-model="search"
- :placeholder="s__('MergeRequest|Filter files')"
+ :placeholder="$options.searchPlaceholder"
type="search"
+ name="diff-tree-search"
class="form-control"
- @focus="toggleFocusSearch(true);"
- @blur="blurSearch"
/>
<button
v-show="search"
@@ -90,36 +88,8 @@ export default {
<icon name="close" />
</button>
</div>
- <div v-show="!focusSearch" class="btn-group prepend-left-8 tree-list-view-toggle">
- <button
- v-gl-tooltip.hover
- :aria-label="__('List view')"
- :title="__('List view')"
- :class="{
- active: !renderTreeList,
- }"
- class="btn btn-default pt-0 pb-0 d-flex align-items-center"
- type="button"
- @click="toggleRenderTreeList(false);"
- >
- <icon name="hamburger" />
- </button>
- <button
- v-gl-tooltip.hover
- :aria-label="__('Tree view')"
- :title="__('Tree view')"
- :class="{
- active: renderTreeList,
- }"
- class="btn btn-default pt-0 pb-0 d-flex align-items-center"
- type="button"
- @click="toggleRenderTreeList(true);"
- >
- <icon name="file-tree" />
- </button>
- </div>
</div>
- <div class="tree-list-scroll">
+ <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
<template v-if="filteredTreeList.length">
<file-row
v-for="file in filteredTreeList"
@@ -127,10 +97,8 @@ export default {
:file="file"
:level="0"
:hide-extra-on-tree="true"
- :extra-component="$options.FileRowStats"
+ :extra-component="fileRowExtraComponent"
:show-changed-icon="true"
- :display-text-key="rowDisplayTextKey"
- :should-truncate-start="true"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="scrollToFile"
/>
@@ -139,12 +107,22 @@ export default {
{{ s__('MergeRequest|No files found') }}
</p>
</div>
- <div v-once class="pt-3 pb-3 text-center">
- {{ n__('%d changed file', '%d changed files', diffFilesLength) }}
- <div>
- <span class="cgreen"> {{ n__('%d addition', '%d additions', addedLines) }} </span>
- <span class="cred"> {{ n__('%d deleted', '%d deletions', removedLines) }} </span>
- </div>
- </div>
</div>
</template>
+
+<style>
+.tree-list-blobs .file-row-name {
+ margin-left: 12px;
+}
+
+.diff-tree-search-shortcut {
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ pointer-events: none;
+}
+
+.tree-list-icon:not(button) {
+ pointer-events: none;
+}
+</style>
diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js
index 78a39baa4cb..d84e1af11f3 100644
--- a/app/assets/javascripts/diffs/constants.js
+++ b/app/assets/javascripts/diffs/constants.js
@@ -32,3 +32,28 @@ export const LINES_TO_BE_RENDERED_DIRECTLY = 100;
export const MAX_LINES_TO_BE_RENDERED = 2000;
export const MR_TREE_SHOW_KEY = 'mr_tree_show';
+
+export const TREE_TYPE = 'tree';
+export const TREE_LIST_STORAGE_KEY = 'mr_diff_tree_list';
+export const WHITESPACE_STORAGE_KEY = 'mr_show_whitespace';
+export const TREE_LIST_WIDTH_STORAGE_KEY = 'mr_tree_list_width';
+
+export const INITIAL_TREE_WIDTH = 320;
+export const MIN_TREE_WIDTH = 240;
+export const MAX_TREE_WIDTH = 400;
+export const TREE_HIDE_STATS_WIDTH = 260;
+
+export const OLD_LINE_KEY = 'old_line';
+export const NEW_LINE_KEY = 'new_line';
+export const TYPE_KEY = 'type';
+export const LEFT_LINE_KEY = 'left';
+
+export const CENTERED_LIMITED_CONTAINER_CLASSES =
+ 'container-limited limit-container-width mx-lg-auto px-3';
+
+export const MAX_RENDERING_DIFF_LINES = 500;
+export const MAX_RENDERING_BULK_ROWS = 30;
+export const MIN_RENDERING_MS = 2;
+export const START_RENDERING_INDEX = 200;
+export const INLINE_DIFF_LINES_KEY = 'highlighted_diff_lines';
+export const PARALLEL_DIFF_LINES_KEY = 'parallel_diff_lines';
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index b130cedc24c..1d897bca1dd 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -1,8 +1,60 @@
import Vue from 'vue';
-import { mapState } from 'vuex';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import { getParameterValues } from '~/lib/utils/url_utility';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
+import eventHub from '../notes/event_hub';
import diffsApp from './components/app.vue';
+import { TREE_LIST_STORAGE_KEY } from './constants';
export default function initDiffsApp(store) {
+ const fileFinderEl = document.getElementById('js-diff-file-finder');
+
+ if (fileFinderEl) {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: fileFinderEl,
+ store,
+ computed: {
+ ...mapState('diffs', ['fileFinderVisible', 'isLoading']),
+ ...mapGetters('diffs', ['flatBlobsList']),
+ },
+ watch: {
+ fileFinderVisible(newVal, oldVal) {
+ if (newVal && !oldVal && !this.flatBlobsList.length) {
+ eventHub.$emit('fetchDiffData');
+ }
+ },
+ },
+ methods: {
+ ...mapActions('diffs', ['toggleFileFinder', 'scrollToFile']),
+ openFile(file) {
+ window.mrTabs.tabShown('diffs');
+ this.scrollToFile(file.path);
+ },
+ },
+ render(createElement) {
+ return createElement(FindFile, {
+ props: {
+ files: this.flatBlobsList,
+ visible: this.fileFinderVisible,
+ loading: this.isLoading,
+ showDiffStats: true,
+ clearSearchOnClose: false,
+ },
+ on: {
+ toggle: this.toggleFileFinder,
+ click: this.openFile,
+ },
+ class: ['diff-file-finder'],
+ style: {
+ display: this.fileFinderVisible ? '' : 'none',
+ },
+ });
+ },
+ });
+ }
+
return new Vue({
el: '#js-diffs-app',
name: 'MergeRequestDiffs',
@@ -19,6 +71,7 @@ export default function initDiffsApp(store) {
helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
changesEmptyStateIllustration: dataset.changesEmptyStateIllustration,
+ isFluidLayout: parseBoolean(dataset.isFluidLayout),
};
},
computed: {
@@ -26,6 +79,16 @@ export default function initDiffsApp(store) {
activeTab: state => state.page.activeTab,
}),
},
+ created() {
+ const treeListStored = localStorage.getItem(TREE_LIST_STORAGE_KEY);
+ const renderTreeList = treeListStored !== null ? parseBoolean(treeListStored) : true;
+
+ this.setRenderTreeList(renderTreeList);
+ this.setShowWhitespace({ showWhitespace: getParameterValues('w')[0] !== '1' });
+ },
+ methods: {
+ ...mapActions('diffs', ['setRenderTreeList', 'setShowWhitespace']),
+ },
render(createElement) {
return createElement('diffs-app', {
props: {
@@ -35,6 +98,7 @@ export default function initDiffsApp(store) {
helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
changesEmptyStateIllustration: this.changesEmptyStateIllustration,
+ isFluidLayout: this.isFluidLayout,
},
});
},
diff --git a/app/assets/javascripts/diffs/mixins/draft_comments.js b/app/assets/javascripts/diffs/mixins/draft_comments.js
new file mode 100644
index 00000000000..dfb71bf38ce
--- /dev/null
+++ b/app/assets/javascripts/diffs/mixins/draft_comments.js
@@ -0,0 +1,10 @@
+export default {
+ computed: {
+ shouldRenderDraftRow: () => () => false,
+ shouldRenderParallelDraftRow: () => () => false,
+ draftForLine: () => () => ({}),
+ imageDiscussions() {
+ return this.diffFile.discussions;
+ },
+ },
+};
diff --git a/app/assets/javascripts/diffs/mixins/image_diff.js b/app/assets/javascripts/diffs/mixins/image_diff.js
new file mode 100644
index 00000000000..9067ea6f8b3
--- /dev/null
+++ b/app/assets/javascripts/diffs/mixins/image_diff.js
@@ -0,0 +1,13 @@
+import { mapActions } from 'vuex';
+
+export default {
+ methods: {
+ ...mapActions(['toggleDiscussion']),
+ clickedToggle(discussion) {
+ this.toggleDiscussion({ discussionId: discussion.id });
+ },
+ toggleText(discussion, index) {
+ return index + 1;
+ },
+ },
+};
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 00a4bb6d3a3..35297b7c264 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -5,15 +5,35 @@ import createFlash from '~/flash';
import { s__ } from '~/locale';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
+import TreeWorker from '../workers/tree_worker';
import eventHub from '../../notes/event_hub';
-import { getDiffPositionByLineCode, getNoteFormData } from './utils';
+import {
+ getDiffPositionByLineCode,
+ getNoteFormData,
+ convertExpandLines,
+ idleCallback,
+} from './utils';
import * as types from './mutation_types';
import {
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
DIFF_VIEW_COOKIE_NAME,
MR_TREE_SHOW_KEY,
+ TREE_LIST_STORAGE_KEY,
+ WHITESPACE_STORAGE_KEY,
+ TREE_LIST_WIDTH_STORAGE_KEY,
+ OLD_LINE_KEY,
+ NEW_LINE_KEY,
+ TYPE_KEY,
+ LEFT_LINE_KEY,
+ MAX_RENDERING_DIFF_LINES,
+ MAX_RENDERING_BULK_ROWS,
+ MIN_RENDERING_MS,
+ START_RENDERING_INDEX,
+ INLINE_DIFF_LINES_KEY,
+ PARALLEL_DIFF_LINES_KEY,
} from '../constants';
+import { diffViewerModes } from '~/ide/constants';
export const setBaseConfig = ({ commit }, options) => {
const { endpoint, projectPath } = options;
@@ -21,21 +41,35 @@ export const setBaseConfig = ({ commit }, options) => {
};
export const fetchDiffFiles = ({ state, commit }) => {
+ const worker = new TreeWorker();
+
commit(types.SET_LOADING, true);
+ worker.addEventListener('message', ({ data }) => {
+ commit(types.SET_TREE_DATA, data);
+
+ worker.terminate();
+ });
+
return axios
- .get(state.endpoint)
+ .get(mergeUrlParams({ w: state.showWhitespace ? '0' : '1' }, state.endpoint))
.then(res => {
commit(types.SET_LOADING, false);
commit(types.SET_MERGE_REQUEST_DIFFS, res.data.merge_request_diffs || []);
commit(types.SET_DIFF_DATA, res.data);
+
+ worker.postMessage(state.diffFiles);
+
return Vue.nextTick();
})
- .then(handleLocationHash);
+ .then(handleLocationHash)
+ .catch(() => worker.terminate());
};
export const setHighlightedRow = ({ commit }, lineCode) => {
+ const fileHash = lineCode.split('_')[0];
commit(types.SET_HIGHLIGHTED_ROW, lineCode);
+ commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
};
// This is adding line discussions to the actual lines in the diff tree
@@ -76,7 +110,7 @@ export const renderFileForDiscussionId = ({ commit, rootState, state }, discussi
commit(types.RENDER_FILE, file);
}
- if (file.collapsed) {
+ if (file.viewer.collapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else {
@@ -90,7 +124,9 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
const nextFile = state.diffFiles.find(
- file => !file.renderIt && (!file.collapsed || !file.text),
+ file =>
+ !file.renderIt &&
+ (file.viewer && (!file.viewer.collapsed || !file.viewer.name === diffViewerModes.text)),
);
if (nextFile) {
@@ -113,6 +149,8 @@ export const startRenderDiffsQueue = ({ state, commit }) => {
return checkItem();
};
+export const setRenderIt = ({ commit }, file) => commit(types.RENDER_FILE, file);
+
export const setInlineDiffViewType = ({ commit }) => {
commit(types.SET_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE);
@@ -242,13 +280,14 @@ export const scrollToFile = ({ state, commit }, path) => {
document.location.hash = fileHash;
commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash);
-
- setTimeout(() => commit(types.UPDATE_CURRENT_DIFF_FILE_ID, ''), 1000);
};
-export const toggleShowTreeList = ({ commit, state }) => {
+export const toggleShowTreeList = ({ commit, state }, saving = true) => {
commit(types.TOGGLE_SHOW_TREE_LIST);
- localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList);
+
+ if (saving) {
+ localStorage.setItem(MR_TREE_SHOW_KEY, state.showTreeList);
+ }
};
export const openDiffFileCommentForm = ({ commit, getters }, formData) => {
@@ -265,5 +304,155 @@ export const closeDiffFileCommentForm = ({ commit }, fileHash) => {
commit(types.CLOSE_DIFF_FILE_COMMENT_FORM, fileHash);
};
+export const setRenderTreeList = ({ commit }, renderTreeList) => {
+ commit(types.SET_RENDER_TREE_LIST, renderTreeList);
+
+ localStorage.setItem(TREE_LIST_STORAGE_KEY, renderTreeList);
+};
+
+export const setShowWhitespace = ({ commit }, { showWhitespace, pushState = false }) => {
+ commit(types.SET_SHOW_WHITESPACE, showWhitespace);
+
+ localStorage.setItem(WHITESPACE_STORAGE_KEY, showWhitespace);
+
+ if (pushState) {
+ historyPushState(mergeUrlParams({ w: showWhitespace ? '0' : '1' }, window.location.href));
+ }
+
+ eventHub.$emit('refetchDiffData');
+};
+
+export const toggleFileFinder = ({ commit }, visible) => {
+ commit(types.TOGGLE_FILE_FINDER_VISIBLE, visible);
+};
+
+export const cacheTreeListWidth = (_, size) => {
+ localStorage.setItem(TREE_LIST_WIDTH_STORAGE_KEY, size);
+};
+
+export const requestFullDiff = ({ commit }, filePath) => commit(types.REQUEST_FULL_DIFF, filePath);
+export const receiveFullDiffSucess = ({ commit }, { filePath }) =>
+ commit(types.RECEIVE_FULL_DIFF_SUCCESS, { filePath });
+export const receiveFullDiffError = ({ commit }, filePath) => {
+ commit(types.RECEIVE_FULL_DIFF_ERROR, filePath);
+ createFlash(s__('MergeRequest|Error loading full diff. Please try again.'));
+};
+
+export const setExpandedDiffLines = ({ commit, state }, { file, data }) => {
+ const expandedDiffLines = {
+ highlighted_diff_lines: convertExpandLines({
+ diffLines: file.highlighted_diff_lines,
+ typeKey: TYPE_KEY,
+ oldLineKey: OLD_LINE_KEY,
+ newLineKey: NEW_LINE_KEY,
+ data,
+ mapLine: ({ line, oldLine, newLine }) =>
+ Object.assign(line, {
+ old_line: oldLine,
+ new_line: newLine,
+ line_code: `${file.file_hash}_${oldLine}_${newLine}`,
+ }),
+ }),
+ parallel_diff_lines: convertExpandLines({
+ diffLines: file.parallel_diff_lines,
+ typeKey: [LEFT_LINE_KEY, TYPE_KEY],
+ oldLineKey: [LEFT_LINE_KEY, OLD_LINE_KEY],
+ newLineKey: [LEFT_LINE_KEY, NEW_LINE_KEY],
+ data,
+ mapLine: ({ line, oldLine, newLine }) => ({
+ left: {
+ ...line,
+ old_line: oldLine,
+ line_code: `${file.file_hash}_${oldLine}_${newLine}`,
+ },
+ right: {
+ ...line,
+ new_line: newLine,
+ line_code: `${file.file_hash}_${newLine}_${oldLine}`,
+ },
+ }),
+ }),
+ };
+ const currentDiffLinesKey =
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE ? INLINE_DIFF_LINES_KEY : PARALLEL_DIFF_LINES_KEY;
+ const hiddenDiffLinesKey =
+ state.diffViewType === INLINE_DIFF_VIEW_TYPE ? PARALLEL_DIFF_LINES_KEY : INLINE_DIFF_LINES_KEY;
+
+ commit(types.SET_HIDDEN_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[hiddenDiffLinesKey],
+ });
+
+ if (expandedDiffLines[currentDiffLinesKey].length > MAX_RENDERING_DIFF_LINES) {
+ let index = START_RENDERING_INDEX;
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[currentDiffLinesKey].slice(0, index),
+ });
+ commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
+
+ const idleCb = t => {
+ const startIndex = index;
+
+ while (
+ t.timeRemaining() >= MIN_RENDERING_MS &&
+ index !== expandedDiffLines[currentDiffLinesKey].length &&
+ index - startIndex !== MAX_RENDERING_BULK_ROWS
+ ) {
+ const line = expandedDiffLines[currentDiffLinesKey][index];
+
+ if (line) {
+ commit(types.ADD_CURRENT_VIEW_DIFF_FILE_LINES, { filePath: file.file_path, line });
+ index += 1;
+ }
+ }
+
+ if (index !== expandedDiffLines[currentDiffLinesKey].length) {
+ idleCallback(idleCb);
+ } else {
+ commit(types.TOGGLE_DIFF_FILE_RENDERING_MORE, file.file_path);
+ }
+ };
+
+ idleCallback(idleCb);
+ } else {
+ commit(types.SET_CURRENT_VIEW_DIFF_FILE_LINES, {
+ filePath: file.file_path,
+ lines: expandedDiffLines[currentDiffLinesKey],
+ });
+ }
+};
+
+export const fetchFullDiff = ({ dispatch }, file) =>
+ axios
+ .get(file.context_lines_path, {
+ params: {
+ full: true,
+ from_merge_request: true,
+ },
+ })
+ .then(({ data }) => {
+ dispatch('receiveFullDiffSucess', { filePath: file.file_path });
+ dispatch('setExpandedDiffLines', { file, data });
+ })
+ .catch(() => dispatch('receiveFullDiffError', file.file_path));
+
+export const toggleFullDiff = ({ dispatch, getters, state }, filePath) => {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ dispatch('requestFullDiff', filePath);
+
+ if (file.isShowingFullFile) {
+ dispatch('loadCollapsedDiff', file)
+ .then(() => dispatch('assignDiscussionsToDiff', getters.getDiffFileDiscussions(file)))
+ .catch(() => dispatch('receiveFullDiffError', filePath));
+ } else {
+ dispatch('fetchFullDiff', file);
+ }
+};
+
+export const setFileCollapsed = ({ commit }, { filePath, collapsed }) =>
+ commit(types.SET_FILE_COLLAPSED, { filePath, collapsed });
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js
index fdf1efbb10e..bc27e263bff 100644
--- a/app/assets/javascripts/diffs/store/getters.js
+++ b/app/assets/javascripts/diffs/store/getters.js
@@ -4,7 +4,8 @@ export const isParallelView = state => state.diffViewType === PARALLEL_DIFF_VIEW
export const isInlineView = state => state.diffViewType === INLINE_DIFF_VIEW_TYPE;
-export const hasCollapsedFile = state => state.diffFiles.some(file => file.collapsed);
+export const hasCollapsedFile = state =>
+ state.diffFiles.some(file => file.viewer && file.viewer.collapsed);
export const commitId = state => (state.commit && state.commit.id ? state.commit.id : null);
@@ -74,12 +75,37 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash);
-export const allBlobs = state => Object.values(state.treeEntries).filter(f => f.type === 'blob');
+export const flatBlobsList = state =>
+ Object.values(state.treeEntries).filter(f => f.type === 'blob');
+
+export const allBlobs = (state, getters) =>
+ getters.flatBlobsList.reduce((acc, file) => {
+ const { parentPath } = file;
+
+ if (parentPath && !acc.some(f => f.path === parentPath)) {
+ acc.push({
+ path: parentPath,
+ isHeader: true,
+ tree: [],
+ });
+ }
+
+ acc.find(f => f.path === parentPath).tree.push(file);
+
+ return acc;
+ }, []);
export const diffFilesLength = state => state.diffFiles.length;
export const getCommentFormForDiffFile = state => fileHash =>
state.commentForms.find(form => form.fileHash === fileHash);
+/**
+ * Returns index of a currently selected diff in diffFiles
+ * @returns {number}
+ */
+export const currentDiffIndex = state =>
+ Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId));
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js
index 98e57d52d77..cf4dd93dbfb 100644
--- a/app/assets/javascripts/diffs/store/modules/diff_state.js
+++ b/app/assets/javascripts/diffs/store/modules/diff_state.js
@@ -1,16 +1,15 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
-import bp from '~/breakpoints';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME, MR_TREE_SHOW_KEY } from '../../constants';
+import { INLINE_DIFF_VIEW_TYPE, DIFF_VIEW_COOKIE_NAME } from '../../constants';
const viewTypeFromQueryString = getParameterValues('view')[0];
const viewTypeFromCookie = Cookies.get(DIFF_VIEW_COOKIE_NAME);
const defaultViewType = INLINE_DIFF_VIEW_TYPE;
-const storedTreeShow = localStorage.getItem(MR_TREE_SHOW_KEY);
export default () => ({
isLoading: true,
+ addedLines: null,
+ removedLines: null,
endpoint: '',
basePath: '',
commit: null,
@@ -21,10 +20,12 @@ export default () => ({
diffViewType: viewTypeFromQueryString || viewTypeFromCookie || defaultViewType,
tree: [],
treeEntries: {},
- showTreeList:
- storedTreeShow === null ? bp.getBreakpointSize() !== 'xs' : parseBoolean(storedTreeShow),
+ showTreeList: true,
currentDiffFileId: '',
projectPath: '',
commentForms: [],
highlightedRow: null,
+ renderTreeList: true,
+ showWhitespace: true,
+ fileFinderVisible: false,
});
diff --git a/app/assets/javascripts/diffs/store/mutation_types.js b/app/assets/javascripts/diffs/store/mutation_types.js
index 0338cde3658..6bb24c97139 100644
--- a/app/assets/javascripts/diffs/store/mutation_types.js
+++ b/app/assets/javascripts/diffs/store/mutation_types.js
@@ -18,3 +18,18 @@ export const OPEN_DIFF_FILE_COMMENT_FORM = 'OPEN_DIFF_FILE_COMMENT_FORM';
export const UPDATE_DIFF_FILE_COMMENT_FORM = 'UPDATE_DIFF_FILE_COMMENT_FORM';
export const CLOSE_DIFF_FILE_COMMENT_FORM = 'CLOSE_DIFF_FILE_COMMENT_FORM';
export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
+
+export const SET_TREE_DATA = 'SET_TREE_DATA';
+export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
+export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
+export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
+
+export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF';
+export const RECEIVE_FULL_DIFF_SUCCESS = 'RECEIVE_FULL_DIFF_SUCCESS';
+export const RECEIVE_FULL_DIFF_ERROR = 'RECEIVE_FULL_DIFF_ERROR';
+export const SET_FILE_COLLAPSED = 'SET_FILE_COLLAPSED';
+
+export const SET_HIDDEN_VIEW_DIFF_FILE_LINES = 'SET_HIDDEN_VIEW_DIFF_FILE_LINES';
+export const SET_CURRENT_VIEW_DIFF_FILE_LINES = 'SET_CURRENT_VIEW_DIFF_FILE_LINES';
+export const ADD_CURRENT_VIEW_DIFF_FILE_LINES = 'ADD_CURRENT_VIEW_DIFF_FILE_LINES';
+export const TOGGLE_DIFF_FILE_RENDERING_MORE = 'TOGGLE_DIFF_FILE_RENDERING_MORE';
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 2ea884d1293..67bc1724738 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -1,5 +1,4 @@
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import { sortTree } from '~/ide/stores/utils';
import {
findDiffFile,
addLineReferences,
@@ -7,7 +6,6 @@ import {
addContextLines,
prepareDiffData,
isDiscussionApplicableToLine,
- generateTreeList,
} from './utils';
import * as types from './mutation_types';
@@ -23,12 +21,9 @@ export default {
[types.SET_DIFF_DATA](state, data) {
prepareDiffData(data);
- const { tree, treeEntries } = generateTreeList(data.diff_files);
Object.assign(state, {
...convertObjectPropsToCamelCase(data),
- tree: sortTree(tree),
- treeEntries,
});
},
@@ -107,7 +102,10 @@ export default {
[types.EXPAND_ALL_FILES](state) {
state.diffFiles = state.diffFiles.map(file => ({
...file,
- collapsed: false,
+ viewer: {
+ ...file.viewer,
+ collapsed: false,
+ },
}));
},
@@ -138,7 +136,7 @@ export default {
if (file.highlighted_diff_lines) {
file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
- mapDiscussions(line),
+ lineCheck(line) ? mapDiscussions(line) : line,
);
}
@@ -149,6 +147,7 @@ export default {
if (left || right) {
return {
+ ...line,
left: line.left ? mapDiscussions(line.left) : null,
right: line.right ? mapDiscussions(line.right, () => !left) : null,
};
@@ -159,7 +158,9 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = (file.discussions || []).concat(discussion);
+ file.discussions = (file.discussions || [])
+ .filter(d => d.id !== discussion.id)
+ .concat(discussion);
}
return file;
@@ -239,4 +240,66 @@ export default {
[types.SET_HIGHLIGHTED_ROW](state, lineCode) {
state.highlightedRow = lineCode;
},
+ [types.SET_TREE_DATA](state, { treeEntries, tree }) {
+ state.treeEntries = treeEntries;
+ state.tree = tree;
+ },
+ [types.SET_RENDER_TREE_LIST](state, renderTreeList) {
+ state.renderTreeList = renderTreeList;
+ },
+ [types.SET_SHOW_WHITESPACE](state, showWhitespace) {
+ state.showWhitespace = showWhitespace;
+ },
+ [types.TOGGLE_FILE_FINDER_VISIBLE](state, visible) {
+ state.fileFinderVisible = visible;
+ },
+ [types.REQUEST_FULL_DIFF](state, filePath) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isLoadingFullFile = true;
+ },
+ [types.RECEIVE_FULL_DIFF_ERROR](state, filePath) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isLoadingFullFile = false;
+ },
+ [types.RECEIVE_FULL_DIFF_SUCCESS](state, { filePath }) {
+ const file = findDiffFile(state.diffFiles, filePath, 'file_path');
+
+ file.isShowingFullFile = true;
+ file.isLoadingFullFile = false;
+ },
+ [types.SET_FILE_COLLAPSED](state, { filePath, collapsed }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ if (file && file.viewer) {
+ file.viewer.collapsed = collapsed;
+ }
+ },
+ [types.SET_HIDDEN_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const hiddenDiffLinesKey =
+ state.diffViewType === 'inline' ? 'parallel_diff_lines' : 'highlighted_diff_lines';
+
+ file[hiddenDiffLinesKey] = lines;
+ },
+ [types.SET_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, lines }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const currentDiffLinesKey =
+ state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
+
+ file[currentDiffLinesKey] = lines;
+ },
+ [types.ADD_CURRENT_VIEW_DIFF_FILE_LINES](state, { filePath, line }) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+ const currentDiffLinesKey =
+ state.diffViewType === 'inline' ? 'highlighted_diff_lines' : 'parallel_diff_lines';
+
+ file[currentDiffLinesKey].push(line);
+ },
+ [types.TOGGLE_DIFF_FILE_RENDERING_MORE](state, filePath) {
+ const file = state.diffFiles.find(f => f.file_path === filePath);
+
+ file.renderingLines = !file.renderingLines;
+ },
};
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index cbaa0e26395..71956255eef 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -1,5 +1,6 @@
import _ from 'underscore';
-import { diffModes } from '~/ide/constants';
+import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
+import { diffModes, diffViewerModes } from '~/ide/constants';
import {
LINE_POSITION_LEFT,
LINE_POSITION_RIGHT,
@@ -11,10 +12,11 @@ import {
MATCH_LINE_TYPE,
LINES_TO_BE_RENDERED_DIRECTLY,
MAX_LINES_TO_BE_RENDERED,
+ TREE_TYPE,
} from '../constants';
-export function findDiffFile(files, hash) {
- return files.filter(file => file.file_hash === hash)[0];
+export function findDiffFile(files, match, matchKey = 'file_hash') {
+ return files.find(file => file[matchKey] === match);
}
export const getReversePosition = linePosition => {
@@ -159,6 +161,7 @@ export function addContextLines(options) {
const normalizedParallelLines = contextLines.map(line => ({
left: line,
right: line,
+ line_code: line.line_code,
}));
if (options.bottom) {
@@ -180,8 +183,6 @@ export function addContextLines(options) {
export function trimFirstCharOfLineContent(line = {}) {
// eslint-disable-next-line no-param-reassign
delete line.text;
- // eslint-disable-next-line no-param-reassign
- line.discussions = [];
const parsedLine = Object.assign({}, line);
@@ -196,6 +197,15 @@ export function trimFirstCharOfLineContent(line = {}) {
return parsedLine;
}
+function getLineCode({ left, right }, index) {
+ if (left && left.line_code) {
+ return left.line_code;
+ } else if (right && right.line_code) {
+ return right.line_code;
+ }
+ return index;
+}
+
// This prepares and optimizes the incoming diff data from the server
// by setting up incremental rendering and removing unneeded data
export function prepareDiffData(diffData) {
@@ -208,12 +218,16 @@ export function prepareDiffData(diffData) {
const linesLength = file.parallel_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.parallel_diff_lines[u];
+
+ line.line_code = getLineCode(line, u);
if (line.left) {
line.left = trimFirstCharOfLineContent(line.left);
+ line.left.discussions = [];
line.left.hasForm = false;
}
if (line.right) {
line.right = trimFirstCharOfLineContent(line.right);
+ line.right.discussions = [];
line.right.hasForm = false;
}
}
@@ -223,15 +237,23 @@ export function prepareDiffData(diffData) {
const linesLength = file.highlighted_diff_lines.length;
for (let u = 0; u < linesLength; u += 1) {
const line = file.highlighted_diff_lines[u];
- Object.assign(line, { ...trimFirstCharOfLineContent(line), hasForm: false });
+ Object.assign(line, {
+ ...trimFirstCharOfLineContent(line),
+ discussions: [],
+ hasForm: false,
+ });
}
showingLines += file.parallel_diff_lines.length;
}
Object.assign(file, {
renderIt: showingLines < LINES_TO_BE_RENDERED_DIRECTLY,
- collapsed: file.text && showingLines > MAX_LINES_TO_BE_RENDERED,
+ collapsed:
+ file.viewer.name === diffViewerModes.text && showingLines > MAX_LINES_TO_BE_RENDERED,
+ isShowingFullFile: false,
+ isLoadingFullFile: false,
discussions: [],
+ renderingLines: false,
});
}
}
@@ -278,8 +300,63 @@ export function isDiscussionApplicableToLine({ discussion, diffPosition, latestD
return latestDiff && discussion.active && line_code === discussion.line_code;
}
-export const generateTreeList = files =>
- files.reduce(
+export const getLowestSingleFolder = folder => {
+ const getFolder = (blob, start = []) =>
+ blob.tree.reduce(
+ (acc, file) => {
+ const shouldGetFolder = file.tree.length === 1 && file.tree[0].type === TREE_TYPE;
+ const currentFileTypeTree = file.type === TREE_TYPE;
+ const path = shouldGetFolder || currentFileTypeTree ? acc.path.concat(file.name) : acc.path;
+ const tree = shouldGetFolder || currentFileTypeTree ? acc.tree.concat(file) : acc.tree;
+
+ if (shouldGetFolder) {
+ const firstFolder = getFolder(file);
+
+ path.push(...firstFolder.path);
+ tree.push(...firstFolder.tree);
+ }
+
+ return {
+ ...acc,
+ path,
+ tree,
+ };
+ },
+ { path: start, tree: [] },
+ );
+ const { path, tree } = getFolder(folder, [folder.name]);
+
+ return {
+ path: truncatePathMiddleToLength(path.join('/'), 40),
+ treeAcc: tree.length ? tree[tree.length - 1].tree : null,
+ };
+};
+
+export const flattenTree = tree => {
+ const flatten = blobTree =>
+ blobTree.reduce((acc, file) => {
+ const blob = file;
+ let treeToFlatten = blob.tree;
+
+ if (file.type === TREE_TYPE && file.tree.length === 1) {
+ const { treeAcc, path } = getLowestSingleFolder(file);
+
+ if (treeAcc) {
+ blob.name = path;
+ treeToFlatten = flatten(treeAcc);
+ }
+ }
+
+ blob.tree = flatten(treeToFlatten);
+
+ return acc.concat(blob);
+ }, []);
+
+ return flatten(tree);
+};
+
+export const generateTreeList = files => {
+ const { treeEntries, tree } = files.reduce(
(acc, file) => {
const split = file.new_path.split('/');
@@ -307,6 +384,7 @@ export const generateTreeList = files =>
fileHash: file.file_hash,
addedLines: file.added_lines,
removedLines: file.removed_lines,
+ parentPath: parent ? `${parent.path}/` : '/',
});
} else {
Object.assign(entry, {
@@ -323,11 +401,56 @@ export const generateTreeList = files =>
{ treeEntries: {}, tree: [] },
);
+ return { treeEntries, tree: flattenTree(tree) };
+};
+
export const getDiffMode = diffFile => {
const diffModeKey = Object.keys(diffModes).find(key => diffFile[`${key}_file`]);
return (
diffModes[diffModeKey] ||
- (diffFile.mode_changed && diffModes.mode_changed) ||
+ (diffFile.viewer &&
+ diffFile.viewer.name === diffViewerModes.mode_changed &&
+ diffViewerModes.mode_changed) ||
diffModes.replaced
);
};
+
+export const convertExpandLines = ({
+ diffLines,
+ data,
+ typeKey,
+ oldLineKey,
+ newLineKey,
+ mapLine,
+}) => {
+ const dataLength = data.length;
+ const lines = [];
+
+ for (let i = 0, diffLinesLength = diffLines.length; i < diffLinesLength; i += 1) {
+ const line = diffLines[i];
+
+ if (_.property(typeKey)(line) === 'match') {
+ const beforeLine = diffLines[i - 1];
+ const afterLine = diffLines[i + 1];
+ const newLineProperty = _.property(newLineKey);
+ const beforeLineIndex = newLineProperty(beforeLine) || 0;
+ const afterLineIndex = newLineProperty(afterLine) - 1 || dataLength;
+
+ lines.push(
+ ...data.slice(beforeLineIndex, afterLineIndex).map((l, index) =>
+ mapLine({
+ line: Object.assign(l, { hasForm: false, discussions: [] }),
+ oldLine: (_.property(oldLineKey)(beforeLine) || 0) + index + 1,
+ newLine: (newLineProperty(beforeLine) || 0) + index + 1,
+ }),
+ ),
+ );
+ } else {
+ lines.push(line);
+ }
+ }
+
+ return lines;
+};
+
+export const idleCallback = cb => requestIdleCallback(cb);
diff --git a/app/assets/javascripts/diffs/workers/tree_worker.js b/app/assets/javascripts/diffs/workers/tree_worker.js
new file mode 100644
index 00000000000..415c463fd19
--- /dev/null
+++ b/app/assets/javascripts/diffs/workers/tree_worker.js
@@ -0,0 +1,19 @@
+import { sortTree } from '~/ide/stores/utils';
+import { generateTreeList } from '../store/utils';
+
+// eslint-disable-next-line no-restricted-globals
+self.addEventListener('message', e => {
+ const { data } = e;
+
+ if (data === undefined) {
+ return;
+ }
+
+ const { treeEntries, tree } = generateTreeList(data);
+
+ // eslint-disable-next-line no-restricted-globals
+ self.postMessage({
+ treeEntries,
+ tree: sortTree(tree),
+ });
+});
diff --git a/app/assets/javascripts/dirty_submit/dirty_submit_form.js b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
index d8d0fa1fac4..0fcaec9531c 100644
--- a/app/assets/javascripts/dirty_submit/dirty_submit_form.js
+++ b/app/assets/javascripts/dirty_submit/dirty_submit_form.js
@@ -1,4 +1,5 @@
import _ from 'underscore';
+import $ from 'jquery';
class DirtySubmitForm {
constructor(form) {
@@ -20,20 +21,27 @@ class DirtySubmitForm {
}
registerListeners() {
- const throttledUpdateDirtyInput = _.throttle(
- event => this.updateDirtyInput(event),
- DirtySubmitForm.THROTTLE_DURATION,
+ const getThrottledHandlerForInput = _.memoize(() =>
+ _.throttle(event => this.updateDirtyInput(event), DirtySubmitForm.THROTTLE_DURATION),
);
+
+ const throttledUpdateDirtyInput = event => {
+ const throttledHandler = getThrottledHandlerForInput(event.target.name);
+ throttledHandler(event);
+ };
+
this.form.addEventListener('input', throttledUpdateDirtyInput);
+ this.form.addEventListener('change', throttledUpdateDirtyInput);
+ $(this.form).on('change.select2', throttledUpdateDirtyInput);
this.form.addEventListener('submit', event => this.formSubmit(event));
}
updateDirtyInput(event) {
- const input = event.target;
+ const { target } = event;
- if (!input.dataset.isDirtySubmitInput) return;
+ if (!target.dataset.isDirtySubmitInput) return;
- this.updateDirtyInputs(input);
+ this.updateDirtyInputs(target);
this.toggleSubmission();
}
diff --git a/app/assets/javascripts/dismissable_callout.js b/app/assets/javascripts/dismissable_callout.js
deleted file mode 100644
index 5185b019376..00000000000
--- a/app/assets/javascripts/dismissable_callout.js
+++ /dev/null
@@ -1,27 +0,0 @@
-import $ from 'jquery';
-import axios from '~/lib/utils/axios_utils';
-import { __ } from '~/locale';
-import Flash from '~/flash';
-
-export default function initDismissableCallout(alertSelector) {
- const alertEl = document.querySelector(alertSelector);
- if (!alertEl) {
- return;
- }
-
- const closeButtonEl = alertEl.getElementsByClassName('close')[0];
- const { dismissEndpoint, featureId } = closeButtonEl.dataset;
-
- closeButtonEl.addEventListener('click', () => {
- axios
- .post(dismissEndpoint, {
- feature_name: featureId,
- })
- .then(() => {
- $(alertEl).alert('close');
- })
- .catch(() => {
- Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
- });
- });
-}
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 9987fbcb6a7..0ff26445a6a 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -4,6 +4,7 @@ import _ from 'underscore';
import './behaviors/preview_markdown';
import csrf from './lib/utils/csrf';
import axios from './lib/utils/axios_utils';
+import { n__, __ } from '~/locale';
Dropzone.autoDiscover = false;
@@ -90,7 +91,7 @@ export default function dropzoneInput(form) {
if (!processingFileCount) $attachButton.removeClass('hide');
addFileToForm(response.link.url);
},
- error: (file, errorMessage = 'Attaching the file failed.', xhr) => {
+ error: (file, errorMessage = __('Attaching the file failed.'), xhr) => {
// If 'error' event is fired by dropzone, the second parameter is error message.
// If the 'errorMessage' parameter is empty, the default error message is set.
// If the 'error' event is fired by backend (xhr) error response, the third parameter is
@@ -273,19 +274,11 @@ export default function dropzoneInput(form) {
};
updateAttachingMessage = (files, messageContainer) => {
- let attachingMessage;
const filesCount = files.filter(file => file.status === 'uploading' || file.status === 'queued')
.length;
+ const attachingMessage = n__('Attaching a file', 'Attaching %d files', filesCount);
- // Dinamycally change uploading files text depending on files number in
- // dropzone files queue.
- if (filesCount > 1) {
- attachingMessage = `Attaching ${filesCount} files -`;
- } else {
- attachingMessage = 'Attaching a file -';
- }
-
- messageContainer.text(attachingMessage);
+ messageContainer.text(`${attachingMessage} -`);
};
form.find('.markdown-selector').click(function onMarkdownClick(e) {
diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js
index dbfcf8cc921..3c650397a19 100644
--- a/app/assets/javascripts/due_date_select.js
+++ b/app/assets/javascripts/due_date_select.js
@@ -64,6 +64,7 @@ class DueDateSelect {
this.saveDueDate(true);
}
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($dueDateInput.val()));
@@ -103,7 +104,7 @@ class DueDateSelect {
const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]);
this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy');
} else {
- this.displayedDate = 'No due date';
+ this.displayedDate = __('None');
}
}
@@ -131,7 +132,7 @@ class DueDateSelect {
submitSelectedDate(isDropdown) {
const selectedDateValue = this.datePayload[this.abilityName].due_date;
- const hasDueDate = this.displayedDate !== 'No due date';
+ const hasDueDate = this.displayedDate !== __('None');
const displayedDateStyle = hasDueDate ? 'bold' : 'no-value';
this.$loading.removeClass('hidden').fadeIn();
@@ -183,6 +184,7 @@ export default class DueDateSelectors {
onSelect(dateText) {
$datePicker.val(calendar.toString(dateText));
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate(datePickerVal));
diff --git a/app/assets/javascripts/emoji/no_emoji_validator.js b/app/assets/javascripts/emoji/no_emoji_validator.js
new file mode 100644
index 00000000000..0fd4dd74953
--- /dev/null
+++ b/app/assets/javascripts/emoji/no_emoji_validator.js
@@ -0,0 +1,63 @@
+import { __ } from '~/locale';
+import emojiRegex from 'emoji-regex';
+
+const invalidInputClass = 'gl-field-error-outline';
+
+export default class NoEmojiValidator {
+ constructor(opts = {}) {
+ const container = opts.container || '';
+ this.noEmojiEmelents = document.querySelectorAll(`${container} .js-block-emoji`);
+
+ this.noEmojiEmelents.forEach(element =>
+ element.addEventListener('input', this.eventHandler.bind(this)),
+ );
+ }
+
+ eventHandler(event) {
+ this.inputDomElement = event.target;
+ this.inputErrorMessage = this.inputDomElement.nextSibling;
+
+ const { value } = this.inputDomElement;
+
+ this.validatePattern(value);
+ this.setValidationStateAndMessage();
+ }
+
+ validatePattern(value) {
+ const pattern = emojiRegex();
+ this.hasEmojis = new RegExp(pattern).test(value);
+
+ if (this.hasEmojis) {
+ this.inputDomElement.setCustomValidity(__('Invalid input, please avoid emojis'));
+ } else {
+ this.inputDomElement.setCustomValidity('');
+ }
+ }
+
+ setValidationStateAndMessage() {
+ if (!this.inputDomElement.checkValidity()) {
+ this.setInvalidState();
+ } else {
+ this.clearFieldValidationState();
+ }
+ }
+
+ clearFieldValidationState() {
+ this.inputDomElement.classList.remove(invalidInputClass);
+ this.inputErrorMessage.classList.add('hide');
+ }
+
+ setInvalidState() {
+ this.inputDomElement.classList.add(invalidInputClass);
+ this.setErrorMessage();
+ }
+
+ setErrorMessage() {
+ if (this.hasEmojis) {
+ this.inputErrorMessage.innerHTML = this.inputDomElement.validationMessage;
+ } else {
+ this.inputErrorMessage.innerHTML = this.inputDomElement.title;
+ }
+ this.inputErrorMessage.classList.remove('hide');
+ }
+}
diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
new file mode 100644
index 00000000000..70b5c6b0094
--- /dev/null
+++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue
@@ -0,0 +1,108 @@
+<script>
+/**
+ * Render modal to confirm rollback/redeploy.
+ */
+
+import _ from 'underscore';
+import { GlModal } from '@gitlab/ui';
+import { s__, sprintf } from '~/locale';
+
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ConfirmRollbackModal',
+
+ components: {
+ GlModal,
+ },
+
+ props: {
+ environment: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ modalTitle() {
+ const title = this.environment.isLastDeployment
+ ? s__('Environments|Re-deploy environment %{name}?')
+ : s__('Environments|Rollback environment %{name}?');
+
+ return sprintf(title, {
+ name: _.escape(this.environment.name),
+ });
+ },
+
+ commitShortSha() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'short_id');
+ },
+
+ commitUrl() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'commit_path');
+ },
+
+ commitTitle() {
+ const { last_deployment } = this.environment;
+ return this.commitData(last_deployment, 'title');
+ },
+
+ modalText() {
+ const linkStart = `<a class="commit-sha mr-0" href="${_.escape(this.commitUrl)}">`;
+ const commitId = _.escape(this.commitShortSha);
+ const linkEnd = '</a>';
+ const name = _.escape(this.name);
+ const body = this.environment.isLastDeployment
+ ? s__(
+ 'Environments|This action will relaunch the job for commit %{linkStart}%{commitId}%{linkEnd}, putting the environment in a previous version. Are you sure you want to continue?',
+ )
+ : s__(
+ 'Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?',
+ );
+ return sprintf(
+ body,
+ {
+ commitId,
+ linkStart,
+ linkEnd,
+ name,
+ },
+ false,
+ );
+ },
+
+ modalActionText() {
+ return this.environment.isLastDeployment
+ ? s__('Environments|Re-deploy')
+ : s__('Environments|Rollback');
+ },
+ },
+
+ methods: {
+ onOk() {
+ eventHub.$emit('rollbackEnvironment', this.environment);
+ },
+
+ commitData(lastDeployment, key) {
+ if (lastDeployment && lastDeployment.commit) {
+ return lastDeployment.commit[key];
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ :title="modalTitle"
+ modal-id="confirm-rollback-modal"
+ :ok-title="modalActionText"
+ ok-variant="danger"
+ @ok="onOk"
+ >
+ <p v-html="modalText"></p>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/environments/components/container.vue b/app/assets/javascripts/environments/components/container.vue
index bd402c0eea5..be80661223c 100644
--- a/app/assets/javascripts/environments/components/container.vue
+++ b/app/assets/javascripts/environments/components/container.vue
@@ -1,14 +1,16 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
-import tablePagination from '../../vue_shared/components/table_pagination.vue';
-import environmentTable from '../components/environments_table.vue';
+import TablePagination from '~/vue_shared/components/table_pagination.vue';
+import containerMixin from 'ee_else_ce/environments/mixins/container_mixin';
+import EnvironmentTable from '../components/environments_table.vue';
export default {
components: {
- environmentTable,
- tablePagination,
+ EnvironmentTable,
+ TablePagination,
GlLoadingIcon,
},
+ mixins: [containerMixin],
props: {
isLoading: {
type: Boolean,
@@ -22,10 +24,6 @@ export default {
type: Object,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -53,8 +51,12 @@ export default {
<div v-if="!isLoading && environments.length > 0" class="table-holder">
<environment-table
:environments="environments"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
/>
<table-pagination
diff --git a/app/assets/javascripts/environments/components/environment_actions.vue b/app/assets/javascripts/environments/components/environment_actions.vue
index 1f7dab9fbd2..208bd19f6b0 100644
--- a/app/assets/javascripts/environments/components/environment_actions.vue
+++ b/app/assets/javascripts/environments/components/environment_actions.vue
@@ -92,7 +92,7 @@ export default {
:disabled="isActionDisabled(action)"
type="button"
class="js-manual-action-link no-btn btn d-flex align-items-center"
- @click="onClickAction(action);"
+ @click="onClickAction(action)"
>
<span class="flex-fill"> {{ action.name }} </span>
<span v-if="action.scheduledAt" class="text-secondary">
diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue
index cd2f46fd07a..f0e80cba753 100644
--- a/app/assets/javascripts/environments/components/environment_item.vue
+++ b/app/assets/javascripts/environments/components/environment_item.vue
@@ -3,8 +3,8 @@ import Timeago from 'timeago.js';
import _ from 'underscore';
import { GlTooltipDirective } from '@gitlab/ui';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
-import { humanize } from '~/lib/utils/text_utility';
import Icon from '~/vue_shared/components/icon.vue';
+import environmentItemMixin from 'ee_else_ce/environments/mixins/environment_item_mixin';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
import StopComponent from './environment_stop.vue';
@@ -14,6 +14,7 @@ import MonitoringButtonComponent from './environment_monitoring.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import eventHub from '../event_hub';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { CLUSTER_TYPE } from '~/clusters/constants';
/**
* Environment Item Component
@@ -34,10 +35,10 @@ export default {
TerminalButtonComponent,
MonitoringButtonComponent,
},
-
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [environmentItemMixin],
props: {
model: {
@@ -46,12 +47,6 @@ export default {
default: () => ({}),
},
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
- },
-
canReadEnvironment: {
type: Boolean,
required: false,
@@ -85,6 +80,15 @@ export default {
},
/**
+ * Hide group cluster features which are not currently implemented.
+ *
+ * @returns {Boolean}
+ */
+ disableGroupClusterFeatures() {
+ return this.model && this.model.cluster_type === CLUSTER_TYPE.GROUP;
+ },
+
+ /**
* Returns whether the environment can be stopped.
*
* @returns {Boolean}
@@ -141,7 +145,7 @@ export default {
},
actions() {
- if (!this.model || !this.model.last_deployment || !this.canCreateDeployment) {
+ if (!this.model || !this.model.last_deployment) {
return [];
}
@@ -152,7 +156,7 @@ export default {
const combinedActions = (manualActions || []).concat(scheduledActions || []);
return combinedActions.map(action => ({
...action,
- name: humanize(action.name),
+ name: action.name,
}));
},
@@ -455,19 +459,37 @@ export default {
class="gl-responsive-table-row"
role="row"
>
- <div
- v-gl-tooltip
- :title="model.name"
- class="table-section section-wrap section-15 text-truncate"
- role="gridcell"
- >
+ <div class="table-section section-wrap section-15 text-truncate" role="gridcell">
<div v-if="!model.isFolder" class="table-mobile-header" role="rowheader">
{{ s__('Environments|Environment') }}
</div>
- <span v-if="!model.isFolder" class="environment-name table-mobile-content">
- <a class="qa-environment-link" :href="environmentPath"> {{ model.name }} </a>
+
+ <span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard">
+ <icon :name="deployIconName" />
+ </span>
+
+ <span
+ v-if="!model.isFolder"
+ v-gl-tooltip
+ :title="model.name"
+ class="environment-name table-mobile-content"
+ >
+ <a class="qa-environment-link" :href="environmentPath">
+ <span v-if="model.size === 1">{{ model.name }}</span>
+ <span v-else>{{ model.name_without_type }}</span>
+ </a>
+ <span v-if="isProtected" class="badge badge-success">
+ {{ s__('Environments|protected') }}
+ </span>
</span>
- <span v-else class="folder-name" role="button" @click="onClickFolder">
+ <span
+ v-else
+ v-gl-tooltip
+ :title="model.folderName"
+ class="folder-name"
+ role="button"
+ @click="onClickFolder"
+ >
<icon :name="folderIconName" class="folder-icon" />
<icon name="folder" class="folder-icon" />
@@ -482,22 +504,28 @@ export default {
class="table-section section-10 deployment-column d-none d-sm-none d-md-block"
role="gridcell"
>
- <span v-if="shouldRenderDeploymentID"> {{ deploymentInternalId }} </span>
+ <span v-if="shouldRenderDeploymentID" class="text-break-word">
+ {{ deploymentInternalId }}
+ </span>
- <span v-if="!model.isFolder && deploymentHasUser">
+ <span v-if="!model.isFolder && deploymentHasUser" class="text-break-word">
by
<user-avatar-link
:link-href="deploymentUser.web_url"
:img-src="deploymentUser.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="deploymentUser.username"
- class="js-deploy-user-container"
+ class="js-deploy-user-container float-none"
/>
</span>
</div>
<div class="table-section section-15 d-none d-sm-none d-md-block" role="gridcell">
- <a v-if="shouldRenderBuildName" :href="buildPath" class="build-link flex-truncate-parent">
+ <a
+ v-if="shouldRenderBuildName"
+ :href="buildPath"
+ class="build-link cgray flex-truncate-parent"
+ >
<span class="flex-truncate-child">{{ buildName }}</span>
</a>
</div>
@@ -547,10 +575,12 @@ export default {
<terminal-button-component
v-if="model && model.terminal_path"
:terminal-path="model.terminal_path"
+ :disabled="disableGroupClusterFeatures"
/>
<rollback-component
- v-if="canRetry && canCreateDeployment"
+ v-if="canRetry"
+ :environment="model"
:is-last-deployment="isLastDeployment"
:retry-url="retryUrl"
/>
diff --git a/app/assets/javascripts/environments/components/environment_rollback.vue b/app/assets/javascripts/environments/components/environment_rollback.vue
index 50c86af057c..bafbc00597e 100644
--- a/app/assets/javascripts/environments/components/environment_rollback.vue
+++ b/app/assets/javascripts/environments/components/environment_rollback.vue
@@ -5,29 +5,38 @@
*
* Makes a post request when the button is clicked.
*/
+import { GlTooltipDirective, GlLoadingIcon, GlModalDirective, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
-import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
+import ConfirmRollbackModal from './confirm_rollback_modal.vue';
import eventHub from '../event_hub';
export default {
components: {
Icon,
GlLoadingIcon,
+ GlButton,
+ ConfirmRollbackModal,
},
directives: {
GlTooltip: GlTooltipDirective,
+ GlModal: GlModalDirective,
},
props: {
- retryUrl: {
- type: String,
- default: '',
- },
-
isLastDeployment: {
type: Boolean,
default: true,
},
+
+ environment: {
+ type: Object,
+ required: true,
+ },
+
+ retryUrl: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -45,23 +54,30 @@ export default {
methods: {
onClick() {
- this.isLoading = true;
-
- eventHub.$emit('postAction', { endpoint: this.retryUrl });
+ eventHub.$emit('requestRollbackEnvironment', {
+ ...this.environment,
+ retryUrl: this.retryUrl,
+ isLastDeployment: this.isLastDeployment,
+ });
+ eventHub.$on('rollbackEnvironment', environment => {
+ if (environment.id === this.environment.id) {
+ this.isLoading = true;
+ }
+ });
},
},
};
</script>
<template>
- <button
+ <gl-button
v-gl-tooltip
+ v-gl-modal.confirm-rollback-modal
:disabled="isLoading"
:title="title"
- type="button"
- class="btn d-none d-sm-none d-md-block"
+ class="d-none d-md-block text-secondary"
@click="onClick"
>
<icon v-if="isLastDeployment" name="repeat" /> <icon v-else name="redo" />
<gl-loading-icon v-if="isLoading" />
- </button>
+ </gl-button>
</template>
diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.vue b/app/assets/javascripts/environments/components/environment_terminal_button.vue
index 83727caad16..13195d32cc4 100644
--- a/app/assets/javascripts/environments/components/environment_terminal_button.vue
+++ b/app/assets/javascripts/environments/components/environment_terminal_button.vue
@@ -19,6 +19,11 @@ export default {
required: false,
default: '',
},
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
title() {
@@ -33,7 +38,8 @@ export default {
:title="title"
:aria-label="title"
:href="terminalPath"
- class="btn terminal-button d-none d-sm-none d-md-block"
+ :class="{ disabled: disabled }"
+ class="btn terminal-button d-none d-sm-none d-md-block text-secondary"
>
<icon name="terminal" />
</a>
diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue
index ae9459a2482..ec78240217b 100644
--- a/app/assets/javascripts/environments/components/environments_app.vue
+++ b/app/assets/javascripts/environments/components/environments_app.vue
@@ -1,4 +1,5 @@
<script>
+import envrionmentsAppMixin from 'ee_else_ce/environments/mixins/environments_app_mixin';
import Flash from '../../flash';
import { s__ } from '../../locale';
import emptyState from './empty_state.vue';
@@ -6,14 +7,16 @@ import eventHub from '../event_hub';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from './stop_environment_modal.vue';
+import ConfirmRollbackModal from './confirm_rollback_modal.vue';
export default {
components: {
emptyState,
StopEnvironmentModal,
+ ConfirmRollbackModal,
},
- mixins: [CIPaginationMixin, environmentsMixin],
+ mixins: [CIPaginationMixin, environmentsMixin, envrionmentsAppMixin],
props: {
endpoint: {
@@ -24,10 +27,6 @@ export default {
type: Boolean,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -91,6 +90,7 @@ export default {
<template>
<div :class="cssContainerClass">
<stop-environment-modal :environment="environmentInStopModal" />
+ <confirm-rollback-modal :environment="environmentInRollbackModal" />
<div class="top-area">
<tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
@@ -106,8 +106,12 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
@onChangePage="onChangePage"
>
<empty-state
diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue
index 533e90e2222..55613d815ce 100644
--- a/app/assets/javascripts/environments/components/environments_table.vue
+++ b/app/assets/javascripts/environments/components/environments_table.vue
@@ -3,31 +3,38 @@
* Render environments table.
*/
import { GlLoadingIcon } from '@gitlab/ui';
-import environmentItem from './environment_item.vue';
+import _ from 'underscore';
+import environmentTableMixin from 'ee_else_ce/environments/mixins/environments_table_mixin';
+import EnvironmentItem from './environment_item.vue';
export default {
components: {
- environmentItem,
+ EnvironmentItem,
GlLoadingIcon,
+ DeployBoard: () => import('ee_component/environments/components/deploy_board_component.vue'),
+ CanaryDeploymentCallout: () =>
+ import('ee_component/environments/components/canary_deployment_callout.vue'),
},
-
+ mixins: [environmentTableMixin],
props: {
environments: {
type: Array,
required: true,
default: () => [],
},
-
canReadEnvironment: {
type: Boolean,
required: false,
default: false,
},
-
- canCreateDeployment: {
- type: Boolean,
- required: false,
- default: false,
+ },
+ computed: {
+ sortedEnvironments() {
+ return this.sortEnvironments(this.environments).map(env =>
+ this.shouldRenderFolderContent(env)
+ ? { ...env, children: this.sortEnvironments(env.children) }
+ : env,
+ );
},
},
methods: {
@@ -37,6 +44,30 @@ export default {
shouldRenderFolderContent(env) {
return env.isFolder && env.isOpen && env.children && env.children.length > 0;
},
+ sortEnvironments(environments) {
+ /*
+ * The sorting algorithm should sort in the following priorities:
+ *
+ * 1. folders first,
+ * 2. last updated descending,
+ * 3. by name ascending,
+ *
+ * the sorting algorithm must:
+ *
+ * 1. Sort by name ascending,
+ * 2. Reverse (sort by name descending),
+ * 3. Sort by last deployment ascending,
+ * 4. Reverse (last deployment descending, name ascending),
+ * 5. Put folders first.
+ */
+ return _.chain(environments)
+ .sortBy(env => (env.isFolder ? env.folderName : env.name))
+ .reverse()
+ .sortBy(env => (env.last_deployment ? env.last_deployment.created_at : '0000'))
+ .reverse()
+ .sortBy(env => (env.isFolder ? -1 : 1))
+ .value();
+ },
},
};
</script>
@@ -59,15 +90,29 @@ export default {
{{ s__('Environments|Updated') }}
</div>
</div>
- <template v-for="(model, i) in environments" :model="model">
+ <template v-for="(model, i) in sortedEnvironments" :model="model">
<div
is="environment-item"
:key="`environment-item-${i}`"
:model="model"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
/>
+ <div
+ v-if="shouldRenderDeployBoard(model)"
+ :key="`deploy-board-row-${i}`"
+ class="js-deploy-board-row"
+ >
+ <div class="deploy-board-container">
+ <deploy-board
+ :deploy-board-data="model.deployBoardData"
+ :is-loading="model.isLoadingDeployBoard"
+ :is-empty="model.isEmptyDeployBoard"
+ :logs-path="model.logs_path"
+ />
+ </div>
+ </div>
+
<template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
<gl-loading-icon :size="2" class="prepend-top-16" />
@@ -79,7 +124,6 @@ export default {
v-for="(children, index) in model.children"
:key="`env-item-${i}-${index}`"
:model="children"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
/>
@@ -92,6 +136,17 @@ export default {
</div>
</template>
</template>
+
+ <template v-if="shouldShowCanaryCallout(model)">
+ <canary-deployment-callout
+ :key="`canary-promo-${i}`"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
+ :data-js-canary-promo-key="i"
+ />
+ </template>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
index 3cf6e4ad14d..c1bfe8d05fe 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js
+++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
import environmentsFolderApp from './environments_folder_view.vue';
import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate';
@@ -11,15 +12,15 @@ export default () =>
components: {
environmentsFolderApp,
},
+ mixins: [canaryCalloutMixin],
data() {
const environmentsData = document.querySelector(this.$options.el).dataset;
return {
- endpoint: environmentsData.endpoint,
- folderName: environmentsData.folderName,
+ endpoint: environmentsData.environmentsDataEndpoint,
+ folderName: environmentsData.environmentsDataFolderName,
cssContainerClass: environmentsData.cssClass,
- canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment),
- canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
+ canReadEnvironment: parseBoolean(environmentsData.environmentsDataCanReadEnvironment),
};
},
render(createElement) {
@@ -28,8 +29,8 @@ export default () =>
endpoint: this.endpoint,
folderName: this.folderName,
cssContainerClass: this.cssContainerClass,
- canCreateDeployment: this.canCreateDeployment,
canReadEnvironment: this.canReadEnvironment,
+ ...this.canaryCalloutProps,
},
});
},
diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.vue b/app/assets/javascripts/environments/folder/environments_folder_view.vue
index d6f0b6115a6..6fd0561f682 100644
--- a/app/assets/javascripts/environments/folder/environments_folder_view.vue
+++ b/app/assets/javascripts/environments/folder/environments_folder_view.vue
@@ -1,4 +1,5 @@
<script>
+import folderMixin from 'ee_else_ce/environments/mixins/environments_folder_view_mixin';
import environmentsMixin from '../mixins/environments_mixin';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import StopEnvironmentModal from '../components/stop_environment_modal.vue';
@@ -8,7 +9,7 @@ export default {
StopEnvironmentModal,
},
- mixins: [environmentsMixin, CIPaginationMixin],
+ mixins: [environmentsMixin, CIPaginationMixin, folderMixin],
props: {
endpoint: {
@@ -23,10 +24,6 @@ export default {
type: String,
required: true,
},
- canCreateDeployment: {
- type: Boolean,
- required: true,
- },
canReadEnvironment: {
type: Boolean,
required: true,
@@ -45,7 +42,8 @@ export default {
<div v-if="!isLoading" class="top-area">
<h4 class="js-folder-name environments-folder-name">
- {{ s__('Environments|Environments') }} / <b>{{ folderName }}</b>
+ {{ s__('Environments|Environments') }} /
+ <b>{{ folderName }}</b>
</h4>
<tabs :tabs="tabs" scope="environments" @onChangeTab="onChangeTab" />
@@ -55,8 +53,12 @@ export default {
:is-loading="isLoading"
:environments="state.environments"
:pagination="state.paginationInformation"
- :can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
+ :canary-deployment-feature-id="canaryDeploymentFeatureId"
+ :show-canary-deployment-callout="showCanaryDeploymentCallout"
+ :user-callouts-path="userCalloutsPath"
+ :lock-promotion-svg-path="lockPromotionSvgPath"
+ :help-canary-deployments-path="helpCanaryDeploymentsPath"
@onChangePage="onChangePage"
/>
</div>
diff --git a/app/assets/javascripts/environments/index.js b/app/assets/javascripts/environments/index.js
index d366e7550b7..b53d42f202b 100644
--- a/app/assets/javascripts/environments/index.js
+++ b/app/assets/javascripts/environments/index.js
@@ -1,4 +1,5 @@
import Vue from 'vue';
+import canaryCalloutMixin from 'ee_else_ce/environments/mixins/canary_callout_mixin';
import environmentsComponent from './components/environments_app.vue';
import { parseBoolean } from '../lib/utils/common_utils';
import Translate from '../vue_shared/translate';
@@ -11,6 +12,7 @@ export default () =>
components: {
environmentsComponent,
},
+ mixins: [canaryCalloutMixin],
data() {
const environmentsData = document.querySelector(this.$options.el).dataset;
@@ -20,7 +22,6 @@ export default () =>
helpPagePath: environmentsData.helpPagePath,
cssContainerClass: environmentsData.cssClass,
canCreateEnvironment: parseBoolean(environmentsData.canCreateEnvironment),
- canCreateDeployment: parseBoolean(environmentsData.canCreateDeployment),
canReadEnvironment: parseBoolean(environmentsData.canReadEnvironment),
};
},
@@ -32,8 +33,8 @@ export default () =>
helpPagePath: this.helpPagePath,
cssContainerClass: this.cssContainerClass,
canCreateEnvironment: this.canCreateEnvironment,
- canCreateDeployment: this.canCreateDeployment,
canReadEnvironment: this.canReadEnvironment,
+ ...this.canaryCalloutProps,
},
});
},
diff --git a/app/assets/javascripts/environments/mixins/canary_callout_mixin.js b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
new file mode 100644
index 00000000000..f6d3d67b777
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/canary_callout_mixin.js
@@ -0,0 +1,5 @@
+export default {
+ computed: {
+ canaryCalloutProps() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/container_mixin.js b/app/assets/javascripts/environments/mixins/container_mixin.js
new file mode 100644
index 00000000000..f2907c120f8
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/container_mixin.js
@@ -0,0 +1,29 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environment_item_mixin.js b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
new file mode 100644
index 00000000000..2dfed36ec99
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environment_item_mixin.js
@@ -0,0 +1,13 @@
+export default {
+ computed: {
+ deployIconName() {
+ return '';
+ },
+ shouldRenderDeployBoard() {
+ return false;
+ },
+ },
+ methods: {
+ toggleDeployBoard() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_app_mixin.js b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
new file mode 100644
index 00000000000..fc805b9235a
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_app_mixin.js
@@ -0,0 +1,32 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ metods: {
+ toggleDeployBoard() {},
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
new file mode 100644
index 00000000000..e793a7cadf2
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_folder_view_mixin.js
@@ -0,0 +1,29 @@
+export default {
+ props: {
+ canaryDeploymentFeatureId: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ showCanaryDeploymentCallout: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ userCalloutsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ lockPromotionSvgPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ helpCanaryDeploymentsPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js
index 96dc1f07cb9..a5812b173dc 100644
--- a/app/assets/javascripts/environments/mixins/environments_mixin.js
+++ b/app/assets/javascripts/environments/mixins/environments_mixin.js
@@ -3,13 +3,13 @@
*/
import _ from 'underscore';
import Visibility from 'visibilityjs';
+import EnvironmentsStore from 'ee_else_ce/environments/stores/environments_store';
import Poll from '../../lib/utils/poll';
import { getParameterByName } from '../../lib/utils/common_utils';
import { s__ } from '../../locale';
import Flash from '../../flash';
import eventHub from '../event_hub';
-import EnvironmentsStore from '../stores/environments_store';
import EnvironmentsService from '../services/environments_service';
import tablePagination from '../../vue_shared/components/table_pagination.vue';
import environmentTable from '../components/environments_table.vue';
@@ -36,6 +36,7 @@ export default {
page: getParameterByName('page') || '1',
requestData: {},
environmentInStopModal: {},
+ environmentInRollbackModal: {},
};
},
@@ -43,7 +44,11 @@ export default {
saveData(resp) {
this.isLoading = false;
- if (_.isEqual(resp.config.params, this.requestData)) {
+ // Prevent the absence of the nested flag from causing mismatches
+ const response = this.filterNilValues(resp.config.params);
+ const request = this.filterNilValues(this.requestData);
+
+ if (_.isEqual(response, request)) {
this.store.storeAvailableCount(resp.data.available_count);
this.store.storeStoppedCount(resp.data.stopped_count);
this.store.storeEnvironments(resp.data.environments);
@@ -51,6 +56,10 @@ export default {
}
},
+ filterNilValues(obj) {
+ return _.omit(obj, value => _.isUndefined(value) || _.isNull(value));
+ },
+
/**
* Handles URL and query parameter changes.
* When the user uses the pagination or the tabs,
@@ -64,10 +73,9 @@ export default {
// fetch new data
return this.service
.fetchEnvironments(this.requestData)
- .then(response => this.successCallback(response))
- .then(() => {
- // restart polling
- this.poll.restart({ data: this.requestData });
+ .then(response => {
+ this.successCallback(response);
+ this.poll.enable({ data: this.requestData, response });
})
.catch(() => {
this.errorCallback();
@@ -109,6 +117,10 @@ export default {
this.environmentInStopModal = environment;
},
+ updateRollbackModal(environment) {
+ this.environmentInRollbackModal = environment;
+ },
+
stopEnvironment(environment) {
const endpoint = environment.stop_path;
const errorMessage = s__(
@@ -116,6 +128,16 @@ export default {
);
this.postAction({ endpoint, errorMessage });
},
+
+ rollbackEnvironment(environment) {
+ const { retryUrl, isLastDeployment } = environment;
+ const errorMessage = isLastDeployment
+ ? s__('Environments|An error occurred while re-deploying the environment, please try again')
+ : s__(
+ 'Environments|An error occurred while rolling back the environment, please try again',
+ );
+ this.postAction({ endpoint: retryUrl, errorMessage });
+ },
},
computed: {
@@ -143,7 +165,7 @@ export default {
*/
created() {
this.service = new EnvironmentsService(this.endpoint);
- this.requestData = { page: this.page, scope: this.scope };
+ this.requestData = { page: this.page, scope: this.scope, nested: true };
this.poll = new Poll({
resource: this.service,
@@ -174,11 +196,17 @@ export default {
eventHub.$on('postAction', this.postAction);
eventHub.$on('requestStopEnvironment', this.updateStopModal);
eventHub.$on('stopEnvironment', this.stopEnvironment);
+
+ eventHub.$on('requestRollbackEnvironment', this.updateRollbackModal);
+ eventHub.$on('rollbackEnvironment', this.rollbackEnvironment);
},
beforeDestroy() {
eventHub.$off('postAction', this.postAction);
eventHub.$off('requestStopEnvironment', this.updateStopModal);
eventHub.$off('stopEnvironment', this.stopEnvironment);
+
+ eventHub.$off('requestRollbackEnvironment', this.updateRollbackModal);
+ eventHub.$off('rollbackEnvironment', this.rollbackEnvironment);
},
};
diff --git a/app/assets/javascripts/environments/mixins/environments_table_mixin.js b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
new file mode 100644
index 00000000000..208f1a7373d
--- /dev/null
+++ b/app/assets/javascripts/environments/mixins/environments_table_mixin.js
@@ -0,0 +1,10 @@
+export default {
+ methods: {
+ shouldShowCanaryCallout() {
+ return false;
+ },
+ shouldRenderDeployBoard() {
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js
index 4e07ccba91a..cb4ff6856db 100644
--- a/app/assets/javascripts/environments/services/environments_service.js
+++ b/app/assets/javascripts/environments/services/environments_service.js
@@ -7,8 +7,8 @@ export default class EnvironmentsService {
}
fetchEnvironments(options = {}) {
- const { scope, page } = options;
- return axios.get(this.environmentsEndpoint, { params: { scope, page } });
+ const { scope, page, nested } = options;
+ return axios.get(this.environmentsEndpoint, { params: { scope, page, nested } });
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js
index 5808a2d4afa..5fb420e9da5 100644
--- a/app/assets/javascripts/environments/stores/environments_store.js
+++ b/app/assets/javascripts/environments/stores/environments_store.js
@@ -1,4 +1,6 @@
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
+import { setDeployBoard } from 'ee_else_ce/environments/stores/helpers';
+
/**
* Environments Store.
*
@@ -20,7 +22,8 @@ export default class EnvironmentsStore {
*
* Stores the received environments.
*
- * In the main environments endpoint, each environment has the following schema
+ * In the main environments endpoint (with { nested: true } in params), each folder
+ * has the following schema:
* { name: String, size: Number, latest: Object }
* In the endpoint to retrieve environments from each folder, the environment does
* not have the `latest` key and the data is all in the root level.
@@ -30,6 +33,14 @@ export default class EnvironmentsStore {
* If the `size` is bigger than 1, it means it should be rendered as a folder.
* In those cases we add `isFolder` key in order to render it properly.
*
+ * Top level environments - when the size is 1 - with `rollout_status`
+ * can render a deploy board. We add `isDeployBoardVisible` and `deployBoardData`
+ * keys to those environments.
+ * The first key will let's us know if we should or not render the deploy board.
+ * It will be toggled when the user clicks to seee the deploy board.
+ *
+ * The second key will allow us to update the environment with the received deploy board data.
+ *
* @param {Array} environments
* @returns {Array}
*/
@@ -62,6 +73,7 @@ export default class EnvironmentsStore {
filtered = Object.assign(filtered, env);
}
+ filtered = setDeployBoard(oldEnvironmentState, filtered);
return filtered;
});
@@ -70,6 +82,20 @@ export default class EnvironmentsStore {
return filteredEnvironments;
}
+ /**
+ * Stores the pagination information needed to render the pagination for the
+ * table.
+ *
+ * Normalizes the headers to uppercase since they can be provided either
+ * in uppercase or lowercase.
+ *
+ * Parses to an integer the normalized ones needed for the pagination component.
+ *
+ * Stores the normalized and parsed information.
+ *
+ * @param {Object} pagination = {}
+ * @return {Object}
+ */
setPagination(pagination = {}) {
const normalizedHeaders = normalizeHeaders(pagination);
const paginationInformation = parseIntPagination(normalizedHeaders);
diff --git a/app/assets/javascripts/environments/stores/helpers.js b/app/assets/javascripts/environments/stores/helpers.js
new file mode 100644
index 00000000000..8eba6c00601
--- /dev/null
+++ b/app/assets/javascripts/environments/stores/helpers.js
@@ -0,0 +1,8 @@
+/**
+ * Deploy boards are EE only.
+ *
+ * @param {Object} environment
+ * @returns {Object}
+ */
+// eslint-disable-next-line import/prefer-default-export
+export const setDeployBoard = (oldEnvironmentState, environment) => environment;
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
new file mode 100644
index 00000000000..43ae54133af
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue
@@ -0,0 +1,124 @@
+<script>
+import { mapActions, mapState } from 'vuex';
+import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
+import { __ } from '~/locale';
+
+export default {
+ fields: [
+ { key: 'error', label: __('Open errors') },
+ { key: 'events', label: __('Events') },
+ { key: 'users', label: __('Users') },
+ { key: 'lastSeen', label: __('Last seen') },
+ ],
+ components: {
+ GlEmptyState,
+ GlButton,
+ GlLink,
+ GlLoadingIcon,
+ GlTable,
+ Icon,
+ TimeAgo,
+ },
+ props: {
+ indexPath: {
+ type: String,
+ required: true,
+ },
+ enableErrorTrackingLink: {
+ type: String,
+ required: true,
+ },
+ errorTrackingEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ illustrationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['errors', 'externalUrl', 'loading']),
+ },
+ created() {
+ if (this.errorTrackingEnabled) {
+ this.startPolling(this.indexPath);
+ }
+ },
+ methods: {
+ ...mapActions(['startPolling', 'restartPolling']),
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div v-if="errorTrackingEnabled">
+ <div v-if="loading" class="py-3">
+ <gl-loading-icon :size="3" />
+ </div>
+ <div v-else>
+ <div class="d-flex justify-content-end">
+ <gl-button class="my-3 ml-auto" variant="primary" :href="externalUrl" target="_blank">
+ {{ __('View in Sentry') }}
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+ <gl-table :items="errors" :fields="$options.fields" :show-empty="true">
+ <template slot="HEAD_events" slot-scope="data">
+ <div class="text-right">{{ data.label }}</div>
+ </template>
+ <template slot="HEAD_users" slot-scope="data">
+ <div class="text-right">{{ data.label }}</div>
+ </template>
+ <template slot="error" slot-scope="errors">
+ <div class="d-flex flex-column">
+ <div class="d-flex">
+ <gl-link :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank">
+ <strong>{{ errors.item.title.trim() }}</strong>
+ <icon name="external-link" class="ml-1" />
+ </gl-link>
+ <span class="text-secondary ml-2">{{ errors.item.culprit }}</span>
+ </div>
+ {{ errors.item.message || __('No details available') }}
+ </div>
+ </template>
+
+ <template slot="events" slot-scope="errors">
+ <div class="text-right">{{ errors.item.count }}</div>
+ </template>
+
+ <template slot="users" slot-scope="errors">
+ <div class="text-right">{{ errors.item.userCount }}</div>
+ </template>
+
+ <template slot="lastSeen" slot-scope="errors">
+ <div class="d-flex align-items-center">
+ <icon name="calendar" css-classes="text-secondary mr-1" />
+ <time-ago :time="errors.item.lastSeen" class="text-secondary" />
+ </div>
+ </template>
+ <template slot="empty">
+ <div ref="empty">
+ {{ __('No errors to display.') }}
+ <gl-link class="js-try-again" @click="restartPolling">
+ {{ __('Check again') }}
+ </gl-link>
+ </div>
+ </template>
+ </gl-table>
+ </div>
+ </div>
+ <div v-else>
+ <gl-empty-state
+ :title="__('Get started with error tracking')"
+ :description="__('Monitor your errors by integrating with Sentry')"
+ :primary-button-text="__('Enable error tracking')"
+ :primary-button-link="enableErrorTrackingLink"
+ :svg-path="illustrationPath"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/index.js
new file mode 100644
index 00000000000..3d609448efe
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/index.js
@@ -0,0 +1,31 @@
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import store from './store';
+import ErrorTrackingList from './components/error_tracking_list.vue';
+
+export default () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-error_tracking',
+ components: {
+ ErrorTrackingList,
+ },
+ store,
+ render(createElement) {
+ const domEl = document.querySelector(this.$options.el);
+ const { indexPath, enableErrorTrackingLink, illustrationPath } = domEl.dataset;
+ let { errorTrackingEnabled } = domEl.dataset;
+
+ errorTrackingEnabled = parseBoolean(errorTrackingEnabled);
+
+ return createElement('error-tracking-list', {
+ props: {
+ indexPath,
+ enableErrorTrackingLink,
+ errorTrackingEnabled,
+ illustrationPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js
new file mode 100644
index 00000000000..ab89521dc46
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/services/index.js
@@ -0,0 +1,7 @@
+import axios from '~/lib/utils/axios_utils';
+
+export default {
+ getErrorList({ endpoint }) {
+ return axios.get(endpoint);
+ },
+};
diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js
new file mode 100644
index 00000000000..1e754a4f54f
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/actions.js
@@ -0,0 +1,52 @@
+import Service from '../services';
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import Poll from '~/lib/utils/poll';
+import { __, sprintf } from '~/locale';
+
+let eTagPoll;
+
+export function startPolling({ commit, dispatch }, endpoint) {
+ eTagPoll = new Poll({
+ resource: Service,
+ method: 'getErrorList',
+ data: { endpoint },
+ successCallback: ({ data }) => {
+ if (!data) {
+ return;
+ }
+ commit(types.SET_ERRORS, data.errors);
+ commit(types.SET_EXTERNAL_URL, data.external_url);
+ commit(types.SET_LOADING, false);
+ dispatch('stopPolling');
+ },
+ errorCallback: ({ response }) => {
+ let errorMessage = '';
+ if (response && response.data && response.data.message) {
+ errorMessage = response.data.message;
+ }
+ commit(types.SET_LOADING, false);
+ createFlash(
+ sprintf(__(`Failed to load errors from Sentry. Error message: %{errorMessage}`), {
+ errorMessage,
+ }),
+ );
+ },
+ });
+
+ eTagPoll.makeRequest();
+}
+
+export const stopPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+
+export function restartPolling({ commit }) {
+ commit(types.SET_ERRORS, []);
+ commit(types.SET_EXTERNAL_URL, '');
+ commit(types.SET_LOADING, true);
+
+ if (eTagPoll) eTagPoll.restart();
+}
+
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js
new file mode 100644
index 00000000000..3136682fb64
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/index.js
@@ -0,0 +1,19 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ state: {
+ errors: [],
+ externalUrl: '',
+ loading: true,
+ },
+ actions,
+ mutations,
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/mutation_types.js
new file mode 100644
index 00000000000..f9d77a6b08e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/mutation_types.js
@@ -0,0 +1,3 @@
+export const SET_ERRORS = 'SET_ERRORS';
+export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL';
+export const SET_LOADING = 'SET_LOADING';
diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/mutations.js
new file mode 100644
index 00000000000..e4bd81db9c9
--- /dev/null
+++ b/app/assets/javascripts/error_tracking/store/mutations.js
@@ -0,0 +1,14 @@
+import * as types from './mutation_types';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+export default {
+ [types.SET_ERRORS](state, data) {
+ state.errors = convertObjectPropsToCamelCase(data, { deep: true });
+ },
+ [types.SET_EXTERNAL_URL](state, url) {
+ state.externalUrl = url;
+ },
+ [types.SET_LOADING](state, loading) {
+ state.loading = loading;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue
new file mode 100644
index 00000000000..50eb3e63b7c
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/app.vue
@@ -0,0 +1,129 @@
+<script>
+import { mapActions, mapGetters, mapState } from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import ProjectDropdown from './project_dropdown.vue';
+import ErrorTrackingForm from './error_tracking_form.vue';
+
+export default {
+ components: { ProjectDropdown, ErrorTrackingForm, GlButton },
+ props: {
+ initialApiHost: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ initialEnabled: {
+ type: String,
+ required: true,
+ },
+ initialProject: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ initialToken: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ listProjectsEndpoint: {
+ type: String,
+ required: true,
+ },
+ operationsSettingsEndpoint: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters([
+ 'dropdownLabel',
+ 'hasProjects',
+ 'invalidProjectLabel',
+ 'isProjectInvalid',
+ 'projectSelectionLabel',
+ ]),
+ ...mapState([
+ 'apiHost',
+ 'connectError',
+ 'connectSuccessful',
+ 'enabled',
+ 'projects',
+ 'selectedProject',
+ 'settingsLoading',
+ 'token',
+ ]),
+ },
+ created() {
+ this.setInitialState({
+ apiHost: this.initialApiHost,
+ enabled: this.initialEnabled,
+ project: this.initialProject,
+ token: this.initialToken,
+ listProjectsEndpoint: this.listProjectsEndpoint,
+ operationsSettingsEndpoint: this.operationsSettingsEndpoint,
+ });
+ },
+ methods: {
+ ...mapActions([
+ 'fetchProjects',
+ 'setInitialState',
+ 'updateApiHost',
+ 'updateEnabled',
+ 'updateSelectedProject',
+ 'updateSettings',
+ 'updateToken',
+ ]),
+ handleSubmit() {
+ this.updateSettings();
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="form-check form-group">
+ <input
+ id="error-tracking-enabled"
+ :checked="enabled"
+ class="form-check-input"
+ type="checkbox"
+ @change="updateEnabled($event.target.checked)"
+ />
+ <label class="form-check-label" for="error-tracking-enabled">{{
+ s__('ErrorTracking|Active')
+ }}</label>
+ </div>
+ <error-tracking-form
+ :api-host="apiHost"
+ :connect-error="connectError"
+ :connect-successful="connectSuccessful"
+ :token="token"
+ @handle-connect="fetchProjects"
+ @update-api-host="updateApiHost"
+ @update-token="updateToken"
+ />
+ <div class="form-group">
+ <project-dropdown
+ :has-projects="hasProjects"
+ :invalid-project-label="invalidProjectLabel"
+ :is-project-invalid="isProjectInvalid"
+ :dropdown-label="dropdownLabel"
+ :project-selection-label="projectSelectionLabel"
+ :projects="projects"
+ :selected-project="selectedProject"
+ :token="token"
+ @select-project="updateSelectedProject"
+ />
+ </div>
+ <gl-button
+ :disabled="settingsLoading"
+ class="js-error-tracking-button"
+ variant="success"
+ @click="handleSubmit"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
new file mode 100644
index 00000000000..060d8e25227
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlFormInput } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: { GlButton, GlFormInput, Icon },
+ props: {
+ apiHost: {
+ type: String,
+ required: true,
+ },
+ connectError: {
+ type: Boolean,
+ required: true,
+ },
+ connectSuccessful: {
+ type: Boolean,
+ required: true,
+ },
+ token: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ tokenInputState() {
+ return this.connectError ? false : null;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="form-group">
+ <label class="label-bold" for="error-tracking-api-host">{{ __('Sentry API URL') }}</label>
+ <div class="row">
+ <div class="col-8 col-md-9 gl-pr-0">
+ <gl-form-input
+ id="error-tracking-api-host"
+ :value="apiHost"
+ placeholder="https://mysentryserver.com"
+ @input="$emit('update-api-host', $event)"
+ />
+ </div>
+ </div>
+ <p class="form-text text-muted">
+ {{ s__('ErrorTracking|Find your hostname in your Sentry account settings page') }}
+ </p>
+ </div>
+ <div class="form-group" :class="{ 'gl-show-field-errors': connectError }">
+ <label class="label-bold" for="error-tracking-token">{{
+ s__('ErrorTracking|Auth Token')
+ }}</label>
+ <div class="row">
+ <div class="col-8 col-md-9 gl-pr-0">
+ <gl-form-input
+ id="error-tracking-token"
+ :value="token"
+ :state="tokenInputState"
+ @input="$emit('update-token', $event)"
+ />
+ </div>
+ <div class="col-4 col-md-3 gl-pl-0">
+ <gl-button
+ class="js-error-tracking-connect prepend-left-5"
+ @click="$emit('handle-connect')"
+ >
+ {{ __('Connect') }}
+ </gl-button>
+ <icon
+ v-show="connectSuccessful"
+ class="js-error-tracking-connect-success prepend-left-5 text-success align-middle"
+ :aria-label="__('Projects Successfully Retrieved')"
+ name="check-circle"
+ />
+ </div>
+ </div>
+ <p v-if="connectError" class="gl-field-error">
+ {{ s__('ErrorTracking|Connection has failed. Re-check Auth Token and try again.') }}
+ </p>
+ <p v-else class="form-text text-muted">
+ {{
+ s__(
+ "ErrorTracking|After adding your Auth Token, use the 'Connect' button to load projects",
+ )
+ }}
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
new file mode 100644
index 00000000000..82df02afafd
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { getDisplayName } from '../utils';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownHeader,
+ GlDropdownItem,
+ Icon,
+ },
+ props: {
+ dropdownLabel: {
+ type: String,
+ required: true,
+ },
+ hasProjects: {
+ type: Boolean,
+ required: true,
+ },
+ invalidProjectLabel: {
+ type: String,
+ required: true,
+ },
+ isProjectInvalid: {
+ type: Boolean,
+ required: true,
+ },
+ projects: {
+ type: Array,
+ required: true,
+ },
+ selectedProject: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ projectSelectionLabel: {
+ type: String,
+ required: true,
+ },
+ token: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ getDisplayName,
+ },
+};
+</script>
+
+<template>
+ <div :class="{ 'gl-show-field-errors': isProjectInvalid }">
+ <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label>
+ <div class="row">
+ <gl-dropdown
+ id="project-dropdown"
+ class="col-8 col-md-9 gl-pr-0"
+ :disabled="!hasProjects"
+ menu-class="w-100 mw-100"
+ toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline"
+ :text="dropdownLabel"
+ >
+ <gl-dropdown-item
+ v-for="project in projects"
+ :key="`${project.organizationSlug}.${project.slug}`"
+ class="w-100"
+ @click="$emit('select-project', project)"
+ >{{ getDisplayName(project) }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error">
+ {{ invalidProjectLabel }}
+ </p>
+ <p v-else-if="!hasProjects" class="js-project-dropdown-label form-text text-muted">
+ {{ projectSelectionLabel }}
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/error_tracking_settings/index.js b/app/assets/javascripts/error_tracking_settings/index.js
new file mode 100644
index 00000000000..e39452353f5
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import ErrorTrackingSettings from './components/app.vue';
+import createStore from './store';
+import initSettingsPanels from '~/settings_panels';
+
+export default () => {
+ initSettingsPanels();
+ const formContainerEl = document.querySelector('.js-error-tracking-form');
+ const {
+ dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ } = formContainerEl;
+
+ return new Vue({
+ el: formContainerEl,
+ store: createStore(),
+ render(createElement) {
+ return createElement(ErrorTrackingSettings, {
+ props: {
+ initialApiHost: apiHost,
+ initialEnabled: enabled,
+ initialProject: project,
+ initialToken: token,
+ listProjectsEndpoint,
+ operationsSettingsEndpoint,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js
new file mode 100644
index 00000000000..95105797807
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/actions.js
@@ -0,0 +1,91 @@
+import { __ } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+import { refreshCurrentPage } from '~/lib/utils/url_utility';
+import createFlash from '~/flash';
+import { transformFrontendSettings } from '../utils';
+import * as types from './mutation_types';
+
+export const requestProjects = ({ commit }) => {
+ commit(types.RESET_CONNECT);
+};
+
+export const receiveProjectsSuccess = ({ commit }, projects) => {
+ commit(types.UPDATE_CONNECT_SUCCESS);
+ commit(types.RECEIVE_PROJECTS, projects);
+};
+
+export const receiveProjectsError = ({ commit }) => {
+ commit(types.UPDATE_CONNECT_ERROR);
+ commit(types.CLEAR_PROJECTS);
+};
+
+export const fetchProjects = ({ dispatch, state }) => {
+ dispatch('requestProjects');
+ return axios
+ .post(state.listProjectsEndpoint, {
+ error_tracking_setting: {
+ api_host: state.apiHost,
+ token: state.token,
+ },
+ })
+ .then(({ data: { projects } }) => {
+ dispatch('receiveProjectsSuccess', projects);
+ })
+ .catch(() => {
+ dispatch('receiveProjectsError');
+ });
+};
+
+export const requestSettings = ({ commit }) => {
+ commit(types.UPDATE_SETTINGS_LOADING, true);
+};
+
+export const receiveSettingsError = ({ commit }, { response = {} }) => {
+ const message = response.data && response.data.message ? response.data.message : '';
+
+ createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert');
+ commit(types.UPDATE_SETTINGS_LOADING, false);
+};
+
+export const updateSettings = ({ dispatch, state }) => {
+ dispatch('requestSettings');
+ return axios
+ .patch(state.operationsSettingsEndpoint, {
+ project: {
+ error_tracking_setting_attributes: {
+ ...transformFrontendSettings(state),
+ },
+ },
+ })
+ .then(() => {
+ refreshCurrentPage();
+ })
+ .catch(err => {
+ dispatch('receiveSettingsError', err);
+ });
+};
+
+export const updateApiHost = ({ commit }, apiHost) => {
+ commit(types.UPDATE_API_HOST, apiHost);
+ commit(types.RESET_CONNECT);
+};
+
+export const updateEnabled = ({ commit }, enabled) => {
+ commit(types.UPDATE_ENABLED, enabled);
+};
+
+export const updateToken = ({ commit }, token) => {
+ commit(types.UPDATE_TOKEN, token);
+ commit(types.RESET_CONNECT);
+};
+
+export const updateSelectedProject = ({ commit }, selectedProject) => {
+ commit(types.UPDATE_SELECTED_PROJECT, selectedProject);
+};
+
+export const setInitialState = ({ commit }, data) => {
+ commit(types.SET_INITIAL_STATE, data);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js
new file mode 100644
index 00000000000..a008b181907
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/getters.js
@@ -0,0 +1,44 @@
+import _ from 'underscore';
+import { __, s__, sprintf } from '~/locale';
+import { getDisplayName } from '../utils';
+
+export const hasProjects = state => !!state.projects && state.projects.length > 0;
+
+export const isProjectInvalid = (state, getters) =>
+ !!state.selectedProject &&
+ getters.hasProjects &&
+ !state.projects.some(project => _.isMatch(state.selectedProject, project));
+
+export const dropdownLabel = (state, getters) => {
+ if (state.selectedProject !== null) {
+ return getDisplayName(state.selectedProject);
+ }
+ if (!getters.hasProjects) {
+ return s__('ErrorTracking|No projects available');
+ }
+ return s__('ErrorTracking|Select project');
+};
+
+export const invalidProjectLabel = state => {
+ if (state.selectedProject) {
+ return sprintf(
+ __('Project "%{name}" is no longer available. Select another project to continue.'),
+ {
+ name: state.selectedProject.name,
+ },
+ );
+ }
+ return '';
+};
+
+export const projectSelectionLabel = state => {
+ if (state.token) {
+ return s__(
+ "ErrorTracking|Click 'Connect' to re-establish the connection to Sentry and activate the dropdown.",
+ );
+ }
+ return s__('ErrorTracking|To enable project selection, enter a valid Auth Token');
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/error_tracking_settings/store/index.js b/app/assets/javascripts/error_tracking_settings/store/index.js
new file mode 100644
index 00000000000..560f265a2ea
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/index.js
@@ -0,0 +1,16 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ getters,
+ mutations,
+ });
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
new file mode 100644
index 00000000000..b4f8a237947
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js
@@ -0,0 +1,11 @@
+export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const RECEIVE_PROJECTS = 'RECEIVE_PROJECTS';
+export const RESET_CONNECT = 'RESET_CONNECT';
+export const UPDATE_API_HOST = 'UPDATE_API_HOST';
+export const UPDATE_CONNECT_ERROR = 'UPDATE_CONNECT_ERROR';
+export const UPDATE_CONNECT_SUCCESS = 'UPDATE_CONNECT_SUCCESS';
+export const UPDATE_ENABLED = 'UPDATE_ENABLED';
+export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT';
+export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING';
+export const UPDATE_TOKEN = 'UPDATE_TOKEN';
diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js
new file mode 100644
index 00000000000..4089d1ee94e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js
@@ -0,0 +1,61 @@
+import _ from 'underscore';
+import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+import { projectKeys } from '../utils';
+
+export default {
+ [types.CLEAR_PROJECTS](state) {
+ state.projects = [];
+ },
+ [types.RECEIVE_PROJECTS](state, projects) {
+ state.projects = projects
+ .map(convertObjectPropsToCamelCase)
+ // The `pick` strips out extra properties returned from Sentry.
+ // Such properties could be problematic later, e.g. when checking whether `projects` contains `selectedProject`
+ .map(project => _.pick(project, projectKeys));
+ },
+ [types.RESET_CONNECT](state) {
+ state.connectSuccessful = false;
+ state.connectError = false;
+ },
+ [types.SET_INITIAL_STATE](
+ state,
+ { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
+ ) {
+ state.enabled = parseBoolean(enabled);
+ state.apiHost = apiHost;
+ state.token = token;
+ state.listProjectsEndpoint = listProjectsEndpoint;
+ state.operationsSettingsEndpoint = operationsSettingsEndpoint;
+
+ if (project) {
+ state.selectedProject = _.pick(
+ convertObjectPropsToCamelCase(JSON.parse(project)),
+ projectKeys,
+ );
+ }
+ },
+ [types.UPDATE_API_HOST](state, apiHost) {
+ state.apiHost = apiHost;
+ },
+ [types.UPDATE_ENABLED](state, enabled) {
+ state.enabled = enabled;
+ },
+ [types.UPDATE_TOKEN](state, token) {
+ state.token = token;
+ },
+ [types.UPDATE_SELECTED_PROJECT](state, selectedProject) {
+ state.selectedProject = selectedProject;
+ },
+ [types.UPDATE_SETTINGS_LOADING](state, settingsLoading) {
+ state.settingsLoading = settingsLoading;
+ },
+ [types.UPDATE_CONNECT_SUCCESS](state) {
+ state.connectSuccessful = true;
+ state.connectError = false;
+ },
+ [types.UPDATE_CONNECT_ERROR](state) {
+ state.connectSuccessful = false;
+ state.connectError = true;
+ },
+};
diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js
new file mode 100644
index 00000000000..98219d33f4d
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/store/state.js
@@ -0,0 +1,12 @@
+export default () => ({
+ apiHost: '',
+ enabled: false,
+ token: '',
+ projects: [],
+ selectedProject: null,
+ settingsLoading: false,
+ connectSuccessful: false,
+ connectError: false,
+ listProjectsEndpoint: '',
+ operationsSettingsEndpoint: '',
+});
diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js
new file mode 100644
index 00000000000..6613e04ee0e
--- /dev/null
+++ b/app/assets/javascripts/error_tracking_settings/utils.js
@@ -0,0 +1,18 @@
+export const projectKeys = ['name', 'organizationName', 'organizationSlug', 'slug'];
+
+export const transformFrontendSettings = ({ apiHost, enabled, token, selectedProject }) => {
+ const project = selectedProject
+ ? {
+ slug: selectedProject.slug,
+ name: selectedProject.name,
+ organization_name: selectedProject.organizationName,
+ organization_slug: selectedProject.organizationSlug,
+ }
+ : null;
+
+ return { api_host: apiHost || null, enabled, token: token || null, project };
+};
+
+export const getDisplayName = project => `${project.organizationName} | ${project.name}`;
+
+export default () => {};
diff --git a/app/assets/javascripts/event_tracking/notes.js b/app/assets/javascripts/event_tracking/notes.js
new file mode 100644
index 00000000000..2d1ec238274
--- /dev/null
+++ b/app/assets/javascripts/event_tracking/notes.js
@@ -0,0 +1 @@
+export default () => {};
diff --git a/app/assets/javascripts/feature_highlight/feature_highlight.js b/app/assets/javascripts/feature_highlight/feature_highlight.js
index 173fe7c69de..be55e6923c6 100644
--- a/app/assets/javascripts/feature_highlight/feature_highlight.js
+++ b/app/assets/javascripts/feature_highlight/feature_highlight.js
@@ -31,12 +31,14 @@ export function setupFeatureHighlightPopover(id, debounceTimeout = 300) {
.removeAttr('disabled');
}
+const getPriority = e => parseInt(e.dataset.highlightPriority, 10) || 0;
+
export function findHighestPriorityFeature() {
let priorityFeature;
const sortedFeatureEls = [].slice
.call(document.querySelectorAll('.js-feature-highlight'))
- .sort((a, b) => (a.dataset.highlightPriority || 0) < (b.dataset.highlightPriority || 0));
+ .sort((a, b) => getPriority(b) - getPriority(a));
const [priorityFeatureEl] = sortedFeatureEls;
if (priorityFeatureEl) {
diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
new file mode 100644
index 00000000000..e020628a473
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js
@@ -0,0 +1,30 @@
+import { __ } from '~/locale';
+
+export default IssuableTokenKeys => {
+ const wipToken = {
+ key: 'wip',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'admin',
+ tag: __('Yes or No'),
+ lowercaseValueOnSubmit: true,
+ uppercaseTokenName: true,
+ capitalizeTokenValue: true,
+ };
+
+ IssuableTokenKeys.tokenKeys.push(wipToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken);
+
+ const targetBranchToken = {
+ key: 'target-branch',
+ type: 'string',
+ param: '',
+ symbol: '',
+ icon: 'arrow-right',
+ tag: 'branch',
+ };
+
+ IssuableTokenKeys.tokenKeys.push(targetBranchToken);
+ IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken);
+};
diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
index 934375023ba..691d165c585 100644
--- a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js
@@ -17,6 +17,14 @@ const tokenKeys = [
icon: 'cube',
tag: 'type',
},
+ {
+ key: 'tag',
+ type: 'array',
+ param: 'name[]',
+ symbol: '~',
+ icon: 'tag',
+ tag: '~tag',
+ },
];
const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
new file mode 100644
index 00000000000..be867a3838d
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js
@@ -0,0 +1,164 @@
+import DropdownHint from './dropdown_hint';
+import DropdownUser from './dropdown_user';
+import DropdownNonUser from './dropdown_non_user';
+import DropdownEmoji from './dropdown_emoji';
+import NullDropdown from './null_dropdown';
+import DropdownAjaxFilter from './dropdown_ajax_filter';
+import DropdownUtils from './dropdown_utils';
+import { mergeUrlParams } from '../lib/utils/url_utility';
+
+export default class AvailableDropdownMappings {
+ constructor(container, baseEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups) {
+ this.container = container;
+ this.baseEndpoint = baseEndpoint;
+ this.groupsOnly = groupsOnly;
+ this.includeAncestorGroups = includeAncestorGroups;
+ this.includeDescendantGroups = includeDescendantGroups;
+ this.filteredSearchInput = this.container.querySelector('.filtered-search');
+ }
+
+ getAllowedMappings(supportedTokens) {
+ return this.buildMappings(supportedTokens, this.getMappings());
+ }
+
+ buildMappings(supportedTokens, availableMappings) {
+ const allowedMappings = {
+ hint: {
+ reference: null,
+ gl: DropdownHint,
+ element: this.container.querySelector('#js-dropdown-hint'),
+ },
+ };
+
+ supportedTokens.forEach(type => {
+ if (availableMappings[type]) {
+ allowedMappings[type] = availableMappings[type];
+ }
+ });
+
+ return allowedMappings;
+ }
+
+ getMappings() {
+ return {
+ author: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-author'),
+ },
+ assignee: {
+ reference: null,
+ gl: DropdownUser,
+ element: this.container.querySelector('#js-dropdown-assignee'),
+ },
+ milestone: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getMilestoneEndpoint(),
+ symbol: '%',
+ },
+ element: this.container.querySelector('#js-dropdown-milestone'),
+ },
+ label: {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getLabelsEndpoint(),
+ symbol: '~',
+ preprocessing: DropdownUtils.duplicateLabelPreprocessing,
+ },
+ element: this.container.querySelector('#js-dropdown-label'),
+ },
+ 'my-reaction': {
+ reference: null,
+ gl: DropdownEmoji,
+ element: this.container.querySelector('#js-dropdown-my-reaction'),
+ },
+ wip: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-wip'),
+ },
+ confidential: {
+ reference: null,
+ gl: DropdownNonUser,
+ element: this.container.querySelector('#js-dropdown-confidential'),
+ },
+ status: {
+ reference: null,
+ gl: NullDropdown,
+ element: this.container.querySelector('#js-dropdown-admin-runner-status'),
+ },
+ type: {
+ reference: null,
+ gl: NullDropdown,
+ element: this.container.querySelector('#js-dropdown-admin-runner-type'),
+ },
+ tag: {
+ reference: null,
+ gl: DropdownAjaxFilter,
+ extraArguments: {
+ endpoint: this.getRunnerTagsEndpoint(),
+ symbol: '~',
+ },
+ element: this.container.querySelector('#js-dropdown-runner-tag'),
+ },
+ 'target-branch': {
+ reference: null,
+ gl: DropdownNonUser,
+ extraArguments: {
+ endpoint: this.getMergeRequestTargetBranchesEndpoint(),
+ symbol: '',
+ },
+ element: this.container.querySelector('#js-dropdown-target-branch'),
+ },
+ };
+ }
+
+ getMilestoneEndpoint() {
+ return `${this.baseEndpoint}/milestones.json`;
+ }
+
+ getLabelsEndpoint() {
+ let endpoint = `${this.baseEndpoint}/labels.json?`;
+
+ if (this.groupsOnly) {
+ endpoint = `${endpoint}only_group_labels=true&`;
+ }
+
+ if (this.includeAncestorGroups) {
+ endpoint = `${endpoint}include_ancestor_groups=true&`;
+ }
+
+ if (this.includeDescendantGroups) {
+ endpoint = `${endpoint}include_descendant_groups=true`;
+ }
+
+ return endpoint;
+ }
+
+ getRunnerTagsEndpoint() {
+ return `${this.baseEndpoint}/admin/runners/tag_list.json`;
+ }
+
+ getMergeRequestTargetBranchesEndpoint() {
+ const endpoint = `${gon.relative_url_root ||
+ ''}/autocomplete/merge_request_target_branches.json`;
+
+ const params = {
+ group_id: this.getGroupId(),
+ project_id: this.getProjectId(),
+ };
+
+ return mergeUrlParams(params, endpoint);
+ }
+
+ getGroupId() {
+ return this.filteredSearchInput.getAttribute('data-group-id') || '';
+ }
+
+ getProjectId() {
+ return this.filteredSearchInput.getAttribute('data-project-id') || '';
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
index 6b1a934d3fe..19bc3313373 100644
--- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
+++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue
@@ -66,7 +66,7 @@ export default {
<button
type="button"
class="filtered-search-history-dropdown-item"
- @click="onItemActivated(item.text);"
+ @click="onItemActivated(item.text)"
>
<span>
<span
@@ -88,7 +88,7 @@ export default {
<button
type="button"
class="filtered-search-history-clear-button"
- @click="onRequestClearRecentSearches($event);"
+ @click="onRequestClearRecentSearches($event)"
>
Clear recent searches
</button>
diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
new file mode 100644
index 00000000000..b27bb63c220
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js
@@ -0,0 +1,68 @@
+import createFlash from '../flash';
+import AjaxFilter from '../droplab/plugins/ajax_filter';
+import FilteredSearchDropdown from './filtered_search_dropdown';
+import DropdownUtils from './dropdown_utils';
+import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import { __ } from '~/locale';
+
+export default class DropdownAjaxFilter extends FilteredSearchDropdown {
+ constructor(options = {}) {
+ const { tokenKeys, endpoint, symbol } = options;
+
+ super(options);
+
+ this.tokenKeys = tokenKeys;
+ this.endpoint = endpoint;
+ this.symbol = symbol;
+
+ this.config = {
+ AjaxFilter: this.ajaxFilterConfig(),
+ };
+ }
+
+ ajaxFilterConfig() {
+ return {
+ endpoint: `${gon.relative_url_root || ''}${this.endpoint}`,
+ searchKey: 'search',
+ searchValueFunction: this.getSearchInput.bind(this),
+ loadingTemplate: this.loadingTemplate,
+ onError() {
+ createFlash(__('An error occurred fetching the dropdown data.'));
+ },
+ };
+ }
+
+ itemClicked(e) {
+ super.itemClicked(e, selected =>
+ selected.querySelector('.dropdown-light-content').innerText.trim(),
+ );
+ }
+
+ renderContent(forceShowList = false) {
+ this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
+ super.renderContent(forceShowList);
+ }
+
+ getSearchInput() {
+ const query = DropdownUtils.getSearchInput(this.input);
+ const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
+
+ let value = lastToken || '';
+
+ if (value[0] === this.symbol) {
+ value = value.slice(1);
+ }
+
+ // Removes the first character if it is a quotation so that we can search
+ // with multiple words
+ if (value[0] === '"' || value[0] === "'") {
+ value = value.slice(1);
+ }
+
+ return value;
+ }
+
+ init() {
+ this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
+ }
+}
diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js
index d9a4d06b549..dad188f6f98 100644
--- a/app/assets/javascripts/filtered_search/dropdown_emoji.js
+++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js
@@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class DropdownEmoji extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -14,7 +15,7 @@ export default class DropdownEmoji extends FilteredSearchDropdown {
loadingTemplate: this.loadingTemplate,
onError() {
/* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
+ new Flash(__('An error occurred fetching the dropdown data.'));
/* eslint-enable no-new */
},
},
diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js
index 0264f934914..a2312de289d 100644
--- a/app/assets/javascripts/filtered_search/dropdown_non_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js
@@ -3,6 +3,7 @@ import Ajax from '../droplab/plugins/ajax';
import Filter from '../droplab/plugins/filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class DropdownNonUser extends FilteredSearchDropdown {
constructor(options = {}) {
@@ -17,7 +18,7 @@ export default class DropdownNonUser extends FilteredSearchDropdown {
preprocessing,
onError() {
/* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
+ new Flash(__('An error occurred fetching the dropdown data.'));
/* eslint-enable no-new */
},
},
diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js
index d5027590bb7..a65c0012b4d 100644
--- a/app/assets/javascripts/filtered_search/dropdown_user.js
+++ b/app/assets/javascripts/filtered_search/dropdown_user.js
@@ -1,54 +1,35 @@
-import Flash from '../flash';
-import AjaxFilter from '../droplab/plugins/ajax_filter';
-import FilteredSearchDropdown from './filtered_search_dropdown';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
-import DropdownUtils from './dropdown_utils';
-import FilteredSearchTokenizer from './filtered_search_tokenizer';
+import DropdownAjaxFilter from './dropdown_ajax_filter';
-export default class DropdownUser extends FilteredSearchDropdown {
+export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
- const { tokenKeys } = options;
- super(options);
- this.config = {
- AjaxFilter: {
- endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
- searchKey: 'search',
- params: {
- active: true,
- group_id: this.getGroupId(),
- project_id: this.getProjectId(),
- current_user: true,
- },
- searchValueFunction: this.getSearchInput.bind(this),
- loadingTemplate: this.loadingTemplate,
- onLoadingFinished: () => {
- this.hideCurrentUser();
- },
- onError() {
- /* eslint-disable no-new */
- new Flash('An error occurred fetching the dropdown data.');
- /* eslint-enable no-new */
- },
+ super({
+ ...options,
+ endpoint: '/autocomplete/users.json',
+ symbol: '@',
+ });
+ }
+
+ ajaxFilterConfig() {
+ return {
+ ...super.ajaxFilterConfig(),
+ params: {
+ active: true,
+ group_id: this.getGroupId(),
+ project_id: this.getProjectId(),
+ current_user: true,
+ ...this.projectOrGroupId(),
+ },
+ onLoadingFinished: () => {
+ this.hideCurrentUser();
},
};
- this.tokenKeys = tokenKeys;
}
hideCurrentUser() {
addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden');
}
- itemClicked(e) {
- super.itemClicked(e, selected =>
- selected.querySelector('.dropdown-light-content').innerText.trim(),
- );
- }
-
- renderContent(forceShowList = false) {
- this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
- super.renderContent(forceShowList);
- }
-
getGroupId() {
return this.input.getAttribute('data-group-id');
}
@@ -57,26 +38,16 @@ export default class DropdownUser extends FilteredSearchDropdown {
return this.input.getAttribute('data-project-id');
}
- getSearchInput() {
- const query = DropdownUtils.getSearchInput(this.input);
- const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
-
- let value = lastToken || '';
-
- if (value[0] === '@') {
- value = value.slice(1);
+ projectOrGroupId() {
+ const projectId = this.getProjectId();
+ const groupId = this.getGroupId();
+ if (groupId) {
+ return {
+ group_id: groupId,
+ };
}
-
- // Removes the first character if it is a quotation so that we can search
- // with multiple words
- if (value[0] === '"' || value[0] === "'") {
- value = value.slice(1);
- }
-
- return value;
- }
-
- init() {
- this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
+ return {
+ project_id: projectId,
+ };
}
}
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 57ec6603d80..cb0a84b490b 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js
@@ -1,13 +1,9 @@
+import AvailableDropdownMappings from 'ee_else_ce/filtered_search/available_dropdown_mappings';
import _ from 'underscore';
import DropLab from '~/droplab/drop_lab';
import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import DropdownUtils from './dropdown_utils';
-import DropdownHint from './dropdown_hint';
-import DropdownEmoji from './dropdown_emoji';
-import DropdownNonUser from './dropdown_non_user';
-import DropdownUser from './dropdown_user';
-import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
@@ -49,96 +45,15 @@ export default class FilteredSearchDropdownManager {
setupMapping() {
const supportedTokens = this.filteredSearchTokenKeys.getKeys();
- const allowedMappings = {
- hint: {
- reference: null,
- gl: DropdownHint,
- element: this.container.querySelector('#js-dropdown-hint'),
- },
- };
- const availableMappings = {
- author: {
- reference: null,
- gl: DropdownUser,
- element: this.container.querySelector('#js-dropdown-author'),
- },
- assignee: {
- reference: null,
- gl: DropdownUser,
- element: this.container.querySelector('#js-dropdown-assignee'),
- },
- milestone: {
- reference: null,
- gl: DropdownNonUser,
- extraArguments: {
- endpoint: this.getMilestoneEndpoint(),
- symbol: '%',
- },
- element: this.container.querySelector('#js-dropdown-milestone'),
- },
- label: {
- reference: null,
- gl: DropdownNonUser,
- extraArguments: {
- endpoint: this.getLabelsEndpoint(),
- symbol: '~',
- preprocessing: DropdownUtils.duplicateLabelPreprocessing,
- },
- element: this.container.querySelector('#js-dropdown-label'),
- },
- 'my-reaction': {
- reference: null,
- gl: DropdownEmoji,
- element: this.container.querySelector('#js-dropdown-my-reaction'),
- },
- wip: {
- reference: null,
- gl: DropdownNonUser,
- element: this.container.querySelector('#js-dropdown-wip'),
- },
- status: {
- reference: null,
- gl: NullDropdown,
- element: this.container.querySelector('#js-dropdown-admin-runner-status'),
- },
- type: {
- reference: null,
- gl: NullDropdown,
- element: this.container.querySelector('#js-dropdown-admin-runner-type'),
- },
- };
-
- supportedTokens.forEach(type => {
- if (availableMappings[type]) {
- allowedMappings[type] = availableMappings[type];
- }
- });
-
- this.mapping = allowedMappings;
- }
-
- getMilestoneEndpoint() {
- const endpoint = `${this.baseEndpoint}/milestones.json`;
-
- return endpoint;
- }
-
- getLabelsEndpoint() {
- let endpoint = `${this.baseEndpoint}/labels.json?`;
-
- if (this.groupsOnly) {
- endpoint = `${endpoint}only_group_labels=true&`;
- }
-
- if (this.includeAncestorGroups) {
- endpoint = `${endpoint}include_ancestor_groups=true&`;
- }
-
- if (this.includeDescendantGroups) {
- endpoint = `${endpoint}include_descendant_groups=true`;
- }
+ const availableMappings = new AvailableDropdownMappings(
+ this.container,
+ this.baseEndpoint,
+ this.groupsOnly,
+ this.includeAncestorGroups,
+ this.includeDescendantGroups,
+ );
- return endpoint;
+ this.mapping = availableMappings.getAllowedMappings(supportedTokens);
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js
index 4a2af02b40a..78fbb3696cc 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_manager.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js
@@ -1,6 +1,7 @@
import _ from 'underscore';
import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import FilteredSearchContainer from './container';
@@ -13,6 +14,7 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils';
+import { __ } from '~/locale';
export default class FilteredSearchManager {
constructor({
@@ -36,10 +38,11 @@ export default class FilteredSearchManager {
this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = filteredSearchTokenKeys;
this.stateFiltersSelector = stateFiltersSelector;
- this.recentsStorageKeyNames = {
- issues: 'issue-recent-searches',
- merge_requests: 'merge-request-recent-searches',
- };
+
+ const { multipleAssignees } = this.filteredSearchInput.dataset;
+ if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) {
+ this.filteredSearchTokenKeys.enableMultipleAssignees();
+ }
this.recentSearchesStore = new RecentSearchesStore({
isLocalStorageAvailable: RecentSearchesService.isAvailable(),
@@ -51,7 +54,7 @@ export default class FilteredSearchManager {
const fullPath = this.searchHistoryDropdownElement
? this.searchHistoryDropdownElement.dataset.fullPath
: 'project';
- const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`;
+ const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`;
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
}
@@ -62,7 +65,7 @@ export default class FilteredSearchManager {
.catch(error => {
if (error.name === 'RecentSearchesServiceError') return undefined;
// eslint-disable-next-line no-new
- new Flash('An error occurred while parsing recent searches');
+ new Flash(__('An error occurred while parsing recent searches'));
// Gracefully fail to empty array
return [];
})
@@ -338,7 +341,7 @@ export default class FilteredSearchManager {
handleInputPlaceholder() {
const query = DropdownUtils.getSearchQuery();
- const placeholder = 'Search or filter results...';
+ const placeholder = __('Search or filter results...');
const currentPlaceholder = this.filteredSearchInput.placeholder;
if (query.length === 0 && currentPlaceholder !== placeholder) {
@@ -504,14 +507,7 @@ export default class FilteredSearchManager {
const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam);
if (match) {
- // Use lastIndexOf because the token key is allowed to contain underscore
- // e.g. 'my_reaction' is the token key of 'my_reaction_emoji'
- const lastIndexOf = keyParam.lastIndexOf('_');
- let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam;
- // Replace underscore with hyphen in the sanitizedkey.
- // e.g. 'my_reaction' => 'my-reaction'
- sanitizedKey = sanitizedKey.replace('_', '-');
- const { symbol } = match;
+ const { key, symbol } = match;
let quotationsToUse = '';
if (sanitizedValue.indexOf(' ') !== -1) {
@@ -520,10 +516,10 @@ export default class FilteredSearchManager {
}
hasFilteredSearch = true;
- const canEdit = this.canEdit && this.canEdit(sanitizedKey, sanitizedValue);
+ const canEdit = this.canEdit && this.canEdit(key, sanitizedValue);
const { uppercaseTokenName, capitalizeTokenValue } = match;
FilteredSearchVisualTokens.addFilterVisualToken(
- sanitizedKey,
+ key,
`${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`,
{
canEdit,
@@ -593,7 +589,7 @@ export default class FilteredSearchManager {
tokens.forEach(token => {
const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue(
token.key,
- token.value.toLowerCase(),
+ token.value,
);
const tokenConfig = this.filteredSearchTokenKeys.searchByKey(token.key) || {};
const { param } = tokenConfig;
diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
index e01dedbb57c..0a9579bf491 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class FilteredSearchTokenKeys {
constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
this.tokenKeys = tokenKeys;
@@ -65,25 +67,27 @@ export default class FilteredSearchTokenKeys {
searchByConditionKeyValue(key, value) {
return (
- this.conditions.find(condition => condition.tokenKey === key && condition.value === value) ||
- null
+ this.conditions.find(
+ condition =>
+ condition.tokenKey === key && condition.value.toLowerCase() === value.toLowerCase(),
+ ) || null
);
}
- addExtraTokensForMergeRequests() {
- const wipToken = {
- key: 'wip',
+ addExtraTokensForIssues() {
+ const confidentialToken = {
+ key: 'confidential',
type: 'string',
param: '',
symbol: '',
- icon: 'admin',
- tag: 'Yes or No',
+ icon: 'eye-slash',
+ tag: __('Yes or No'),
lowercaseValueOnSubmit: true,
- uppercaseTokenName: true,
+ uppercaseTokenName: false,
capitalizeTokenValue: true,
};
- this.tokenKeys.push(wipToken);
- this.tokenKeysWithAlternative.push(wipToken);
+ this.tokenKeys.push(confidentialToken);
+ this.tokenKeysWithAlternative.push(confidentialToken);
}
}
diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
index fba31f16d65..315cd6f64da 100644
--- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
+++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js
@@ -1,10 +1,6 @@
-import _ from 'underscore';
-import AjaxCache from '~/lib/utils/ajax_cache';
+import VisualTokenValue from 'ee_else_ce/filtered_search/visual_token_value';
import { objectToQueryString } from '~/lib/utils/common_utils';
-import Flash from '../flash';
import FilteredSearchContainer from './container';
-import UsersCache from '../lib/utils/users_cache';
-import DropdownUtils from './dropdown_utils';
export default class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
@@ -20,21 +16,6 @@ export default class FilteredSearchVisualTokens {
};
}
- /**
- * Returns a computed API endpoint
- * and query string composed of values from endpointQueryParams
- * @param {String} endpoint
- * @param {String} endpointQueryParams
- */
- static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
- if (!endpointQueryParams) {
- return endpoint;
- }
-
- const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
- return `${endpoint}?${queryString}`;
- }
-
static unselectTokens() {
const otherTokens = FilteredSearchContainer.container.querySelectorAll(
'.js-visual-token .selectable.selected',
@@ -76,122 +57,33 @@ export default class FilteredSearchVisualTokens {
`;
}
- static setTokenStyle(tokenContainer, backgroundColor, textColor) {
- const token = tokenContainer;
-
- token.style.backgroundColor = backgroundColor;
- token.style.color = textColor;
-
- if (textColor === '#FFFFFF') {
- const removeToken = token.querySelector('.remove-token');
- removeToken.classList.add('inverted');
- }
-
- return token;
- }
-
- static updateLabelTokenColor(tokenValueContainer, tokenValue) {
- const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
- const { baseEndpoint } = filteredSearchInput.dataset;
- const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
- `${baseEndpoint}/labels.json`,
- filteredSearchInput.dataset.endpointQueryParams,
- );
-
- return AjaxCache.retrieve(labelsEndpoint)
- .then(labels => {
- const matchingLabel = (labels || []).find(
- label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
- );
-
- if (!matchingLabel) {
- return;
- }
-
- FilteredSearchVisualTokens.setTokenStyle(
- tokenValueContainer,
- matchingLabel.color,
- matchingLabel.text_color,
- );
- })
- .catch(() => new Flash('An error occurred while fetching label colors.'));
- }
-
- static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
- const username = tokenValue.replace(/^@/, '');
- return (
- UsersCache.retrieve(username)
- .then(user => {
- if (!user) {
- return;
- }
-
- /* eslint-disable no-param-reassign */
- tokenValueContainer.dataset.originalValue = tokenValue;
- tokenValueElement.innerHTML = `
- <img class="avatar s20" src="${user.avatar_url}" alt="">
- ${_.escape(user.name)}
- `;
- /* eslint-enable no-param-reassign */
- })
- // ignore error and leave username in the search bar
- .catch(() => {})
- );
- }
-
- static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
- const container = tokenValueContainer;
- const element = tokenValueElement;
-
- return (
- import(/* webpackChunkName: 'emoji' */ '../emoji')
- .then(Emoji => {
- if (!Emoji.isEmojiNameValid(tokenValue)) {
- return;
- }
-
- container.dataset.originalValue = tokenValue;
- element.innerHTML = Emoji.glEmojiTag(tokenValue);
- })
- // ignore error and leave emoji name in the search bar
- .catch(() => {})
- );
- }
-
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
+ const tokenType = tokenName.toLowerCase();
const tokenValueContainer = parentElement.querySelector('.value-container');
const tokenValueElement = tokenValueContainer.querySelector('.value');
tokenValueElement.innerText = tokenValue;
- if (tokenValue === 'none' || tokenValue === 'any') {
- return;
- }
-
- const tokenType = tokenName.toLowerCase();
+ const visualTokenValue = new VisualTokenValue(tokenValue, tokenType);
- if (tokenType === 'label') {
- FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
- } else if (tokenType === 'author' || tokenType === 'assignee') {
- FilteredSearchVisualTokens.updateUserTokenAppearance(
- tokenValueContainer,
- tokenValueElement,
- tokenValue,
- );
- } else if (tokenType === 'my-reaction') {
- FilteredSearchVisualTokens.updateEmojiTokenAppearance(
- tokenValueContainer,
- tokenValueElement,
- tokenValue,
- );
- }
+ visualTokenValue.render(tokenValueContainer, tokenValueElement);
}
static addVisualTokenElement(name, value, options = {}) {
- const { isSearchTerm = false, canEdit, uppercaseTokenName, capitalizeTokenValue } = options;
+ const {
+ isSearchTerm = false,
+ canEdit,
+ uppercaseTokenName,
+ capitalizeTokenValue,
+ tokenClass = `search-token-${name.toLowerCase()}`,
+ } = options;
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
+ if (!isSearchTerm) {
+ li.classList.add(tokenClass);
+ }
+
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML({
canEdit,
@@ -318,6 +210,21 @@ export default class FilteredSearchVisualTokens {
}
}
+ /**
+ * Returns a computed API endpoint
+ * and query string composed of values from endpointQueryParams
+ * @param {String} endpoint
+ * @param {String} endpointQueryParams
+ */
+ static getEndpointWithQueryParams(endpoint, endpointQueryParams) {
+ if (!endpointQueryParams) {
+ return endpoint;
+ }
+
+ const queryString = objectToQueryString(JSON.parse(endpointQueryParams));
+ return `${endpoint}?${queryString}`;
+ }
+
static editToken(token) {
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
index b494b7e2de0..6c3d9e33420 100644
--- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
+++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js
@@ -1,4 +1,5 @@
import FilteredSearchTokenKeys from './filtered_search_token_keys';
+import { __ } from '~/locale';
export const tokenKeys = [
{
@@ -60,52 +61,52 @@ export const conditions = [
{
url: 'assignee_id=None',
tokenKey: 'assignee',
- value: 'none',
+ value: __('None'),
},
{
url: 'assignee_id=Any',
tokenKey: 'assignee',
- value: 'any',
+ value: __('Any'),
},
{
url: 'milestone_title=None',
tokenKey: 'milestone',
- value: 'none',
+ value: __('None'),
},
{
url: 'milestone_title=Any',
tokenKey: 'milestone',
- value: 'any',
+ value: __('Any'),
},
{
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
- value: 'upcoming',
+ value: __('Upcoming'),
},
{
url: 'milestone_title=%23started',
tokenKey: 'milestone',
- value: 'started',
+ value: __('Started'),
},
{
url: 'label_name[]=None',
tokenKey: 'label',
- value: 'none',
+ value: __('None'),
},
{
url: 'label_name[]=Any',
- tokenKey: 'any',
- value: 'any',
+ tokenKey: 'label',
+ value: __('Any'),
},
{
url: 'my_reaction_emoji=None',
tokenKey: 'my-reaction',
- value: 'none',
+ value: __('None'),
},
{
url: 'my_reaction_emoji=Any',
tokenKey: 'my-reaction',
- value: 'any',
+ value: __('Any'),
},
];
diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
new file mode 100644
index 00000000000..7e9b809e9b2
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js
@@ -0,0 +1,4 @@
+export default {
+ issues: 'issue-recent-searches',
+ merge_requests: 'merge-request-recent-searches',
+};
diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
index 5917b223d63..011b37e218d 100644
--- a/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
+++ b/app/assets/javascripts/filtered_search/services/recent_searches_service_error.js
@@ -1,7 +1,9 @@
+import { __ } from '~/locale';
+
class RecentSearchesServiceError {
constructor(message) {
this.name = 'RecentSearchesServiceError';
- this.message = message || 'Recent Searches Service is unavailable';
+ this.message = message || __('Recent Searches Service is unavailable');
}
}
diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js
new file mode 100644
index 00000000000..38327472cb3
--- /dev/null
+++ b/app/assets/javascripts/filtered_search/visual_token_value.js
@@ -0,0 +1,117 @@
+import _ from 'underscore';
+import FilteredSearchContainer from '~/filtered_search/container';
+import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import DropdownUtils from '~/filtered_search/dropdown_utils';
+import Flash from '~/flash';
+import UsersCache from '~/lib/utils/users_cache';
+import { __ } from '~/locale';
+
+export default class VisualTokenValue {
+ constructor(tokenValue, tokenType) {
+ this.tokenValue = tokenValue;
+ this.tokenType = tokenType;
+ }
+
+ render(tokenValueContainer, tokenValueElement) {
+ const { tokenType, tokenValue } = this;
+
+ if (['none', 'any'].includes(tokenValue.toLowerCase())) {
+ return;
+ }
+
+ if (tokenType === 'label') {
+ this.updateLabelTokenColor(tokenValueContainer);
+ } else if (tokenType === 'author' || tokenType === 'assignee') {
+ this.updateUserTokenAppearance(tokenValueContainer, tokenValueElement);
+ } else if (tokenType === 'my-reaction') {
+ this.updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement);
+ }
+ }
+
+ updateUserTokenAppearance(tokenValueContainer, tokenValueElement) {
+ const { tokenValue } = this;
+ const username = this.tokenValue.replace(/^@/, '');
+
+ return (
+ UsersCache.retrieve(username)
+ .then(user => {
+ if (!user) {
+ return;
+ }
+
+ /* eslint-disable no-param-reassign */
+ tokenValueContainer.dataset.originalValue = tokenValue;
+ tokenValueElement.innerHTML = `
+ <img class="avatar s20" src="${user.avatar_url}" alt="">
+ ${_.escape(user.name)}
+ `;
+ /* eslint-enable no-param-reassign */
+ })
+ // ignore error and leave username in the search bar
+ .catch(() => {})
+ );
+ }
+
+ updateLabelTokenColor(tokenValueContainer) {
+ const { tokenValue } = this;
+ const filteredSearchInput = FilteredSearchContainer.container.querySelector('.filtered-search');
+ const { baseEndpoint } = filteredSearchInput.dataset;
+ const labelsEndpoint = FilteredSearchVisualTokens.getEndpointWithQueryParams(
+ `${baseEndpoint}/labels.json`,
+ filteredSearchInput.dataset.endpointQueryParams,
+ );
+
+ return AjaxCache.retrieve(labelsEndpoint)
+ .then(labels => {
+ const matchingLabel = (labels || []).find(
+ label => `~${DropdownUtils.getEscapedText(label.title)}` === tokenValue,
+ );
+
+ if (!matchingLabel) {
+ return;
+ }
+
+ VisualTokenValue.setTokenStyle(
+ tokenValueContainer,
+ matchingLabel.color,
+ matchingLabel.text_color,
+ );
+ })
+ .catch(() => new Flash(__('An error occurred while fetching label colors.')));
+ }
+
+ static setTokenStyle(tokenValueContainer, backgroundColor, textColor) {
+ const token = tokenValueContainer;
+
+ token.style.backgroundColor = backgroundColor;
+ token.style.color = textColor;
+
+ if (textColor === '#FFFFFF') {
+ const removeToken = token.querySelector('.remove-token');
+ removeToken.classList.add('inverted');
+ }
+
+ return token;
+ }
+
+ updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement) {
+ const container = tokenValueContainer;
+ const element = tokenValueElement;
+ const value = this.tokenValue;
+
+ return (
+ import(/* webpackChunkName: 'emoji' */ '../emoji')
+ .then(Emoji => {
+ if (!Emoji.isEmojiNameValid(value)) {
+ return;
+ }
+
+ container.dataset.originalValue = value;
+ element.innerHTML = Emoji.glEmojiTag(value);
+ })
+ // ignore error and leave emoji name in the search bar
+ .catch(() => {})
+ );
+ }
+}
diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js
index 3ac00c51df4..2566ed6b47c 100644
--- a/app/assets/javascripts/fly_out_nav.js
+++ b/app/assets/javascripts/fly_out_nav.js
@@ -1,4 +1,5 @@
import bp from './breakpoints';
+import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar';
const HIDE_INTERVAL_TIMEOUT = 300;
const IS_OVER_CLASS = 'is-over';
@@ -24,9 +25,12 @@ export const slope = (a, b) => (b.y - a.y) / (b.x - a.x);
let headerHeight = 50;
export const getHeaderHeight = () => headerHeight;
+const setHeaderHeight = () => {
+ headerHeight = sidebar.offsetTop;
+};
export const isSidebarCollapsed = () =>
- sidebar && sidebar.classList.contains('sidebar-collapsed-desktop');
+ sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS);
export const canShowActiveSubItems = el => {
if (el.classList.contains('active') && !isSidebarCollapsed()) {
@@ -186,7 +190,7 @@ export default () => {
});
}
- headerHeight = document.querySelector('.nav-sidebar').offsetTop;
+ requestIdleCallback(setHeaderHeight);
items.forEach(el => {
const subItems = el.querySelector('.sidebar-sub-level-items');
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue
index 63531f1f246..968e255e1fc 100644
--- a/app/assets/javascripts/frequent_items/components/app.vue
+++ b/app/assets/javascripts/frequent_items/components/app.vue
@@ -47,6 +47,12 @@ export default {
}
eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
+
+ // As we init it through requestIdleCallback it could be that the dropdown is already open
+ const namespaceDropdown = document.getElementById(`nav-${this.namespace}-dropdown`);
+ if (namespaceDropdown && namespaceDropdown.classList.contains('show')) {
+ this.dropdownOpenHandler();
+ }
},
beforeDestroy() {
eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler);
diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
index 2cbc7c7077b..92c3bcb5012 100644
--- a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
+++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue
@@ -1,6 +1,9 @@
<script>
/* eslint-disable vue/require-default-prop */
-import Identicon from '../../vue_shared/components/identicon.vue';
+import _ from 'underscore';
+import Identicon from '~/vue_shared/components/identicon.vue';
+import highlight from '~/lib/utils/highlight';
+import { truncateNamespace } from '~/lib/utils/text_utility';
export default {
components: {
@@ -36,43 +39,13 @@ export default {
},
computed: {
hasAvatar() {
- return this.avatarUrl !== null;
+ return _.isString(this.avatarUrl) && !_.isEmpty(this.avatarUrl);
},
- highlightedItemName() {
- if (this.matcher) {
- const matcherRegEx = new RegExp(this.matcher, 'gi');
- const matches = this.itemName.match(matcherRegEx);
-
- if (matches && matches.length > 0) {
- return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`);
- }
- }
- return this.itemName;
- },
- /**
- * Smartly truncates item namespace by doing two things;
- * 1. Only include Group names in path by removing item name
- * 2. Only include first and last group names in the path
- * when namespace has more than 2 groups present
- *
- * First part (removal of item name from namespace) can be
- * done from backend but doing so involves migration of
- * existing item namespaces which is not wise thing to do.
- */
truncatedNamespace() {
- if (!this.namespace) {
- return null;
- }
- const namespaceArr = this.namespace.split(' / ');
-
- namespaceArr.splice(-1, 1);
- let namespace = namespaceArr.join(' / ');
-
- if (namespaceArr.length > 2) {
- namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
- }
-
- return namespace;
+ return truncateNamespace(this.namespace);
+ },
+ highlightedItemName() {
+ return highlight(this.itemName, this.matcher);
},
},
};
@@ -82,12 +55,26 @@ export default {
<li class="frequent-items-list-item-container">
<a :href="webUrl" class="clearfix">
<div class="frequent-items-item-avatar-container">
- <img v-if="hasAvatar" :src="avatarUrl" class="avatar s32" />
- <identicon v-else :entity-id="itemId" :entity-name="itemName" size-class="s32" />
+ <img v-if="hasAvatar" :src="avatarUrl" class="avatar rect-avatar s32" />
+ <identicon
+ v-else
+ :entity-id="itemId"
+ :entity-name="itemName"
+ size-class="s32"
+ class="rect-avatar"
+ />
</div>
<div class="frequent-items-item-metadata-container">
- <div :title="itemName" class="frequent-items-item-title" v-html="highlightedItemName"></div>
- <div v-if="truncatedNamespace" :title="namespace" class="frequent-items-item-namespace">
+ <div
+ :title="itemName"
+ class="frequent-items-item-title js-frequent-items-item-title"
+ v-html="highlightedItemName"
+ ></div>
+ <div
+ v-if="namespace"
+ :title="namespace"
+ class="frequent-items-item-namespace js-frequent-items-item-namespace"
+ >
{{ truncatedNamespace }}
</div>
</div>
diff --git a/app/assets/javascripts/frequent_items/index.js b/app/assets/javascripts/frequent_items/index.js
index 5157ff211dc..6263acbab8e 100644
--- a/app/assets/javascripts/frequent_items/index.js
+++ b/app/assets/javascripts/frequent_items/index.js
@@ -17,7 +17,7 @@ const frequentItemDropdowns = [
},
];
-document.addEventListener('DOMContentLoaded', () => {
+const initFrequentItemDropdowns = () => {
frequentItemDropdowns.forEach(dropdown => {
const { namespace, key } = dropdown;
const el = document.getElementById(`js-${namespace}-dropdown`);
@@ -66,4 +66,8 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
});
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ requestIdleCallback(initFrequentItemDropdowns);
});
diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js
index c14eb936930..f437954881c 100644
--- a/app/assets/javascripts/gfm_auto_complete.js
+++ b/app/assets/javascripts/gfm_auto_complete.js
@@ -195,11 +195,15 @@ class GfmAutoComplete {
title += ` (${m.count})`;
}
+ const GROUP_TYPE = 'Group';
+
const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase();
+
+ const rectAvatarClass = m.type === GROUP_TYPE ? 'rect-avatar' : '';
const imgAvatar = `<img src="${m.avatar_url}" alt="${
m.username
- }" class="avatar avatar-inline center s26"/>`;
- const txtAvatar = `<div class="avatar center avatar-inline s26">${autoCompleteAvatar}</div>`;
+ }" class="avatar ${rectAvatarClass} avatar-inline center s26"/>`;
+ const txtAvatar = `<div class="avatar ${rectAvatarClass} center avatar-inline s26">${autoCompleteAvatar}</div>`;
return {
username: m.username,
@@ -221,13 +225,13 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title);
+ tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
- // eslint-disable-next-line no-template-curly-in-string
- insertTpl: '${atwho-at}${id}',
+ insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
+ skipSpecialCharacterTest: true,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(issues) {
@@ -238,6 +242,7 @@ class GfmAutoComplete {
return {
id: i.iid,
title: sanitize(i.title),
+ reference: i.reference,
search: `${i.iid} ${i.title}`,
};
});
@@ -256,7 +261,7 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Milestones.template;
+ tmpl = GfmAutoComplete.Milestones.templateFunction(value.title);
}
return tmpl;
},
@@ -287,13 +292,13 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title);
+ tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
data: GfmAutoComplete.defaultLoadingData,
- // eslint-disable-next-line no-template-curly-in-string
- insertTpl: '${atwho-at}${id}',
+ insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
+ skipSpecialCharacterTest: true,
callbacks: {
...this.getDefaultCallbacks(),
beforeSave(merges) {
@@ -304,6 +309,7 @@ class GfmAutoComplete {
return {
id: m.iid,
title: sanitize(m.title),
+ reference: m.reference,
search: `${m.iid} ${m.title}`,
};
});
@@ -323,7 +329,7 @@ class GfmAutoComplete {
searchKey: 'search',
data: GfmAutoComplete.defaultLoadingData,
displayTpl(value) {
- let tmpl = GfmAutoComplete.Labels.template;
+ let tmpl = GfmAutoComplete.Labels.templateFunction(value.color, value.title);
if (GfmAutoComplete.isLoading(value)) {
tmpl = GfmAutoComplete.Loading.template;
}
@@ -397,7 +403,7 @@ class GfmAutoComplete {
displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) {
- tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title);
+ tmpl = GfmAutoComplete.Issues.templateFunction(value);
}
return tmpl;
},
@@ -455,7 +461,10 @@ class GfmAutoComplete {
// We can ignore this for quick actions because they are processed
// before Markdown.
if (!this.setting.skipMarkdownCharacterTest) {
- withoutAt = withoutAt.replace(/([~\-_*`])/g, '\\$&');
+ withoutAt = withoutAt
+ .replace(/(~~|`|\*)/g, '\\$1')
+ .replace(/(\b)(_+)/g, '$1\\$2') // only escape underscores at the start
+ .replace(/(_+)(\b)/g, '\\$1$2'); // or end of words
}
return `${at}${withoutAt}`;
@@ -468,6 +477,16 @@ class GfmAutoComplete {
}
return null;
},
+ highlighter(li, query) {
+ // override default behaviour to escape dot character
+ // see https://github.com/ichord/At.js/pull/576
+ if (!query) {
+ return li;
+ }
+ const escapedQuery = query.replace(/[.+]/, '\\$&');
+ const regexp = new RegExp(`>\\s*([^<]*?)(${escapedQuery})([^<]*)\\s*<`, 'ig');
+ return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}<strong>${$2}</strong>${$3} <`);
+ },
};
}
@@ -588,20 +607,27 @@ GfmAutoComplete.Members = {
},
};
GfmAutoComplete.Labels = {
- template:
- // eslint-disable-next-line no-template-curly-in-string
- '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>',
+ templateFunction(color, title) {
+ return `<li><span class="dropdown-label-box" style="background: ${_.escape(
+ color,
+ )}"></span> ${_.escape(title)}</li>`;
+ },
};
// Issues, MergeRequests and Snippets
GfmAutoComplete.Issues = {
- templateFunction(id, title) {
- return `<li><small>${id}</small> ${_.escape(title)}</li>`;
+ insertTemplateFunction(value) {
+ // eslint-disable-next-line no-template-curly-in-string
+ return value.reference || '${atwho-at}${id}';
+ },
+ templateFunction({ id, title, reference }) {
+ return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
},
};
// Milestones
GfmAutoComplete.Milestones = {
- // eslint-disable-next-line no-template-curly-in-string
- template: '<li>${title}</li>',
+ templateFunction(title) {
+ return `<li>${_.escape(title)}</li>`;
+ },
};
GfmAutoComplete.Loading = {
template:
diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js
index a8ac2f510a4..6a4c1aab308 100644
--- a/app/assets/javascripts/gl_dropdown.js
+++ b/app/assets/javascripts/gl_dropdown.js
@@ -561,10 +561,9 @@ GitLabDropdown = (function() {
!$target.data('isLink')
) {
e.stopPropagation();
- return false;
- } else {
- return true;
}
+
+ return true;
}
};
@@ -656,23 +655,7 @@ GitLabDropdown = (function() {
if (this.options.renderMenu) {
return this.options.renderMenu(html);
} else {
- var ul = document.createElement('ul');
-
- for (var i = 0; i < html.length; i += 1) {
- var el = html[i];
-
- if (el instanceof $) {
- el = el.get(0);
- }
-
- if (typeof el === 'string') {
- ul.innerHTML += el;
- } else {
- ul.appendChild(el);
- }
- }
-
- return ul;
+ return $('<ul>').append(html);
}
};
@@ -719,6 +702,10 @@ GitLabDropdown = (function() {
}
html = document.createElement('li');
+ if (rowHidden) {
+ html.style.display = 'none';
+ }
+
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js
index a5b8c357e8a..04301c9ce12 100644
--- a/app/assets/javascripts/gl_field_error.js
+++ b/app/assets/javascripts/gl_field_error.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from '~/locale';
/**
* This class overrides the browser's validation error bubbles, displaying custom
@@ -61,7 +62,7 @@ export default class GlFieldError {
this.inputElement = $(input);
this.inputDomElement = this.inputElement.get(0);
this.form = formErrors;
- this.errorMessage = this.inputElement.attr('title') || 'This field is required.';
+ this.errorMessage = this.inputElement.attr('title') || __('This field is required.');
this.fieldErrorElement = $(`<p class='${errorMessageClass} hidden'>${this.errorMessage}</p>`);
this.state = {
diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js
index d5d5954ce6a..c4fd719c8d0 100644
--- a/app/assets/javascripts/gl_field_errors.js
+++ b/app/assets/javascripts/gl_field_errors.js
@@ -15,7 +15,7 @@ export default class GlFieldErrors {
initValidators() {
// register selectors here as needed
- const validateSelectors = [':text', ':password', '[type=email]']
+ const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]']
.map(selector => `input${selector}`)
.join(',');
diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js
index f842d2d74db..5a6d44ef838 100644
--- a/app/assets/javascripts/gl_form.js
+++ b/app/assets/javascripts/gl_form.js
@@ -1,6 +1,6 @@
import $ from 'jquery';
import autosize from 'autosize';
-import GfmAutoComplete, * as GFMConfig from './gfm_auto_complete';
+import GfmAutoComplete, { defaultAutocompleteConfig } from 'ee_else_ce/gfm_auto_complete';
import dropzoneInput from './dropzone_input';
import { addMarkdownListeners, removeMarkdownListeners } from './lib/utils/text_markdown';
@@ -8,7 +8,7 @@ export default class GLForm {
constructor(form, enableGFM = {}) {
this.form = form;
this.textarea = this.form.find('textarea.js-gfm-input');
- this.enableGFM = Object.assign({}, GFMConfig.defaultAutocompleteConfig, enableGFM);
+ this.enableGFM = Object.assign({}, defaultAutocompleteConfig, enableGFM);
// Disable autocomplete for keywords which do not have dataSources available
const dataSources = (gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources) || {};
Object.keys(this.enableGFM).forEach(item => {
@@ -51,8 +51,6 @@ export default class GLForm {
// form and textarea event listeners
this.addEventListeners();
addMarkdownListeners(this.form);
- // hide discard button
- this.form.find('.js-note-discard').hide();
this.form.show();
if (this.isAutosizeable) this.setupAutosize();
}
diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js
index efba6fc1aff..96051b612b5 100644
--- a/app/assets/javascripts/gpg_badges.js
+++ b/app/assets/javascripts/gpg_badges.js
@@ -20,7 +20,7 @@ export default class GpgBadges {
const endpoint = tag.data('signaturesPath');
if (!endpoint) {
displayError();
- return Promise.reject(new Error('Missing commit signatures endpoint!'));
+ return Promise.reject(new Error(__('Missing commit signatures endpoint!')));
}
const params = parseQueryStringIntoObject(tag.serialize());
diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue
index 29dc2d6a8a3..aa50fd8ff62 100644
--- a/app/assets/javascripts/groups/components/app.vue
+++ b/app/assets/javascripts/groups/components/app.vue
@@ -244,7 +244,7 @@ export default {
<gl-loading-icon
v-if="isLoading"
:label="s__('GroupsTree|Loading groups')"
- :size="2"
+ size="md"
class="loading-animation prepend-top-20"
/>
<groups-component
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index 688bd37cc56..d5130cd331d 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -88,7 +88,7 @@ export default {
</div>
<div
:class="{ 'content-loading': group.isChildrenLoading }"
- class="avatar-container s24 d-none d-sm-flex"
+ class="avatar-container rect-avatar s24 d-none d-sm-flex"
>
<a :href="group.relativePath" class="no-expand">
<img v-if="hasAvatar" :src="group.avatarUrl" class="avatar s24" />
diff --git a/app/assets/javascripts/groups/transfer_dropdown.js b/app/assets/javascripts/groups/transfer_dropdown.js
index 26510fcdb2a..ce0c9256148 100644
--- a/app/assets/javascripts/groups/transfer_dropdown.js
+++ b/app/assets/javascripts/groups/transfer_dropdown.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from '~/locale';
export default class TransferDropdown {
constructor() {
@@ -13,7 +14,7 @@ export default class TransferDropdown {
}
buildDropdown() {
- const extraOptions = [{ id: '', text: 'No parent group' }, 'divider'];
+ const extraOptions = [{ id: '', text: __('No parent group') }, 'divider'];
this.groupDropdown.glDropdown({
selectable: true,
diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js
index 2049760fe29..a1263d1cdab 100644
--- a/app/assets/javascripts/groups_select.js
+++ b/app/assets/javascripts/groups_select.js
@@ -2,95 +2,100 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import Api from './api';
import { normalizeHeaders } from './lib/utils/common_utils';
+import { __ } from '~/locale';
export default function groupsSelect() {
- // Needs to be accessible in rspec
- window.GROUP_SELECT_PER_PAGE = 20;
- $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
- const $select = $(this);
- const allAvailable = $select.data('allAvailable');
- const skipGroups = $select.data('skipGroups') || [];
- const parentGroupID = $select.data('parentId');
- const groupsPath = parentGroupID
- ? Api.subgroupsPath.replace(':id', parentGroupID)
- : Api.groupsPath;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ // Needs to be accessible in rspec
+ window.GROUP_SELECT_PER_PAGE = 20;
+ $('.ajax-groups-select').each(function setAjaxGroupsSelect2() {
+ const $select = $(this);
+ const allAvailable = $select.data('allAvailable');
+ const skipGroups = $select.data('skipGroups') || [];
+ const parentGroupID = $select.data('parentId');
+ const groupsPath = parentGroupID
+ ? Api.subgroupsPath.replace(':id', parentGroupID)
+ : Api.groupsPath;
- $select.select2({
- placeholder: 'Search for a group',
- allowClear: $select.hasClass('allowClear'),
- multiple: $select.hasClass('multiselect'),
- minimumInputLength: 0,
- ajax: {
- url: Api.buildUrl(groupsPath),
- dataType: 'json',
- quietMillis: 250,
- transport(params) {
- axios[params.type.toLowerCase()](params.url, {
- params: params.data,
- })
- .then(res => {
- const results = res.data || [];
- const headers = normalizeHeaders(res.headers);
- const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
- const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
- const more = currentPage < totalPages;
+ $select.select2({
+ placeholder: __('Search for a group'),
+ allowClear: $select.hasClass('allowClear'),
+ multiple: $select.hasClass('multiselect'),
+ minimumInputLength: 0,
+ ajax: {
+ url: Api.buildUrl(groupsPath),
+ dataType: 'json',
+ quietMillis: 250,
+ transport(params) {
+ axios[params.type.toLowerCase()](params.url, {
+ params: params.data,
+ })
+ .then(res => {
+ const results = res.data || [];
+ const headers = normalizeHeaders(res.headers);
+ const currentPage = parseInt(headers['X-PAGE'], 10) || 0;
+ const totalPages = parseInt(headers['X-TOTAL-PAGES'], 10) || 0;
+ const more = currentPage < totalPages;
- params.success({
- results,
- pagination: {
- more,
- },
- });
- })
- .catch(params.error);
- },
- data(search, page) {
- return {
- search,
- page,
- per_page: window.GROUP_SELECT_PER_PAGE,
- all_available: allAvailable,
- };
- },
- results(data, page) {
- if (data.length) return { results: [] };
+ params.success({
+ results,
+ pagination: {
+ more,
+ },
+ });
+ })
+ .catch(params.error);
+ },
+ data(search, page) {
+ return {
+ search,
+ page,
+ per_page: window.GROUP_SELECT_PER_PAGE,
+ all_available: allAvailable,
+ };
+ },
+ results(data, page) {
+ if (data.length) return { results: [] };
- const groups = data.length ? data : data.results || [];
- const more = data.pagination ? data.pagination.more : false;
- const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
+ const groups = data.length ? data : data.results || [];
+ const more = data.pagination ? data.pagination.more : false;
+ const results = groups.filter(group => skipGroups.indexOf(group.id) === -1);
- return {
- results,
- page,
- more,
- };
- },
- },
- // eslint-disable-next-line consistent-return
- initSelection(element, callback) {
- const id = $(element).val();
- if (id !== '') {
- return Api.group(id, callback);
- }
- },
- formatResult(object) {
- return `<div class='group-result'> <div class='group-name'>${
- object.full_name
- }</div> <div class='group-path'>${object.full_path}</div> </div>`;
- },
- formatSelection(object) {
- return object.full_name;
- },
- dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup(m) {
- return m;
- },
- });
+ return {
+ results,
+ page,
+ more,
+ };
+ },
+ },
+ // eslint-disable-next-line consistent-return
+ initSelection(element, callback) {
+ const id = $(element).val();
+ if (id !== '') {
+ return Api.group(id, callback);
+ }
+ },
+ formatResult(object) {
+ return `<div class='group-result'> <div class='group-name'>${
+ object.full_name
+ }</div> <div class='group-path'>${object.full_path}</div> </div>`;
+ },
+ formatSelection(object) {
+ return object.full_name;
+ },
+ dropdownCssClass: 'ajax-groups-dropdown select2-infinite',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup(m) {
+ return m;
+ },
+ });
- $select.on('select2-loaded', () => {
- const dropdown = document.querySelector('.select2-infinite .select2-results');
- dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
- });
- });
+ $select.on('select2-loaded', () => {
+ const dropdown = document.querySelector('.select2-infinite .select2-results');
+ dropdown.style.height = `${Math.floor(dropdown.scrollHeight)}px`;
+ });
+ });
+ })
+ .catch(() => {});
}
diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js
index 2fa7a219ea8..3d846310008 100644
--- a/app/assets/javascripts/header.js
+++ b/app/assets/javascripts/header.js
@@ -23,7 +23,7 @@ export default function initTodoToggle() {
});
}
-document.addEventListener('DOMContentLoaded', () => {
+function initStatusTriggers() {
const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger');
const setStatusModalWrapperEl = document.querySelector('.js-set-status-modal-wrapper');
@@ -72,4 +72,8 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
}
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ requestIdleCallback(initStatusTriggers);
});
diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js
new file mode 100644
index 00000000000..2c2a04d5b5e
--- /dev/null
+++ b/app/assets/javascripts/helpers/monitor_helper.js
@@ -0,0 +1,17 @@
+/* eslint-disable import/prefer-default-export */
+
+export const makeDataSeries = (queryResults, defaultConfig) =>
+ queryResults.reduce((acc, result) => {
+ const data = result.values.filter(([, value]) => !Number.isNaN(value));
+ if (!data.length) {
+ return acc;
+ }
+ const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_');
+ const name = result.metric[relevantMetric];
+ const series = { data };
+ if (name) {
+ series.name = `${defaultConfig.name}: ${name}`;
+ }
+
+ return acc.concat({ ...defaultConfig, ...series });
+ }, []);
diff --git a/app/assets/javascripts/ide/components/activity_bar.vue b/app/assets/javascripts/ide/components/activity_bar.vue
index a1f66ff764d..7c769ab7fa0 100644
--- a/app/assets/javascripts/ide/components/activity_bar.vue
+++ b/app/assets/javascripts/ide/components/activity_bar.vue
@@ -45,7 +45,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
- @click.prevent="changedActivityView($event, $options.activityBarViews.edit);"
+ @click.prevent="changedActivityView($event, $options.activityBarViews.edit)"
>
<icon name="code" />
</button>
@@ -62,7 +62,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-review-mode"
- @click.prevent="changedActivityView($event, $options.activityBarViews.review);"
+ @click.prevent="changedActivityView($event, $options.activityBarViews.review)"
>
<icon name="file-modified" />
</button>
@@ -79,7 +79,7 @@ export default {
data-placement="right"
type="button"
class="ide-sidebar-link js-ide-commit-mode"
- @click.prevent="changedActivityView($event, $options.activityBarViews.commit);"
+ @click.prevent="changedActivityView($event, $options.activityBarViews.commit)"
>
<icon name="commit" />
</button>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
index d360dc42cd3..1824a0f6147 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue
@@ -1,17 +1,23 @@
<script>
import _ from 'underscore';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import { mapActions, mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { sprintf, __ } from '~/locale';
-import * as consts from '../../stores/modules/commit/constants';
+import consts from '../../stores/modules/commit/constants';
import RadioGroup from './radio_group.vue';
+const { mapState: mapCommitState, mapGetters: mapCommitGetters } = createNamespacedHelpers(
+ 'commit',
+);
+
export default {
components: {
RadioGroup,
},
computed: {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
- ...mapGetters(['currentProject', 'currentBranch']),
+ ...mapCommitState(['commitAction', 'shouldCreateMR', 'shouldDisableNewMrOption']),
+ ...mapGetters(['currentProject', 'currentBranch', 'currentMergeRequest']),
+ ...mapCommitGetters(['shouldDisableNewMrOption']),
commitToCurrentBranchText() {
return sprintf(
__('Commit to %{branchName} branch'),
@@ -32,7 +38,7 @@ export default {
this.updateSelectedCommitAction();
},
methods: {
- ...mapActions('commit', ['updateCommitAction']),
+ ...mapActions('commit', ['updateCommitAction', 'toggleShouldCreateMR']),
updateSelectedCommitAction() {
if (this.currentBranch && !this.currentBranch.can_push) {
this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH);
@@ -43,7 +49,6 @@ export default {
},
commitToCurrentBranch: consts.COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: consts.COMMIT_TO_NEW_BRANCH,
- commitToNewBranchMR: consts.COMMIT_TO_NEW_BRANCH_MR,
currentBranchPermissionsTooltip: __(
"This option is disabled as you don't have write permissions for the current branch",
),
@@ -64,13 +69,17 @@ export default {
:label="__('Create a new branch')"
:show-input="true"
/>
- <radio-group
- v-if="currentProject.merge_requests_enabled"
- :value="$options.commitToNewBranchMR"
- :label="__('Create a new branch and merge request')"
- :title="__('This option is disabled while you still have unstaged changes')"
- :show-input="true"
- :disabled="disableMergeRequestRadio"
- />
+ <hr class="my-2" />
+ <label class="mb-0">
+ <input
+ :checked="shouldCreateMR"
+ :disabled="shouldDisableNewMrOption"
+ type="checkbox"
+ @change="toggleShouldCreateMR"
+ />
+ <span class="prepend-left-10" :class="{ 'text-secondary': shouldDisableNewMrOption }">
+ {{ __('Start a new merge request') }}
+ </span>
+ </label>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
index 5119dbf32eb..11d5d9639b6 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue
@@ -44,7 +44,7 @@ export default {
<div class="d-flex ide-commit-editor-header align-items-center">
<file-icon :file-name="activeFile.name" :size="16" class="mr-2" />
<strong class="mr-2"> {{ activeFile.path }} </strong>
- <changed-file-icon :file="activeFile" class="ml-0" />
+ <changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<button
v-if="!isStaged"
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
index a1094570275..4f1260de0bc 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue
@@ -87,7 +87,7 @@ export default {
},
},
discardModalText: __(
- "You will loose all the unstaged changes you've made in this project. This action cannot be undone.",
+ "You will lose all the unstaged changes you've made in this project. This action cannot be undone.",
),
};
</script>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
index 6f1ded91753..6b0aa5b2b2b 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/message_field.vue
@@ -108,11 +108,12 @@ export default {
:placeholder="placeholder"
:value="text"
class="note-textarea ide-commit-message-textarea"
+ dir="auto"
name="commit-message"
@scroll="handleScroll"
@input="onInput"
- @focus="updateIsFocused(true);"
- @blur="updateIsFocused(false);"
+ @focus="updateIsFocused(true)"
+ @blur="updateIsFocused(false)"
@keydown.ctrl.enter="onCtrlEnter"
@keydown.meta.enter="onCtrlEnter"
>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
index 3525084b1cb..9161eb3d9b1 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue
@@ -38,8 +38,8 @@ export default {
},
},
computed: {
- ...mapState('commit', ['commitAction']),
- ...mapGetters('commit', ['newBranchName']),
+ ...mapState('commit', ['commitAction', 'newBranchName']),
+ ...mapGetters('commit', ['placeholderBranchName']),
tooltipTitle() {
return this.disabled ? this.title : '';
},
@@ -65,7 +65,7 @@ export default {
:disabled="disabled"
type="radio"
name="commit-action"
- @change="updateCommitAction($event.target.value);"
+ @change="updateCommitAction($event.target.value)"
/>
<span class="prepend-left-10">
<span v-if="label" class="ide-radio-label"> {{ label }} </span> <slot v-else></slot>
@@ -73,10 +73,11 @@ export default {
</label>
<div v-if="commitAction === value && showInput" class="ide-commit-new-branch">
<input
- :placeholder="newBranchName"
+ :placeholder="placeholderBranchName"
+ :value="newBranchName"
type="text"
class="form-control monospace"
- @input="updateBranchName($event.target.value);"
+ @input="updateBranchName($event.target.value)"
/>
</div>
</fieldset>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
index 02c2004d495..09c9d135614 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue
@@ -48,7 +48,7 @@ export default {
data-container="body"
data-boundary="viewport"
data-placement="bottom"
- @click.stop.prevent="stageChange(path);"
+ @click.stop.prevent="stageChange(path)"
>
<icon :size="16" name="mobile-issue-close" class="ml-auto mr-auto" />
</button>
@@ -70,9 +70,9 @@ export default {
:header-title-text="modalTitle"
:footer-primary-button-text="__('Discard changes')"
footer-primary-button-variant="danger"
- @submit="discardFileChanges(path);"
+ @submit="discardFileChanges(path)"
>
- {{ __("You will loose all changes you've made to this file. This action cannot be undone.") }}
+ {{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
</gl-modal>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
index ce41fcdb087..0567ef54ff3 100644
--- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
+++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue
@@ -33,7 +33,7 @@ export default {
data-container="body"
data-boundary="viewport"
data-placement="bottom"
- @click.stop.prevent="unstageChange(path);"
+ @click.stop.prevent="unstageChange(path)"
>
<icon :size="16" name="redo" class="ml-auto mr-auto" />
</button>
diff --git a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
index 5f99261ec39..732fa0786b0 100644
--- a/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/ide/components/editor_mode_dropdown.vue
@@ -40,7 +40,7 @@ export default {
'is-active': viewer === $options.viewerTypes.mr,
}"
href="#"
- @click.prevent="changeMode($options.viewerTypes.mr);"
+ @click.prevent="changeMode($options.viewerTypes.mr)"
>
<strong class="dropdown-menu-inner-title"> {{ mergeReviewLine }} </strong>
<span class="dropdown-menu-inner-content">
@@ -54,7 +54,7 @@ export default {
'is-active': viewer === $options.viewerTypes.diff,
}"
href="#"
- @click.prevent="changeMode($options.viewerTypes.diff);"
+ @click.prevent="changeMode($options.viewerTypes.diff)"
>
<strong class="dropdown-menu-inner-title">{{ __('Reviewing') }}</strong>
<span class="dropdown-menu-inner-content">
diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue
index d6673cf0421..80a6ab9598a 100644
--- a/app/assets/javascripts/ide/components/file_row_extra.vue
+++ b/app/assets/javascripts/ide/components/file_row_extra.vue
@@ -23,7 +23,7 @@ export default {
type: Object,
required: true,
},
- mouseOver: {
+ dropdownOpen: {
type: Boolean,
required: true,
},
@@ -92,8 +92,9 @@ export default {
<new-dropdown
:type="file.type"
:path="file.path"
- :mouse-over="mouseOver"
+ :is-open="dropdownOpen"
class="prepend-left-8"
+ v-on="$listeners"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
index 414ea9c7d4d..343e0cca672 100644
--- a/app/assets/javascripts/ide/components/file_templates/dropdown.vue
+++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue
@@ -91,7 +91,7 @@ export default {
<gl-loading-icon v-if="showLoading" :size="2" />
<ul v-else>
<li v-for="(item, index) in outputData" :key="index">
- <button type="button" @click="clickItem(item);">{{ item.name }}</button>
+ <button type="button" @click="clickItem(item)">{{ item.name }}</button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue
index caec8779cac..9894ebb0624 100644
--- a/app/assets/javascripts/ide/components/ide.vue
+++ b/app/assets/javascripts/ide/components/ide.vue
@@ -1,20 +1,17 @@
<script>
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import { __ } from '~/locale';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue';
-import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
import ErrorMessage from './error_message.vue';
import CommitEditorHeader from './commit_sidebar/editor_header.vue';
-const originalStopCallback = Mousetrap.stopCallback;
-
export default {
components: {
NewModal,
@@ -42,21 +39,18 @@ export default {
'emptyStateSvgPath',
'currentProjectId',
'errorMessage',
+ 'loading',
+ ]),
+ ...mapGetters([
+ 'activeFile',
+ 'hasChanges',
+ 'someUncommittedChanges',
+ 'isCommitModeActive',
+ 'allBlobs',
]),
- ...mapGetters(['activeFile', 'hasChanges', 'someUncommittedChanges', 'isCommitModeActive']),
},
mounted() {
window.onbeforeunload = e => this.onBeforeUnload(e);
-
- Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
- if (e.preventDefault) {
- e.preventDefault();
- }
-
- this.toggleFileFinder(!this.fileFindVisible);
- });
-
- Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
@@ -70,17 +64,8 @@ export default {
});
return returnValue;
},
- mousetrapStopCallback(e, el, combo) {
- if (
- (combo === 't' && el.classList.contains('dropdown-input-field')) ||
- el.classList.contains('inputarea')
- ) {
- return true;
- } else if (combo === 'command+p' || combo === 'ctrl+p') {
- return false;
- }
-
- return originalStopCallback(e, el, combo);
+ openFile(file) {
+ this.$router.push(`/project${file.url}`);
},
},
};
@@ -90,7 +75,14 @@ export default {
<article class="ide position-relative d-flex flex-column align-items-stretch">
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view flex-grow d-flex">
- <find-file v-show="fileFindVisible" />
+ <find-file
+ v-show="fileFindVisible"
+ :files="allBlobs"
+ :visible="fileFindVisible"
+ :loading="loading"
+ @toggle="toggleFileFinder"
+ @click="openFile"
+ />
<ide-sidebar />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue
index e2e0acc22b1..ce577ae85b0 100644
--- a/app/assets/javascripts/ide/components/ide_status_bar.vue
+++ b/app/assets/javascripts/ide/components/ide_status_bar.vue
@@ -84,7 +84,7 @@ export default {
<button
type="button"
class="p-0 border-0 h-50"
- @click="openRightPane($options.rightSidebarViews.pipelines);"
+ @click="openRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
v-tooltip
@@ -107,16 +107,23 @@ export default {
class="commit-sha"
>{{ lastCommit.short_id }}</a
>
- by {{ lastCommit.author_name }}
+ by
+ <user-avatar-image
+ css-classes="ide-status-avatar"
+ :size="18"
+ :img-src="latestPipeline && latestPipeline.commit.author_gravatar_url"
+ :img-alt="lastCommit.author_name"
+ :tooltip-text="lastCommit.author_name"
+ />
+ {{ lastCommit.author_name }}
<time
v-tooltip
:datetime="lastCommit.committed_date"
:title="tooltipTitle(lastCommit.committed_date)"
data-placement="top"
data-container="body"
+ >{{ lastCommitFormatedAge }}</time
>
- {{ lastCommitFormatedAge }}
- </time>
</div>
<div v-if="file" class="ide-status-file">{{ file.name }}</div>
<div v-if="file" class="ide-status-file">{{ file.eol }}</div>
diff --git a/app/assets/javascripts/ide/components/ide_tree.vue b/app/assets/javascripts/ide/components/ide_tree.vue
index 9fc21adae7c..f93496132a4 100644
--- a/app/assets/javascripts/ide/components/ide_tree.vue
+++ b/app/assets/javascripts/ide/components/ide_tree.vue
@@ -43,7 +43,7 @@ export default {
:show-label="false"
class="d-flex border-0 p-0 mr-3 qa-new-file"
icon="doc-new"
- @click="openNewEntryModal({ type: 'blob' });"
+ @click="openNewEntryModal({ type: 'blob' })"
/>
<upload
:show-label="false"
@@ -56,7 +56,7 @@ export default {
:show-label="false"
class="d-flex border-0 p-0"
icon="folder-new"
- @click="openNewEntryModal({ type: 'tree' });"
+ @click="openNewEntryModal({ type: 'tree' })"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/jobs/detail.vue b/app/assets/javascripts/ide/components/jobs/detail.vue
index e8fe5fc696d..7710bfb49ec 100644
--- a/app/assets/javascripts/ide/components/jobs/detail.vue
+++ b/app/assets/javascripts/ide/components/jobs/detail.vue
@@ -75,7 +75,7 @@ export default {
<template>
<div class="ide-pipeline build-page d-flex flex-column flex-fill">
<header class="ide-job-header d-flex align-items-center">
- <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null);">
+ <button class="btn btn-default btn-sm d-flex" @click="setDetailJob(null)">
<icon name="chevron-left" /> {{ __('View jobs') }}
</button>
</header>
diff --git a/app/assets/javascripts/ide/components/merge_requests/list.vue b/app/assets/javascripts/ide/components/merge_requests/list.vue
index ac2b0eddfb4..2d55ffb3c65 100644
--- a/app/assets/javascripts/ide/components/merge_requests/list.vue
+++ b/app/assets/javascripts/ide/components/merge_requests/list.vue
@@ -84,7 +84,7 @@ export default {
:placeholder="__('Search merge requests')"
@focus="onSearchFocus"
@input="searchMergeRequests"
- @removeToken="setSearchType(null);"
+ @removeToken="setSearchType(null)"
/>
<icon :size="18" name="search" class="input-icon" />
</div>
@@ -102,7 +102,7 @@ export default {
<button
type="button"
class="btn-link d-flex align-items-center"
- @click.stop="setSearchType(searchType);"
+ @click.stop="setSearchType(searchType)"
>
<span class="d-flex append-right-default ide-search-list-current-icon">
<icon :size="18" name="search" />
diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue
index a50d729036f..27d24fa5e1d 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/index.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue
@@ -1,7 +1,6 @@
<script>
import { mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
-import newModal from './modal.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
import { modalTypes } from '../../constants';
@@ -9,7 +8,6 @@ import { modalTypes } from '../../constants';
export default {
components: {
icon,
- newModal,
upload,
ItemButton,
},
@@ -23,38 +21,29 @@ export default {
required: false,
default: '',
},
- mouseOver: {
+ isOpen: {
type: Boolean,
- required: true,
+ required: false,
+ default: false,
},
},
- data() {
- return {
- dropdownOpen: false,
- };
- },
watch: {
- dropdownOpen() {
+ isOpen() {
this.$nextTick(() => {
this.$refs.dropdownMenu.scrollIntoView({
block: 'nearest',
});
});
},
- mouseOver() {
- if (!this.mouseOver) {
- this.dropdownOpen = false;
- }
- },
},
methods: {
...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']),
createNewItem(type) {
this.openNewEntryModal({ type, path: this.path });
- this.dropdownOpen = false;
+ this.$emit('toggle', false);
},
openDropdown() {
- this.dropdownOpen = !this.dropdownOpen;
+ this.$emit('toggle', !this.isOpen);
},
},
modalTypes,
@@ -65,7 +54,7 @@ export default {
<div class="ide-new-btn">
<div
:class="{
- show: dropdownOpen,
+ show: isOpen,
}"
class="dropdown d-flex"
>
@@ -73,7 +62,7 @@ export default {
:aria-label="__('Create new file or directory')"
type="button"
class="rounded border-0 d-flex ide-entry-dropdown-toggle"
- @click.stop="openDropdown();"
+ @click.stop="openDropdown()"
>
<icon name="ellipsis_v" /> <icon name="arrow-down" />
</button>
@@ -85,7 +74,7 @@ export default {
class="d-flex"
icon="doc-new"
icon-classes="mr-2"
- @click="createNewItem('blob');"
+ @click="createNewItem('blob')"
/>
</li>
<li><upload :path="path" @create="createTempEntry" /></li>
@@ -95,7 +84,7 @@ export default {
class="d-flex"
icon="folder-new"
icon-classes="mr-2"
- @click="createNewItem($options.modalTypes.tree);"
+ @click="createNewItem($options.modalTypes.tree)"
/>
</li>
<li class="divider"></li>
@@ -106,7 +95,7 @@ export default {
class="d-flex"
icon="pencil"
icon-classes="mr-2"
- @click="createNewItem($options.modalTypes.rename);"
+ @click="createNewItem($options.modalTypes.rename)"
/>
</li>
<li>
@@ -115,7 +104,7 @@ export default {
class="d-flex"
icon="remove"
icon-classes="mr-2"
- @click="deleteEntry(path);"
+ @click="deleteEntry(path)"
/>
</li>
</ul>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index 63cbf41b89b..412b07553dc 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -1,6 +1,7 @@
<script>
import $ from 'jquery';
-import { __ } from '~/locale';
+import flash from '~/flash';
+import { __, sprintf, s__ } from '~/locale';
import { mapActions, mapState, mapGetters } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import { modalTypes } from '../../constants';
@@ -15,18 +16,20 @@ export default {
};
},
computed: {
- ...mapState(['entryModal']),
+ ...mapState(['entries', 'entryModal']),
...mapGetters('fileTemplates', ['templateTypes']),
entryName: {
get() {
+ const entryPath = this.entryModal.entry.path;
+
if (this.entryModal.type === modalTypes.rename) {
- return this.name || this.entryModal.entry.name;
+ return this.name || entryPath;
}
- return this.name || (this.entryModal.path !== '' ? `${this.entryModal.path}/` : '');
+ return this.name || (entryPath ? `${entryPath}/` : '');
},
set(val) {
- this.name = val;
+ this.name = val.trim();
},
},
modalTitle() {
@@ -51,18 +54,51 @@ export default {
return __('Create file');
},
- isCreatingNew() {
- return this.entryModal.type !== modalTypes.rename;
+ isCreatingNewFile() {
+ return this.entryModal.type === 'blob';
+ },
+ placeholder() {
+ return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
},
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
submitForm() {
if (this.entryModal.type === modalTypes.rename) {
- this.renameEntry({
- path: this.entryModal.entry.path,
- name: this.entryName,
- });
+ if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
+ flash(
+ sprintf(s__('The name %{entryName} is already taken in this directory.'), {
+ entryName: this.entryName,
+ }),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ } else {
+ let parentPath = this.entryName.split('/');
+ const entryName = parentPath.pop();
+ parentPath = parentPath.join('/');
+
+ const createPromise =
+ parentPath && !this.entries[parentPath]
+ ? this.createTempEntry({ name: parentPath, type: 'tree' })
+ : Promise.resolve();
+
+ createPromise
+ .then(() =>
+ this.renameEntry({
+ path: this.entryModal.entry.path,
+ name: entryName,
+ entryPath: null,
+ parentPath,
+ }),
+ )
+ .catch(() =>
+ flash(__('Error creating a new path'), 'alert', document, null, false, true),
+ );
+ }
} else {
this.createTempEntry({
name: this.name,
@@ -79,7 +115,14 @@ export default {
$('#ide-new-entry').modal('toggle');
},
focusInput() {
+ const name = this.entries[this.entryName] ? this.entries[this.entryName].name : null;
+ const inputValue = this.$refs.fieldName.value;
+
this.$refs.fieldName.focus();
+
+ if (name) {
+ this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
+ }
},
closedModal() {
this.name = '';
@@ -107,14 +150,17 @@ export default {
v-model="entryName"
type="text"
class="form-control qa-full-file-path"
- placeholder="/dir/file_name"
+ :placeholder="placeholder"
/>
- <ul v-if="isCreatingNew" class="prepend-top-default list-inline qa-template-list">
+ <ul
+ v-if="isCreatingNewFile"
+ class="file-templates prepend-top-default list-inline qa-template-list"
+ >
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<button
type="button"
class="btn btn-missing p-1 pr-2 pl-2"
- @click="createFromTemplate(template);"
+ @click="createFromTemplate(template)"
>
{{ template.name }}
</button>
diff --git a/app/assets/javascripts/ide/components/new_dropdown/upload.vue b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
index ec759043efc..188518dd419 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/upload.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/upload.vue
@@ -57,6 +57,8 @@ export default {
type: 'blob',
content: result,
base64: !isText,
+ binary: !isText,
+ rawPath: !isText ? target.result : '',
});
},
readFile(file) {
diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue
index 7a57ccf2dd3..2e6bd85feec 100644
--- a/app/assets/javascripts/ide/components/panes/right.vue
+++ b/app/assets/javascripts/ide/components/panes/right.vue
@@ -122,7 +122,7 @@ export default {
data-placement="left"
class="ide-sidebar-link is-right"
type="button"
- @click="clickTab($event, tab);"
+ @click="clickTab($event, tab)"
>
<icon :size="16" :name="tab.icon" />
</button>
diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue
index 451c8030e16..5ae73b2fc9c 100644
--- a/app/assets/javascripts/ide/components/pipelines/list.vue
+++ b/app/assets/javascripts/ide/components/pipelines/list.vue
@@ -24,7 +24,13 @@ export default {
...mapState(['pipelinesEmptyStateSvgPath', 'links']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
- ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']),
+ ...mapState('pipelines', [
+ 'isLoadingPipeline',
+ 'hasLoadedPipeline',
+ 'latestPipeline',
+ 'stages',
+ 'isLoadingJobs',
+ ]),
ciLintText() {
return sprintf(
__('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'),
@@ -36,7 +42,7 @@ export default {
);
},
showLoadingIcon() {
- return this.isLoadingPipeline && this.latestPipeline === null;
+ return this.isLoadingPipeline && !this.hasLoadedPipeline;
},
},
created() {
@@ -51,7 +57,7 @@ export default {
<template>
<div class="ide-pipeline">
<gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" />
- <template v-else-if="latestPipeline !== null">
+ <template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" :size="24" />
<span class="prepend-left-8">
@@ -62,7 +68,7 @@ export default {
</span>
</header>
<empty-state
- v-if="latestPipeline === false"
+ v-if="!latestPipeline"
:help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue
index 8dd88f187d4..99f1d4a573d 100644
--- a/app/assets/javascripts/ide/components/repo_commit_section.vue
+++ b/app/assets/javascripts/ide/components/repo_commit_section.vue
@@ -5,7 +5,7 @@ import Icon from '~/vue_shared/components/icon.vue';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue';
-import * as consts from '../stores/modules/commit/constants';
+import consts from '../stores/modules/commit/constants';
import { activityBarViews, stageKeys } from '../constants';
export default {
diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue
index c13d3ec094b..e15b2a6f76b 100644
--- a/app/assets/javascripts/ide/components/repo_editor.vue
+++ b/app/assets/javascripts/ide/components/repo_editor.vue
@@ -1,5 +1,6 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
@@ -35,7 +36,7 @@ export default {
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
shouldHideEditor() {
- return this.file && this.file.binary && !this.file.content;
+ return this.file && this.file.binary;
},
showContentViewer() {
return (
@@ -56,6 +57,10 @@ export default {
active: this.file.viewMode === 'preview',
};
},
+ fileType() {
+ const info = viewerInformationForPath(this.file.path);
+ return (info && info.id) || '';
+ },
},
watch: {
file(newVal, oldVal) {
@@ -219,7 +224,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: 'editor' });"
+ @click.prevent="setFileViewMode({ file, viewMode: 'editor' })"
>
<template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }}
@@ -233,7 +238,7 @@ export default {
<a
href="javascript:void(0);"
role="button"
- @click.prevent="setFileViewMode({ file, viewMode: 'preview' });"
+ @click.prevent="setFileViewMode({ file, viewMode: 'preview' })"
>
{{ file.previewMode.previewTitle }}
</a>
@@ -258,6 +263,7 @@ export default {
:path="file.rawPath || file.path"
:file-size="file.size"
:project-path="file.projectId"
+ :type="fileType"
/>
<diff-viewer
v-if="showDiffViewer"
diff --git a/app/assets/javascripts/ide/components/repo_tab.vue b/app/assets/javascripts/ide/components/repo_tab.vue
index 4b87b83db8a..f6aa2295844 100644
--- a/app/assets/javascripts/ide/components/repo_tab.vue
+++ b/app/assets/javascripts/ide/components/repo_tab.vue
@@ -74,7 +74,7 @@ export default {
active: tab.active,
disabled: tab.pending,
}"
- @click="clickFile(tab);"
+ @click="clickFile(tab)"
@mouseover="mouseOverTab"
@mouseout="mouseOutTab"
>
@@ -88,7 +88,7 @@ export default {
:disabled="tab.pending"
type="button"
class="multi-file-tab-close"
- @click.stop.prevent="closeFile(tab);"
+ @click.stop.prevent="closeFile(tab)"
>
<icon v-if="!showChangedIcon" :size="12" name="close" />
<changed-file-icon v-else :file="tab" />
diff --git a/app/assets/javascripts/ide/components/resizable_panel.vue b/app/assets/javascripts/ide/components/resizable_panel.vue
index a89de56ab5c..7277fcb7617 100644
--- a/app/assets/javascripts/ide/components/resizable_panel.vue
+++ b/app/assets/javascripts/ide/components/resizable_panel.vue
@@ -78,8 +78,8 @@ export default {
:min-size="minSize"
:max-size="$options.maxSize"
:side="side === 'right' ? 'left' : 'right'"
- @resize-start="setResizingStatus(true);"
- @resize-end="setResizingStatus(false);"
+ @resize-start="setResizingStatus(true)"
+ @resize-end="setResizingStatus(false)"
/>
</div>
</template>
diff --git a/app/assets/javascripts/ide/components/shared/tokened_input.vue b/app/assets/javascripts/ide/components/shared/tokened_input.vue
index f58e08c2cc9..de3e71dad92 100644
--- a/app/assets/javascripts/ide/components/shared/tokened_input.vue
+++ b/app/assets/javascripts/ide/components/shared/tokened_input.vue
@@ -76,8 +76,8 @@ export default {
<button
class="selectable btn-blank"
type="button"
- @click.stop="removeToken(token);"
- @keyup.delete="removeToken(token);"
+ @click.stop="removeToken(token)"
+ @keyup.delete="removeToken(token)"
>
<div class="value-container rounded">
<div class="value">{{ token.label }}</div>
diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js
index 09245ed0296..e30670e119f 100644
--- a/app/assets/javascripts/ide/constants.js
+++ b/app/assets/javascripts/ide/constants.js
@@ -1,8 +1,3 @@
-// Fuzzy file finder
-export const MAX_FILE_FINDER_RESULTS = 40;
-export const FILE_FINDER_ROW_HEIGHT = 55;
-export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
-
export const MAX_WINDOW_HEIGHT_COMPACT = 750;
// Commit message textarea
@@ -29,6 +24,22 @@ export const diffModes = {
mode_changed: 'mode_changed',
};
+export const diffViewerModes = Object.freeze({
+ not_diffable: 'not_diffable',
+ no_preview: 'no_preview',
+ added: 'added',
+ deleted: 'deleted',
+ renamed: 'renamed',
+ mode_changed: 'mode_changed',
+ text: 'text',
+ image: 'image',
+});
+
+export const diffViewerErrors = Object.freeze({
+ too_large: 'too_large',
+ stored_externally: 'server_side_but_stored_externally',
+});
+
export const rightSidebarViews = {
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
@@ -61,4 +72,11 @@ export const modalTypes = {
tree: 'tree',
};
+export const commitActionTypes = {
+ move: 'move',
+ delete: 'delete',
+ create: 'create',
+ update: 'update',
+};
+
export const packageJsonPath = 'package.json';
diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js
index 3f6101e58f4..8c84b98a108 100644
--- a/app/assets/javascripts/ide/ide_router.js
+++ b/app/assets/javascripts/ide/ide_router.js
@@ -1,8 +1,9 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
-import { join as joinPath } from 'path';
+import { joinPaths } from '~/lib/utils/url_utility';
import flash from '~/flash';
import store from './stores';
+import { __ } from '~/locale';
Vue.use(VueRouter);
@@ -34,7 +35,7 @@ const EmptyRouterComponent = {
const router = new VueRouter({
mode: 'history',
- base: `${gon.relative_url_root}/-/ide/`,
+ base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
routes: [
{
path: '/project/:namespace+/:project',
@@ -46,11 +47,11 @@ const router = new VueRouter({
},
{
path: ':targetmode(edit|tree|blob)/:branchid+/',
- redirect: to => joinPath(to.path, '/-/'),
+ redirect: to => joinPaths(to.path, '/-/'),
},
{
path: ':targetmode(edit|tree|blob)',
- redirect: to => joinPath(to.path, '/master/-/'),
+ redirect: to => joinPaths(to.path, '/master/-/'),
},
{
path: 'merge_requests/:mrid',
@@ -58,7 +59,7 @@ const router = new VueRouter({
},
{
path: '',
- redirect: to => joinPath(to.path, '/edit/master/-/'),
+ redirect: to => joinPaths(to.path, '/edit/master/-/'),
},
],
},
@@ -73,7 +74,7 @@ router.beforeEach((to, from, next) => {
projectId: to.params.project,
})
.then(() => {
- const basePath = to.params[0] || '';
+ const basePath = to.params.pathMatch || '';
const projectId = `${to.params.namespace}/${to.params.project}`;
const branchId = to.params.branchid;
const mergeRequestId = to.params.mrid;
@@ -94,7 +95,7 @@ router.beforeEach((to, from, next) => {
})
.catch(e => {
flash(
- 'Error while loading the project data. Please try again.',
+ __('Error while loading the project data. Please try again.'),
'alert',
document,
null,
diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js
index 6351948f750..cdfebd19fa4 100644
--- a/app/assets/javascripts/ide/index.js
+++ b/app/assets/javascripts/ide/index.js
@@ -6,17 +6,25 @@ import ide from './components/ide.vue';
import store from './stores';
import router from './ide_router';
import { parseBoolean } from '../lib/utils/common_utils';
+import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
Vue.use(Translate);
/**
+ * Function that receives the default store and returns an extended one.
+ * @callback extendStoreCallback
+ * @param {Vuex.Store} store
+ * @param {Element} el
+ */
+
+/**
* Initialize the IDE on the given element.
*
* @param {Element} el - The element that will contain the IDE.
* @param {Object} options - Extra options for the IDE (Used by EE).
* @param {Component} options.rootComponent -
* Component that overrides the root component.
- * @param {(store:Vuex.Store, el:Element) => Vuex.Store} options.extendStore -
+ * @param {extendStoreCallback} options.extendStore -
* Function that receives the default store and returns an extended one.
*/
export function initIde(el, options = {}) {
@@ -53,16 +61,6 @@ export function initIde(el, options = {}) {
});
}
-// tell webpack to load assets from origin so that web workers don't break
-export function resetServiceWorkersPublicPath() {
- // __webpack_public_path__ is a global variable that can be used to adjust
- // the webpack publicPath setting at runtime.
- // see: https://webpack.js.org/guides/public-path/
- const relativeRootPath = (gon && gon.relative_url_root) || '';
- const webpackAssetPath = `${relativeRootPath}/assets/webpack/`;
- __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase
-}
-
/**
* Start the IDE.
*
diff --git a/app/assets/javascripts/ide/lib/common/model.js b/app/assets/javascripts/ide/lib/common/model.js
index 60bddb34977..a15f04075d9 100644
--- a/app/assets/javascripts/ide/lib/common/model.js
+++ b/app/assets/javascripts/ide/lib/common/model.js
@@ -13,12 +13,12 @@ export default class Model {
(this.originalModel = monacoEditor.createModel(
head ? head.content : this.file.raw,
undefined,
- new Uri(false, false, `original/${this.path}`),
+ new Uri('gitlab', false, `original/${this.path}`),
)),
(this.model = monacoEditor.createModel(
this.content,
undefined,
- new Uri(false, false, this.path),
+ new Uri('gitlab', false, this.path),
)),
);
if (this.file.mrChange) {
@@ -26,7 +26,7 @@ export default class Model {
(this.baseModel = monacoEditor.createModel(
this.file.baseRaw,
undefined,
- new Uri(false, false, `target/${this.path}`),
+ new Uri('gitlab', false, `target/${this.path}`),
)),
);
}
diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js
new file mode 100644
index 00000000000..b8abaa41f23
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/files.js
@@ -0,0 +1,119 @@
+import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
+import { decorateData, sortTree } from '../stores/utils';
+
+export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/');
+
+export const splitParent = path => {
+ const idx = path.lastIndexOf('/');
+
+ return {
+ parent: idx >= 0 ? path.substring(0, idx) : null,
+ name: idx >= 0 ? path.substring(idx + 1) : path,
+ };
+};
+
+/**
+ * Create file objects from a list of file paths.
+ */
+export const decorateFiles = ({
+ data,
+ projectId,
+ branchId,
+ tempFile = false,
+ content = '',
+ base64 = false,
+ binary = false,
+ rawPath = '',
+}) => {
+ const treeList = [];
+ const entries = {};
+
+ // These mutable variable references end up being exported and used by `createTempEntry`
+ let file;
+ let parentPath;
+
+ const insertParent = path => {
+ if (!path) {
+ return null;
+ } else if (entries[path]) {
+ return entries[path];
+ }
+
+ const { parent, name } = splitParent(path);
+ const parentFolder = parent && insertParent(parent);
+ parentPath = parentFolder && parentFolder.path;
+
+ const tree = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name,
+ path,
+ url: `/${projectId}/tree/${branchId}/-/${escapeFileUrl(path)}/`,
+ type: 'tree',
+ parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
+ tempFile,
+ changed: tempFile,
+ opened: tempFile,
+ parentPath,
+ });
+
+ Object.assign(entries, {
+ [path]: tree,
+ });
+
+ if (parentFolder) {
+ parentFolder.tree.push(tree);
+ } else {
+ treeList.push(tree);
+ }
+
+ return tree;
+ };
+
+ data.forEach(path => {
+ const { parent, name } = splitParent(path);
+
+ const fileFolder = parent && insertParent(parent);
+
+ if (name) {
+ parentPath = fileFolder && fileFolder.path;
+
+ file = decorateData({
+ projectId,
+ branchId,
+ id: path,
+ name,
+ path,
+ url: `/${projectId}/blob/${branchId}/-/${escapeFileUrl(path)}`,
+ type: 'blob',
+ parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
+ tempFile,
+ changed: tempFile,
+ content,
+ base64,
+ binary,
+ rawPath,
+ previewMode: viewerInformationForPath(name),
+ parentPath,
+ });
+
+ Object.assign(entries, {
+ [path]: file,
+ });
+
+ if (fileFolder) {
+ fileFolder.tree.push(file);
+ } else {
+ treeList.push(file);
+ }
+ }
+ });
+
+ return {
+ entries,
+ treeList: sortTree(treeList),
+ file,
+ parentPath,
+ };
+};
diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js
index 13449592e62..ba33b6826d6 100644
--- a/app/assets/javascripts/ide/services/index.js
+++ b/app/assets/javascripts/ide/services/index.js
@@ -40,6 +40,9 @@ export default {
getProjectData(namespace, project) {
return Api.project(`${namespace}/${project}`);
},
+ getProjectMergeRequests(projectId, params = {}) {
+ return Api.projectMergeRequests(projectId, params);
+ },
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.projectMergeRequest(projectId, mergeRequestId, params);
},
diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js
index e10a132ab4b..fd678e6e10c 100644
--- a/app/assets/javascripts/ide/stores/actions.js
+++ b/app/assets/javascripts/ide/stores/actions.js
@@ -3,7 +3,7 @@ import Vue from 'vue';
import { visitUrl } from '~/lib/utils/url_utility';
import flash from '~/flash';
import * as types from './mutation_types';
-import FilesDecoratorWorker from './workers/files_decorator_worker';
+import { decorateFiles } from '../lib/files';
import { stageKeys } from '../constants';
export const redirectToUrl = (_, url) => visitUrl(url);
@@ -53,10 +53,9 @@ export const setResizingStatus = ({ commit }, resizing) => {
export const createTempEntry = (
{ state, commit, dispatch },
- { name, type, content = '', base64 = false },
+ { name, type, content = '', base64 = false, binary = false, rawPath = '' },
) =>
new Promise(resolve => {
- const worker = new FilesDecoratorWorker();
const fullName = name.slice(-1) !== '/' && type === 'tree' ? `${name}/` : name;
if (state.entries[name]) {
@@ -74,39 +73,36 @@ export const createTempEntry = (
return null;
}
- worker.addEventListener('message', ({ data }) => {
- const { file, parentPath } = data;
-
- worker.terminate();
-
- commit(types.CREATE_TMP_ENTRY, {
- data,
- projectId: state.currentProjectId,
- branchId: state.currentBranchId,
- });
-
- if (type === 'blob') {
- commit(types.TOGGLE_FILE_OPEN, file.path);
- commit(types.ADD_FILE_TO_CHANGED, file.path);
- dispatch('setFileActive', file.path);
- }
-
- if (parentPath && !state.entries[parentPath].opened) {
- commit(types.TOGGLE_TREE_OPEN, parentPath);
- }
-
- resolve(file);
- });
-
- worker.postMessage({
+ const data = decorateFiles({
data: [fullName],
projectId: state.currentProjectId,
branchId: state.currentBranchId,
type,
tempFile: true,
- base64,
content,
+ base64,
+ binary,
+ rawPath,
});
+ const { file, parentPath } = data;
+
+ commit(types.CREATE_TMP_ENTRY, {
+ data,
+ projectId: state.currentProjectId,
+ branchId: state.currentBranchId,
+ });
+
+ if (type === 'blob') {
+ commit(types.TOGGLE_FILE_OPEN, file.path);
+ commit(types.ADD_FILE_TO_CHANGED, file.path);
+ dispatch('setFileActive', file.path);
+ }
+
+ if (parentPath && !state.entries[parentPath].opened) {
+ commit(types.TOGGLE_TREE_OPEN, parentPath);
+ }
+
+ resolve(file);
return null;
});
@@ -215,15 +211,27 @@ export const deleteEntry = ({ commit, dispatch, state }, path) => {
export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES);
-export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => {
+export const renameEntry = (
+ { dispatch, commit, state },
+ { path, name, entryPath = null, parentPath },
+) => {
const entry = state.entries[entryPath || path];
- commit(types.RENAME_ENTRY, { path, name, entryPath });
+ commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath });
if (entry.type === 'tree') {
- state.entries[entryPath || path].tree.forEach(f =>
- dispatch('renameEntry', { path, name, entryPath: f.path }),
- );
+ const slashedParentPath = parentPath ? `${parentPath}/` : '';
+ const targetEntry = entryPath ? entryPath.split('/').pop() : name;
+ const newParentPath = `${slashedParentPath}${targetEntry}`;
+
+ state.entries[entryPath || path].tree.forEach(f => {
+ dispatch('renameEntry', {
+ path,
+ name,
+ entryPath: f.path,
+ parentPath: newParentPath,
+ });
+ });
}
if (!entryPath && !entry.tempFile) {
diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js
index e74b880e02c..e7e8ac6d80b 100644
--- a/app/assets/javascripts/ide/stores/actions/file.js
+++ b/app/assets/javascripts/ide/stores/actions/file.js
@@ -1,5 +1,6 @@
-import { __ } from '../../../locale';
-import { normalizeHeaders } from '../../../lib/utils/common_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
import eventHub from '../../eventhub';
import service from '../../services';
import * as types from '../mutation_types';
@@ -69,7 +70,7 @@ export const getFileData = (
const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url;
return service
- .getFileData(`${gon.relative_url_root ? gon.relative_url_root : ''}${url.replace('/-/', '/')}`)
+ .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/')))
.then(({ data, headers }) => {
const normalizedHeaders = normalizeHeaders(headers);
setPageTitle(decodeURI(normalizedHeaders['PAGE-TITLE']));
diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js
index 18c24369996..1273e375859 100644
--- a/app/assets/javascripts/ide/stores/actions/merge_request.js
+++ b/app/assets/javascripts/ide/stores/actions/merge_request.js
@@ -4,6 +4,39 @@ import service from '../../services';
import * as types from '../mutation_types';
import { activityBarViews } from '../../constants';
+export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) =>
+ service
+ .getProjectMergeRequests(`${projectId}`, {
+ source_branch: branchId,
+ source_project_id: state.projects[projectId].id,
+ order_by: 'created_at',
+ per_page: 1,
+ })
+ .then(({ data }) => {
+ if (data.length > 0) {
+ const currentMR = data[0];
+
+ commit(types.SET_MERGE_REQUEST, {
+ projectPath: projectId,
+ mergeRequestId: currentMR.iid,
+ mergeRequest: currentMR,
+ });
+
+ commit(types.SET_CURRENT_MERGE_REQUEST, `${currentMR.iid}`);
+ }
+ })
+ .catch(e => {
+ flash(
+ __(`Error fetching merge requests for ${branchId}`),
+ 'alert',
+ document,
+ null,
+ false,
+ true,
+ );
+ throw e;
+ });
+
export const getMergeRequestData = (
{ commit, dispatch, state },
{ projectId, mergeRequestId, targetProjectId = null, force = false } = {},
diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js
index b65f631c99c..4b10d148ebf 100644
--- a/app/assets/javascripts/ide/stores/actions/project.js
+++ b/app/assets/javascripts/ide/stores/actions/project.js
@@ -136,17 +136,29 @@ export const openBranch = ({ dispatch, state }, { projectId, branchId, basePath
return dispatch('getFiles', {
projectId,
branchId,
- }).then(() => {
- if (basePath) {
- const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
- const treeEntryKey = Object.keys(state.entries).find(
- key => key === path && !state.entries[key].pending,
- );
- const treeEntry = state.entries[treeEntryKey];
+ })
+ .then(() => {
+ if (basePath) {
+ const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath;
+ const treeEntryKey = Object.keys(state.entries).find(
+ key => key === path && !state.entries[key].pending,
+ );
+ const treeEntry = state.entries[treeEntryKey];
- if (treeEntry) {
- dispatch('handleTreeEntryAction', treeEntry);
+ if (treeEntry) {
+ dispatch('handleTreeEntryAction', treeEntry);
+ } else {
+ dispatch('createTempEntry', {
+ name: path,
+ type: 'blob',
+ });
+ }
}
- }
- });
+ })
+ .then(() => {
+ dispatch('getMergeRequestsForBranch', {
+ projectId,
+ branchId,
+ });
+ });
};
diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js
index de5f6050074..3d83e4a9ba5 100644
--- a/app/assets/javascripts/ide/stores/actions/tree.js
+++ b/app/assets/javascripts/ide/stores/actions/tree.js
@@ -1,7 +1,8 @@
+import _ from 'underscore';
import { __ } from '../../../locale';
import service from '../../services';
import * as types from '../mutation_types';
-import FilesDecoratorWorker from '../workers/files_decorator_worker';
+import { decorateFiles } from '../../lib/files';
export const toggleTreeOpen = ({ commit }, path) => {
commit(types.TOGGLE_TREE_OPEN, path);
@@ -32,6 +33,19 @@ export const handleTreeEntryAction = ({ commit, dispatch }, row) => {
dispatch('showTreeEntry', row.path);
};
+export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeList }) => {
+ const selectedTree = state.trees[`${projectId}/${branchId}`];
+
+ commit(types.SET_DIRECTORY_DATA, {
+ treePath: `${projectId}/${branchId}`,
+ data: treeList,
+ });
+ commit(types.TOGGLE_LOADING, {
+ entry: selectedTree,
+ forceValue: false,
+ });
+};
+
export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) =>
new Promise((resolve, reject) => {
if (
@@ -45,31 +59,19 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } =
service
.getFiles(selectedProject.web_url, branchId)
.then(({ data }) => {
- const worker = new FilesDecoratorWorker();
- worker.addEventListener('message', e => {
- const { entries, treeList } = e.data;
- const selectedTree = state.trees[`${projectId}/${branchId}`];
-
- commit(types.SET_ENTRIES, entries);
- commit(types.SET_DIRECTORY_DATA, {
- treePath: `${projectId}/${branchId}`,
- data: treeList,
- });
- commit(types.TOGGLE_LOADING, {
- entry: selectedTree,
- forceValue: false,
- });
-
- worker.terminate();
-
- resolve();
- });
-
- worker.postMessage({
+ const { entries, treeList } = decorateFiles({
data,
projectId,
branchId,
});
+
+ commit(types.SET_ENTRIES, entries);
+
+ // Defer setting the directory data because this triggers some intense rendering.
+ // The entries is all we need to load the file editor.
+ _.defer(() => dispatch('setDirectoryData', { projectId, branchId, treeList }));
+
+ resolve();
})
.catch(e => {
if (e.response.status === 404) {
diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js
index 8ad85074d6b..490658a4543 100644
--- a/app/assets/javascripts/ide/stores/getters.js
+++ b/app/assets/javascripts/ide/stores/getters.js
@@ -25,7 +25,10 @@ export const projectsWithTrees = state =>
});
export const currentMergeRequest = state => {
- if (state.projects[state.currentProjectId]) {
+ if (
+ state.projects[state.currentProjectId] &&
+ state.projects[state.currentProjectId].mergeRequests
+ ) {
return state.projects[state.currentProjectId].mergeRequests[state.currentMergeRequestId];
}
return null;
diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js
index d97a950a8b2..c2760eb1554 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js
@@ -6,7 +6,7 @@ import { createCommitPayload, createNewMergeRequestUrl } from '../../utils';
import router from '../../../ide_router';
import service from '../../../services';
import * as types from './mutation_types';
-import * as consts from './constants';
+import consts from './constants';
import { activityBarViews } from '../../../constants';
import eventHub from '../../../eventhub';
@@ -18,16 +18,23 @@ export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
-export const updateCommitAction = ({ commit }, commitAction) => {
- commit(types.UPDATE_COMMIT_ACTION, commitAction);
+export const updateCommitAction = ({ commit, rootGetters }, commitAction) => {
+ commit(types.UPDATE_COMMIT_ACTION, {
+ commitAction,
+ currentMergeRequest: rootGetters.currentMergeRequest,
+ });
+};
+
+export const toggleShouldCreateMR = ({ commit }) => {
+ commit(types.TOGGLE_SHOULD_CREATE_MR);
};
export const updateBranchName = ({ commit }, branchName) => {
commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
};
-export const setLastCommitMessage = ({ rootState, commit }, data) => {
- const currentProject = rootState.projects[rootState.currentProjectId];
+export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
+ const { currentProject } = rootGetters;
const commitStats = data.stats
? sprintf(__('with %{additions} additions, %{deletions} deletions.'), {
additions: data.stats.additions,
@@ -48,8 +55,8 @@ export const setLastCommitMessage = ({ rootState, commit }, data) => {
commit(rootTypes.SET_LAST_COMMIT_MSG, commitMsg, { root: true });
};
-export const updateFilesAfterCommit = ({ commit, dispatch, rootState }, { data }) => {
- const selectedProject = rootState.projects[rootState.currentProjectId];
+export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetters }, { data }) => {
+ const selectedProject = rootGetters.currentProject;
const lastCommit = {
commit_path: `${selectedProject.web_url}/commit/${data.id}`,
commit: {
@@ -135,14 +142,15 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
branch: getters.branchName,
})
.then(() => {
- if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR) {
+ if (state.shouldCreateMR) {
+ const { currentProject } = rootGetters;
+ const targetBranch = getters.isCreatingNewBranch
+ ? rootState.currentBranchId
+ : currentProject.default_branch;
+
dispatch(
'redirectToUrl',
- createNewMergeRequestUrl(
- rootState.projects[rootState.currentProjectId].web_url,
- getters.branchName,
- rootState.currentBranchId,
- ),
+ createNewMergeRequestUrl(currentProject.web_url, getters.branchName, targetBranch),
{ root: true },
);
}
@@ -202,7 +210,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
dispatch(
'setErrorMessage',
{
- text: __('An error accured whilst committing your changes.'),
+ text: __('An error occurred whilst committing your changes.'),
action: () =>
dispatch('commitChanges').then(() =>
dispatch('setErrorMessage', null, { root: true }),
diff --git a/app/assets/javascripts/ide/stores/modules/commit/constants.js b/app/assets/javascripts/ide/stores/modules/commit/constants.js
index 230b0a3d9b5..c6c3701effe 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/constants.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/constants.js
@@ -1,3 +1,7 @@
-export const COMMIT_TO_CURRENT_BRANCH = '1';
-export const COMMIT_TO_NEW_BRANCH = '2';
-export const COMMIT_TO_NEW_BRANCH_MR = '3';
+const COMMIT_TO_CURRENT_BRANCH = '1';
+const COMMIT_TO_NEW_BRANCH = '2';
+
+export default {
+ COMMIT_TO_CURRENT_BRANCH,
+ COMMIT_TO_NEW_BRANCH,
+};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js
index 03777e6c10b..6aa5d22a4ea 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js
@@ -1,5 +1,5 @@
import { sprintf, n__, __ } from '../../../../locale';
-import * as consts from './constants';
+import consts from './constants';
const BRANCH_SUFFIX_COUNT = 5;
const createTranslatedTextForFiles = (files, text) => {
@@ -14,18 +14,15 @@ const createTranslatedTextForFiles = (files, text) => {
export const discardDraftButtonDisabled = state =>
state.commitMessage === '' || state.submitCommitLoading;
-export const newBranchName = (state, _, rootState) =>
+export const placeholderBranchName = (state, _, rootState) =>
`${gon.current_username}-${rootState.currentBranchId}-patch-${`${new Date().getTime()}`.substr(
-BRANCH_SUFFIX_COUNT,
)}`;
export const branchName = (state, getters, rootState) => {
- if (
- state.commitAction === consts.COMMIT_TO_NEW_BRANCH ||
- state.commitAction === consts.COMMIT_TO_NEW_BRANCH_MR
- ) {
+ if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) {
if (state.newBranchName === '') {
- return getters.newBranchName;
+ return getters.placeholderBranchName;
}
return state.newBranchName;
@@ -49,5 +46,10 @@ export const preBuiltCommitMessage = (state, _, rootState) => {
.join('\n');
};
+export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH;
+
+export const shouldDisableNewMrOption = (state, _getters, _rootState, rootGetters) =>
+ rootGetters.currentMergeRequest && state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH;
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
index 9221f054e9f..7ad8f3570b7 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js
@@ -2,3 +2,4 @@ export const UPDATE_COMMIT_MESSAGE = 'UPDATE_COMMIT_MESSAGE';
export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION';
export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME';
export const UPDATE_LOADING = 'UPDATE_LOADING';
+export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR';
diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
index 797357e3df9..be0f894c059 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js
@@ -1,4 +1,5 @@
import * as types from './mutation_types';
+import consts from './constants';
export default {
[types.UPDATE_COMMIT_MESSAGE](state, commitMessage) {
@@ -6,9 +7,13 @@ export default {
commitMessage,
});
},
- [types.UPDATE_COMMIT_ACTION](state, commitAction) {
+ [types.UPDATE_COMMIT_ACTION](state, { commitAction, currentMergeRequest }) {
Object.assign(state, {
commitAction,
+ shouldCreateMR:
+ commitAction === consts.COMMIT_TO_CURRENT_BRANCH && currentMergeRequest
+ ? false
+ : state.shouldCreateMR,
});
},
[types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
@@ -21,4 +26,9 @@ export default {
submitCommitLoading,
});
},
+ [types.TOGGLE_SHOULD_CREATE_MR](state) {
+ Object.assign(state, {
+ shouldCreateMR: !state.shouldCreateMR,
+ });
+ },
};
diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js
index 8dae50961b0..5c0e6a41ca1 100644
--- a/app/assets/javascripts/ide/stores/modules/commit/state.js
+++ b/app/assets/javascripts/ide/stores/modules/commit/state.js
@@ -3,4 +3,5 @@ export default () => ({
commitAction: '1',
newBranchName: '',
submitCommitLoading: false,
+ shouldCreateMR: false,
});
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
index b7090e09daf..59ead8a3dcf 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js
@@ -23,22 +23,27 @@ export const receiveTemplateTypesError = ({ commit, dispatch }) => {
export const receiveTemplateTypesSuccess = ({ commit }, templates) =>
commit(types.RECEIVE_TEMPLATE_TYPES_SUCCESS, templates);
-export const fetchTemplateTypes = ({ dispatch, state, rootState }, page = 1) => {
+export const fetchTemplateTypes = ({ dispatch, state, rootState }) => {
if (!Object.keys(state.selectedTemplateType).length) return Promise.reject();
dispatch('requestTemplateTypes');
- return Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, { page })
- .then(({ data, headers }) => {
- const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10);
+ const fetchPages = (page = 1, prev = []) =>
+ Api.projectTemplates(rootState.currentProjectId, state.selectedTemplateType.key, {
+ page,
+ per_page: 100,
+ })
+ .then(({ data, headers }) => {
+ const nextPage = parseInt(normalizeHeaders(headers)['X-NEXT-PAGE'], 10);
+ const nextData = prev.concat(data);
- dispatch('receiveTemplateTypesSuccess', data);
+ dispatch('receiveTemplateTypesSuccess', nextData);
- if (nextPage) {
- dispatch('fetchTemplateTypes', nextPage);
- }
- })
- .catch(() => dispatch('receiveTemplateTypesError'));
+ return nextPage ? fetchPages(nextPage, nextData) : nextData;
+ })
+ .catch(() => dispatch('receiveTemplateTypesError'));
+
+ return fetchPages();
};
export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => {
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
index 628babe6a01..f10891a8e5b 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js
@@ -1,4 +1,5 @@
import { activityBarViews } from '../../../constants';
+import { __ } from '~/locale';
export const templateTypes = () => [
{
@@ -10,11 +11,11 @@ export const templateTypes = () => [
key: 'gitignores',
},
{
- name: 'LICENSE',
+ name: __('LICENSE'),
key: 'licenses',
},
{
- name: 'Dockerfile',
+ name: __('Dockerfile'),
key: 'dockerfiles',
},
];
diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
index 25a65b047f1..7fc1c9134a7 100644
--- a/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/file_templates/mutations.js
@@ -3,13 +3,14 @@ import * as types from './mutation_types';
export default {
[types.REQUEST_TEMPLATE_TYPES](state) {
state.isLoading = true;
+ state.templates = [];
},
[types.RECEIVE_TEMPLATE_TYPES_ERROR](state) {
state.isLoading = false;
},
[types.RECEIVE_TEMPLATE_TYPES_SUCCESS](state, templates) {
state.isLoading = false;
- state.templates = state.templates.concat(templates);
+ state.templates = templates;
},
[types.SET_SELECTED_TEMPLATE_TYPE](state, type) {
state.selectedTemplateType = type;
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
index b4be100cb07..eaaa82cb339 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js
@@ -10,6 +10,7 @@ export default {
},
[types.RECEIVE_LASTEST_PIPELINE_SUCCESS](state, pipeline) {
state.isLoadingPipeline = false;
+ state.hasLoadedPipeline = true;
if (pipeline) {
state.latestPipeline = {
@@ -34,7 +35,7 @@ export default {
};
});
} else {
- state.latestPipeline = false;
+ state.latestPipeline = null;
}
},
[types.REQUEST_JOBS](state, id) {
diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
index 8651e267b53..8dfa0ec491f 100644
--- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js
+++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js
@@ -1,5 +1,6 @@
export default () => ({
isLoadingPipeline: true,
+ hasLoadedPipeline: false,
isLoadingJobs: false,
latestPipeline: null,
stages: [],
diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js
index 78cdfda74f0..344b189decf 100644
--- a/app/assets/javascripts/ide/stores/mutations.js
+++ b/app/assets/javascripts/ide/stores/mutations.js
@@ -206,19 +206,17 @@ export default {
}
}
},
- [types.RENAME_ENTRY](state, { path, name, entryPath = null }) {
+ [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) {
const oldEntry = state.entries[entryPath || path];
- const nameRegex =
- !entryPath && oldEntry.type === 'blob'
- ? new RegExp(`${oldEntry.name}$`)
- : new RegExp(`^${path}`);
- const newPath = oldEntry.path.replace(nameRegex, name);
- const parentPath = oldEntry.parentPath ? oldEntry.parentPath.replace(nameRegex, name) : '';
+ const slashedParentPath = parentPath ? `${parentPath}/` : '';
+ const newPath = entryPath
+ ? `${slashedParentPath}${oldEntry.name}`
+ : `${slashedParentPath}${name}`;
- state.entries[newPath] = {
+ Vue.set(state.entries, newPath, {
...oldEntry,
id: newPath,
- key: `${name}-${oldEntry.type}-${oldEntry.id}`,
+ key: `${newPath}-${oldEntry.type}-${oldEntry.id}`,
path: newPath,
name: entryPath ? oldEntry.name : name,
tempFile: true,
@@ -227,7 +225,8 @@ export default {
tree: [],
parentPath,
raw: '',
- };
+ });
+
oldEntry.moved = true;
oldEntry.movedPath = newPath;
@@ -256,6 +255,7 @@ export default {
Vue.delete(state.entries, oldEntry.path);
}
},
+
...projectMutations,
...mergeRequestMutation,
...fileMutations,
diff --git a/app/assets/javascripts/ide/stores/mutations/merge_request.js b/app/assets/javascripts/ide/stores/mutations/merge_request.js
index 334819fe702..e5b5107bc93 100644
--- a/app/assets/javascripts/ide/stores/mutations/merge_request.js
+++ b/app/assets/javascripts/ide/stores/mutations/merge_request.js
@@ -7,6 +7,8 @@ export default {
});
},
[types.SET_MERGE_REQUEST](state, { projectPath, mergeRequestId, mergeRequest }) {
+ const existingMergeRequest = state.projects[projectPath].mergeRequests[mergeRequestId] || {};
+
Object.assign(state.projects[projectPath], {
mergeRequests: {
[mergeRequestId]: {
@@ -15,6 +17,7 @@ export default {
changes: [],
versions: [],
baseCommitSha: null,
+ ...existingMergeRequest,
},
},
});
diff --git a/app/assets/javascripts/ide/stores/mutations/tree.js b/app/assets/javascripts/ide/stores/mutations/tree.js
index eac7441ee54..359943b4ab7 100644
--- a/app/assets/javascripts/ide/stores/mutations/tree.js
+++ b/app/assets/javascripts/ide/stores/mutations/tree.js
@@ -1,5 +1,5 @@
import * as types from '../mutation_types';
-import { sortTree } from '../utils';
+import { sortTree, mergeTrees } from '../utils';
export default {
[types.TOGGLE_TREE_OPEN](state, path) {
@@ -23,9 +23,15 @@ export default {
});
},
[types.SET_DIRECTORY_DATA](state, { data, treePath }) {
- Object.assign(state.trees[treePath], {
- tree: data,
- });
+ const selectedTree = state.trees[treePath];
+
+ // If we opened files while loading the tree, we need to merge them
+ // Otherwise, simply overwrite the tree
+ const tree = !selectedTree.tree.length
+ ? data
+ : selectedTree.loading && mergeTrees(selectedTree.tree, data);
+
+ Object.assign(selectedTree, { tree });
},
[types.SET_LAST_COMMIT_URL](state, { tree = state, url }) {
Object.assign(tree, {
diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js
index 0ede76fd1e0..bcc9ca60d9b 100644
--- a/app/assets/javascripts/ide/stores/utils.js
+++ b/app/assets/javascripts/ide/stores/utils.js
@@ -1,3 +1,5 @@
+import { commitActionTypes } from '../constants';
+
export const dataStructure = () => ({
id: '',
// Key will contain a mixture of ID and path
@@ -69,14 +71,15 @@ export const decorateData = entity => {
changed = false,
parentTreeUrl = '',
base64 = false,
+ binary = false,
+ rawPath = '',
previewMode,
file_lock,
html,
parentPath = '',
} = entity;
- return {
- ...dataStructure(),
+ return Object.assign(dataStructure(), {
id,
projectId,
branchId,
@@ -93,11 +96,13 @@ export const decorateData = entity => {
renderError,
content,
base64,
+ binary,
+ rawPath,
previewMode,
file_lock,
html,
parentPath,
- };
+ });
};
export const findEntry = (tree, type, name, prop = 'name') =>
@@ -111,14 +116,14 @@ export const setPageTitle = title => {
export const commitActionForFile = file => {
if (file.prevPath) {
- return 'move';
+ return commitActionTypes.move;
} else if (file.deleted) {
- return 'delete';
+ return commitActionTypes.delete;
} else if (file.tempFile) {
- return 'create';
+ return commitActionTypes.create;
}
- return 'update';
+ return commitActionTypes.update;
};
export const getCommitFiles = stagedFiles =>
@@ -171,3 +176,31 @@ export const filePathMatches = (filePath, path) => filePath.indexOf(`${path}/`)
export const getChangesCountForFiles = (files, path) =>
files.filter(f => filePathMatches(f.path, path)).length;
+
+export const mergeTrees = (fromTree, toTree) => {
+ if (!fromTree || !fromTree.length) {
+ return toTree;
+ }
+
+ const recurseTree = (n, t) => {
+ if (!n) {
+ return t;
+ }
+ const existingTreeNode = t.find(el => el.path === n.path);
+
+ if (existingTreeNode && n.tree.length > 0) {
+ existingTreeNode.opened = true;
+ recurseTree(n.tree[0], existingTreeNode.tree);
+ } else if (!existingTreeNode) {
+ const sorted = sortTree(t.concat(n));
+ t.splice(0, t.length + 1, ...sorted);
+ }
+ return t;
+ };
+
+ for (let i = 0, l = fromTree.length; i < l; i += 1) {
+ recurseTree(fromTree[i], toTree);
+ }
+
+ return toTree;
+};
diff --git a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js b/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
deleted file mode 100644
index fa35c215880..00000000000
--- a/app/assets/javascripts/ide/stores/workers/files_decorator_worker.js
+++ /dev/null
@@ -1,100 +0,0 @@
-import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
-import { decorateData, sortTree } from '../utils';
-
-// eslint-disable-next-line no-restricted-globals
-self.addEventListener('message', e => {
- const { data, projectId, branchId, tempFile = false, content = '', base64 = false } = e.data;
-
- const treeList = [];
- let file;
- let parentPath;
- const entries = data.reduce((acc, path) => {
- const pathSplit = path.split('/');
- const blobName = pathSplit.pop().trim();
-
- if (pathSplit.length > 0) {
- pathSplit.reduce((pathAcc, folderName) => {
- const parentFolder = acc[pathAcc[pathAcc.length - 1]];
- const folderPath = `${parentFolder ? `${parentFolder.path}/` : ''}${folderName}`;
- const foundEntry = acc[folderPath];
-
- if (!foundEntry) {
- parentPath = parentFolder ? parentFolder.path : null;
-
- const tree = decorateData({
- projectId,
- branchId,
- id: folderPath,
- name: folderName,
- path: folderPath,
- url: `/${projectId}/tree/${branchId}/-/${folderPath}/`,
- type: 'tree',
- parentTreeUrl: parentFolder ? parentFolder.url : `/${projectId}/tree/${branchId}/`,
- tempFile,
- changed: tempFile,
- opened: tempFile,
- parentPath,
- });
-
- Object.assign(acc, {
- [folderPath]: tree,
- });
-
- if (parentFolder) {
- parentFolder.tree.push(tree);
- } else {
- treeList.push(tree);
- }
-
- pathAcc.push(tree.path);
- } else {
- pathAcc.push(foundEntry.path);
- }
-
- return pathAcc;
- }, []);
- }
-
- if (blobName !== '') {
- const fileFolder = acc[pathSplit.join('/')];
- parentPath = fileFolder ? fileFolder.path : null;
-
- file = decorateData({
- projectId,
- branchId,
- id: path,
- name: blobName,
- path,
- url: `/${projectId}/blob/${branchId}/-/${path}`,
- type: 'blob',
- parentTreeUrl: fileFolder ? fileFolder.url : `/${projectId}/blob/${branchId}`,
- tempFile,
- changed: tempFile,
- content,
- base64,
- previewMode: viewerInformationForPath(blobName),
- parentPath,
- });
-
- Object.assign(acc, {
- [path]: file,
- });
-
- if (fileFolder) {
- fileFolder.tree.push(file);
- } else {
- treeList.push(file);
- }
- }
-
- return acc;
- }, {});
-
- // eslint-disable-next-line no-restricted-globals
- self.postMessage({
- entries,
- treeList: sortTree(treeList),
- file,
- parentPath,
- });
-});
diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue
new file mode 100644
index 00000000000..00eb0afb3bf
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue
@@ -0,0 +1,101 @@
+<script>
+import { mapActions, mapState, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import { __, sprintf } from '~/locale';
+import ImportedProjectTableRow from './imported_project_table_row.vue';
+import ProviderRepoTableRow from './provider_repo_table_row.vue';
+import eventHub from '../event_hub';
+
+export default {
+ name: 'ImportProjectsTable',
+ components: {
+ ImportedProjectTableRow,
+ ProviderRepoTableRow,
+ LoadingButton,
+ GlLoadingIcon,
+ },
+ props: {
+ providerTitle: {
+ type: String,
+ required: true,
+ },
+ },
+
+ computed: {
+ ...mapState(['importedProjects', 'providerRepos', 'isLoadingRepos']),
+ ...mapGetters(['isImportingAnyRepo', 'hasProviderRepos', 'hasImportedProjects']),
+
+ emptyStateText() {
+ return sprintf(__('No %{providerTitle} repositories available to import'), {
+ providerTitle: this.providerTitle,
+ });
+ },
+
+ fromHeaderText() {
+ return sprintf(__('From %{providerTitle}'), { providerTitle: this.providerTitle });
+ },
+ },
+
+ mounted() {
+ return this.fetchRepos();
+ },
+
+ beforeDestroy() {
+ this.stopJobsPolling();
+ this.clearJobsEtagPoll();
+ },
+
+ methods: {
+ ...mapActions(['fetchRepos', 'fetchJobs', 'stopJobsPolling', 'clearJobsEtagPoll']),
+
+ importAll() {
+ eventHub.$emit('importAll');
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div class="d-flex justify-content-between align-items-end flex-wrap mb-3">
+ <p class="light text-nowrap mt-2 my-sm-0">
+ {{ s__('ImportProjects|Select the projects you want to import') }}
+ </p>
+ <loading-button
+ container-class="btn btn-success js-import-all"
+ :loading="isImportingAnyRepo"
+ :label="__('Import all repositories')"
+ :disabled="!hasProviderRepos"
+ type="button"
+ @click="importAll"
+ />
+ </div>
+ <gl-loading-icon
+ v-if="isLoadingRepos"
+ class="js-loading-button-icon import-projects-loading-icon"
+ size="md"
+ />
+ <div v-else-if="hasProviderRepos || hasImportedProjects" class="table-responsive">
+ <table class="table import-table">
+ <thead>
+ <th class="import-jobs-from-col">{{ fromHeaderText }}</th>
+ <th class="import-jobs-to-col">{{ __('To GitLab') }}</th>
+ <th class="import-jobs-status-col">{{ __('Status') }}</th>
+ <th class="import-jobs-cta-col"></th>
+ </thead>
+ <tbody>
+ <imported-project-table-row
+ v-for="project in importedProjects"
+ :key="project.id"
+ :project="project"
+ />
+ <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" />
+ </tbody>
+ </table>
+ </div>
+ <div v-else class="text-center">
+ <strong>{{ emptyStateText }}</strong>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/import_projects/components/import_status.vue b/app/assets/javascripts/import_projects/components/import_status.vue
new file mode 100644
index 00000000000..9e3347a657f
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/import_status.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import STATUS_MAP from '../constants';
+
+export default {
+ name: 'ImportStatus',
+ components: {
+ CiIcon,
+ GlLoadingIcon,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+
+ computed: {
+ mappedStatus() {
+ return STATUS_MAP[this.status];
+ },
+
+ ciIconStatus() {
+ const { icon } = this.mappedStatus;
+
+ return {
+ icon: `status_${icon}`,
+ group: icon,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-loading-icon
+ v-if="mappedStatus.loadingIcon"
+ :inline="true"
+ :class="mappedStatus.textClass"
+ class="align-middle mr-2"
+ />
+ <ci-icon v-else css-classes="align-middle mr-2" :status="ciIconStatus" />
+ <span :class="mappedStatus.textClass">{{ mappedStatus.text }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
new file mode 100644
index 00000000000..ab2bd87ee9f
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue
@@ -0,0 +1,55 @@
+<script>
+import ImportStatus from './import_status.vue';
+import { STATUSES } from '../constants';
+
+export default {
+ name: 'ImportedProjectTableRow',
+ components: {
+ ImportStatus,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ computed: {
+ displayFullPath() {
+ return this.project.fullPath.replace(/^\//, '');
+ },
+
+ isFinished() {
+ return this.project.importStatus === STATUSES.FINISHED;
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="js-imported-project import-row">
+ <td>
+ <a
+ :href="project.providerLink"
+ rel="noreferrer noopener"
+ target="_blank"
+ class="js-provider-link"
+ >
+ {{ project.importSource }}
+ </a>
+ </td>
+ <td class="js-full-path">{{ displayFullPath }}</td>
+ <td><import-status :status="project.importStatus" /></td>
+ <td>
+ <a
+ v-if="isFinished"
+ class="btn btn-default js-go-to-project"
+ :href="project.fullPath"
+ rel="noreferrer noopener"
+ target="_blank"
+ >
+ {{ __('Go to project') }}
+ </a>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
new file mode 100644
index 00000000000..3c6c9c71b8c
--- /dev/null
+++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue
@@ -0,0 +1,110 @@
+<script>
+import { mapState, mapGetters, mapActions } from 'vuex';
+import Select2Select from '~/vue_shared/components/select2_select.vue';
+import { __ } from '~/locale';
+import LoadingButton from '~/vue_shared/components/loading_button.vue';
+import eventHub from '../event_hub';
+import { STATUSES } from '../constants';
+import ImportStatus from './import_status.vue';
+
+export default {
+ name: 'ProviderRepoTableRow',
+ components: {
+ Select2Select,
+ LoadingButton,
+ ImportStatus,
+ },
+ props: {
+ repo: {
+ type: Object,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ targetNamespace: this.$store.state.defaultTargetNamespace,
+ newName: this.repo.sanitizedName,
+ };
+ },
+
+ computed: {
+ ...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']),
+
+ ...mapGetters(['namespaceSelectOptions']),
+
+ importButtonText() {
+ return this.ciCdOnly ? __('Connect') : __('Import');
+ },
+
+ select2Options() {
+ return {
+ data: this.namespaceSelectOptions,
+ containerCssClass:
+ 'import-namespace-select js-namespace-select qa-project-namespace-select w-auto',
+ };
+ },
+
+ isLoadingImport() {
+ return this.reposBeingImported.includes(this.repo.id);
+ },
+
+ status() {
+ return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE;
+ },
+ },
+
+ created() {
+ eventHub.$on('importAll', () => this.importRepo());
+ },
+
+ methods: {
+ ...mapActions(['fetchImport']),
+
+ importRepo() {
+ return this.fetchImport({
+ newName: this.newName,
+ targetNamespace: this.targetNamespace,
+ repo: this.repo,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="qa-project-import-row js-provider-repo import-row">
+ <td>
+ <a
+ :href="repo.providerLink"
+ rel="noreferrer noopener"
+ target="_blank"
+ class="js-provider-link"
+ >
+ {{ repo.fullName }}
+ </a>
+ </td>
+ <td class="d-flex flex-wrap flex-lg-nowrap">
+ <select2-select v-model="targetNamespace" :options="select2Options" />
+ <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center"
+ >/</span
+ >
+ <input
+ v-model="newName"
+ type="text"
+ class="form-control import-project-name-input js-new-name qa-project-path-field"
+ />
+ </td>
+ <td><import-status :status="status" /></td>
+ <td>
+ <button
+ v-if="!isLoadingImport"
+ type="button"
+ class="qa-import-button js-import-button btn btn-default"
+ @click="importRepo"
+ >
+ {{ importButtonText }}
+ </button>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/import_projects/constants.js b/app/assets/javascripts/import_projects/constants.js
new file mode 100644
index 00000000000..ad33ca158d2
--- /dev/null
+++ b/app/assets/javascripts/import_projects/constants.js
@@ -0,0 +1,48 @@
+import { __ } from '../locale';
+
+// The `scheduling` status is only present on the client-side,
+// it is used as the status when we are requesting to start an import.
+
+export const STATUSES = {
+ FINISHED: 'finished',
+ FAILED: 'failed',
+ SCHEDULED: 'scheduled',
+ STARTED: 'started',
+ NONE: 'none',
+ SCHEDULING: 'scheduling',
+};
+
+const STATUS_MAP = {
+ [STATUSES.FINISHED]: {
+ icon: 'success',
+ text: __('Done'),
+ textClass: 'text-success',
+ },
+ [STATUSES.FAILED]: {
+ icon: 'failed',
+ text: __('Failed'),
+ textClass: 'text-danger',
+ },
+ [STATUSES.SCHEDULED]: {
+ icon: 'pending',
+ text: __('Scheduled'),
+ textClass: 'text-warning',
+ },
+ [STATUSES.STARTED]: {
+ icon: 'running',
+ text: __('Running…'),
+ textClass: 'text-info',
+ },
+ [STATUSES.NONE]: {
+ icon: 'created',
+ text: __('Not started'),
+ textClass: 'text-muted',
+ },
+ [STATUSES.SCHEDULING]: {
+ loadingIcon: true,
+ text: __('Scheduling'),
+ textClass: 'text-warning',
+ },
+};
+
+export default STATUS_MAP;
diff --git a/app/assets/javascripts/monitoring/event_hub.js b/app/assets/javascripts/import_projects/event_hub.js
index 0948c2e5352..0948c2e5352 100644
--- a/app/assets/javascripts/monitoring/event_hub.js
+++ b/app/assets/javascripts/import_projects/event_hub.js
diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js
new file mode 100644
index 00000000000..2d99d716609
--- /dev/null
+++ b/app/assets/javascripts/import_projects/index.js
@@ -0,0 +1,48 @@
+import Vue from 'vue';
+import { mapActions } from 'vuex';
+import Translate from '../vue_shared/translate';
+import ImportProjectsTable from './components/import_projects_table.vue';
+import { parseBoolean } from '../lib/utils/common_utils';
+import createStore from './store';
+
+Vue.use(Translate);
+
+export default function mountImportProjectsTable(mountElement) {
+ if (!mountElement) return undefined;
+
+ const {
+ reposPath,
+ provider,
+ providerTitle,
+ canSelectNamespace,
+ jobsPath,
+ importPath,
+ ciCdOnly,
+ } = mountElement.dataset;
+
+ const store = createStore();
+ return new Vue({
+ el: mountElement,
+ store,
+
+ created() {
+ this.setInitialData({
+ reposPath,
+ provider,
+ jobsPath,
+ importPath,
+ defaultTargetNamespace: gon.current_username,
+ ciCdOnly: parseBoolean(ciCdOnly),
+ canSelectNamespace: parseBoolean(canSelectNamespace),
+ });
+ },
+
+ methods: {
+ ...mapActions(['setInitialData']),
+ },
+
+ render(createElement) {
+ return createElement(ImportProjectsTable, { props: { providerTitle } });
+ },
+ });
+}
diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js
new file mode 100644
index 00000000000..c44500937cc
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/actions.js
@@ -0,0 +1,106 @@
+import Visibility from 'visibilityjs';
+import * as types from './mutation_types';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import Poll from '~/lib/utils/poll';
+import createFlash from '~/flash';
+import { s__, sprintf } from '~/locale';
+import axios from '~/lib/utils/axios_utils';
+
+let eTagPoll;
+
+export const clearJobsEtagPoll = () => {
+ eTagPoll = null;
+};
+export const stopJobsPolling = () => {
+ if (eTagPoll) eTagPoll.stop();
+};
+export const restartJobsPolling = () => {
+ if (eTagPoll) eTagPoll.restart();
+};
+
+export const setInitialData = ({ commit }, data) => commit(types.SET_INITIAL_DATA, data);
+
+export const requestRepos = ({ commit }, repos) => commit(types.REQUEST_REPOS, repos);
+export const receiveReposSuccess = ({ commit }, repos) =>
+ commit(types.RECEIVE_REPOS_SUCCESS, repos);
+export const receiveReposError = ({ commit }) => commit(types.RECEIVE_REPOS_ERROR);
+export const fetchRepos = ({ state, dispatch }) => {
+ dispatch('requestRepos');
+
+ return axios
+ .get(state.reposPath)
+ .then(({ data }) =>
+ dispatch('receiveReposSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
+ )
+ .then(() => dispatch('fetchJobs'))
+ .catch(() => {
+ createFlash(
+ sprintf(s__('ImportProjects|Requesting your %{provider} repositories failed'), {
+ provider: state.provider,
+ }),
+ );
+
+ dispatch('receiveReposError');
+ });
+};
+
+export const requestImport = ({ commit, state }, repoId) => {
+ if (!state.reposBeingImported.includes(repoId)) commit(types.REQUEST_IMPORT, repoId);
+};
+export const receiveImportSuccess = ({ commit }, { importedProject, repoId }) =>
+ commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject, repoId });
+export const receiveImportError = ({ commit }, repoId) =>
+ commit(types.RECEIVE_IMPORT_ERROR, repoId);
+export const fetchImport = ({ state, dispatch }, { newName, targetNamespace, repo }) => {
+ dispatch('requestImport', repo.id);
+
+ return axios
+ .post(state.importPath, {
+ ci_cd_only: state.ciCdOnly,
+ new_name: newName,
+ repo_id: repo.id,
+ target_namespace: targetNamespace,
+ })
+ .then(({ data }) =>
+ dispatch('receiveImportSuccess', {
+ importedProject: convertObjectPropsToCamelCase(data, { deep: true }),
+ repoId: repo.id,
+ }),
+ )
+ .catch(() => {
+ createFlash(s__('ImportProjects|Importing the project failed'));
+
+ dispatch('receiveImportError', { repoId: repo.id });
+ });
+};
+
+export const receiveJobsSuccess = ({ commit }, updatedProjects) =>
+ commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects);
+export const fetchJobs = ({ state, dispatch }) => {
+ if (eTagPoll) return;
+
+ eTagPoll = new Poll({
+ resource: {
+ fetchJobs: () => axios.get(state.jobsPath),
+ },
+ method: 'fetchJobs',
+ successCallback: ({ data }) =>
+ dispatch('receiveJobsSuccess', convertObjectPropsToCamelCase(data, { deep: true })),
+ errorCallback: () => createFlash(s__('ImportProjects|Updating the imported projects failed')),
+ });
+
+ if (!Visibility.hidden()) {
+ eTagPoll.makeRequest();
+ }
+
+ Visibility.change(() => {
+ if (!Visibility.hidden()) {
+ dispatch('restartJobsPolling');
+ } else {
+ dispatch('stopJobsPolling');
+ }
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js
new file mode 100644
index 00000000000..727b80765bd
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/getters.js
@@ -0,0 +1,22 @@
+import { __ } from '~/locale';
+
+export const namespaceSelectOptions = state => {
+ const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({
+ id: fullPath,
+ text: fullPath,
+ }));
+
+ return [
+ { text: __('Groups'), children: serializedNamespaces },
+ {
+ text: __('Users'),
+ children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }],
+ },
+ ];
+};
+
+export const isImportingAnyRepo = state => state.reposBeingImported.length > 0;
+
+export const hasProviderRepos = state => state.providerRepos.length > 0;
+
+export const hasImportedProjects = state => state.importedProjects.length > 0;
diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js
new file mode 100644
index 00000000000..ff1fd1e598e
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export { state, actions, getters, mutations };
+
+export default () =>
+ new Vuex.Store({
+ state: state(),
+ actions,
+ mutations,
+ getters,
+ });
diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js
new file mode 100644
index 00000000000..6ba3fd6f29e
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/mutation_types.js
@@ -0,0 +1,11 @@
+export const SET_INITIAL_DATA = 'SET_INITIAL_DATA';
+
+export const REQUEST_REPOS = 'REQUEST_REPOS';
+export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS';
+export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR';
+
+export const REQUEST_IMPORT = 'REQUEST_IMPORT';
+export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS';
+export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR';
+
+export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js
new file mode 100644
index 00000000000..b88de0268e7
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/mutations.js
@@ -0,0 +1,55 @@
+import Vue from 'vue';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_DATA](state, data) {
+ Object.assign(state, data);
+ },
+
+ [types.REQUEST_REPOS](state) {
+ state.isLoadingRepos = true;
+ },
+
+ [types.RECEIVE_REPOS_SUCCESS](state, { importedProjects, providerRepos, namespaces }) {
+ state.isLoadingRepos = false;
+
+ state.importedProjects = importedProjects;
+ state.providerRepos = providerRepos;
+ state.namespaces = namespaces;
+ },
+
+ [types.RECEIVE_REPOS_ERROR](state) {
+ state.isLoadingRepos = false;
+ },
+
+ [types.REQUEST_IMPORT](state, repoId) {
+ state.reposBeingImported.push(repoId);
+ },
+
+ [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) {
+ const existingRepoIndex = state.reposBeingImported.indexOf(repoId);
+ if (state.reposBeingImported.includes(repoId))
+ state.reposBeingImported.splice(existingRepoIndex, 1);
+
+ const providerRepoIndex = state.providerRepos.findIndex(
+ providerRepo => providerRepo.id === repoId,
+ );
+ state.providerRepos.splice(providerRepoIndex, 1);
+ state.importedProjects.unshift(importedProject);
+ },
+
+ [types.RECEIVE_IMPORT_ERROR](state, repoId) {
+ const repoIndex = state.reposBeingImported.indexOf(repoId);
+ if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1);
+ },
+
+ [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) {
+ updatedProjects.forEach(updatedProject => {
+ const existingProject = state.importedProjects.find(
+ importedProject => importedProject.id === updatedProject.id,
+ );
+
+ Vue.set(existingProject, 'importStatus', updatedProject.importStatus);
+ });
+ },
+};
diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js
new file mode 100644
index 00000000000..637fef6e53c
--- /dev/null
+++ b/app/assets/javascripts/import_projects/store/state.js
@@ -0,0 +1,15 @@
+export default () => ({
+ reposPath: '',
+ importPath: '',
+ jobsPath: '',
+ currentProjectId: '',
+ provider: '',
+ currentUsername: '',
+ importedProjects: [],
+ providerRepos: [],
+ namespaces: [],
+ reposBeingImported: [],
+ isLoadingRepos: false,
+ canSelectNamespace: false,
+ ciCdOnly: false,
+});
diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js
index 08b858305ab..a7746bb3a0b 100644
--- a/app/assets/javascripts/integrations/integration_settings_form.js
+++ b/app/assets/javascripts/integrations/integration_settings_form.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import axios from '../lib/utils/axios_utils';
import flash from '../flash';
+import { __ } from '~/locale';
export default class IntegrationSettingsForm {
constructor(formSelector) {
@@ -65,10 +66,10 @@ export default class IntegrationSettingsForm {
* Toggle Submit button label based on Integration status and ability to test service
*/
toggleSubmitBtnLabel(serviceActive) {
- let btnLabel = 'Save changes';
+ let btnLabel = __('Save changes');
if (serviceActive && this.canTestService) {
- btnLabel = 'Test settings and save changes';
+ btnLabel = __('Test settings and save changes');
}
this.$submitBtnLabel.text(btnLabel);
@@ -105,7 +106,7 @@ export default class IntegrationSettingsForm {
if (data.test_failed) {
flashActions = {
- title: 'Save anyway',
+ title: __('Save anyway'),
clickHandler: e => {
e.preventDefault();
this.$form.submit();
@@ -121,7 +122,7 @@ export default class IntegrationSettingsForm {
this.toggleSubmitBtnState(false);
})
.catch(() => {
- flash('Something went wrong on our end.');
+ flash(__('Something went wrong on our end.'));
this.toggleSubmitBtnState(false);
});
}
diff --git a/app/assets/javascripts/issuable/auto_width_dropdown_select.js b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
index 612c524ca1c..e0fb58ef195 100644
--- a/app/assets/javascripts/issuable/auto_width_dropdown_select.js
+++ b/app/assets/javascripts/issuable/auto_width_dropdown_select.js
@@ -11,10 +11,14 @@ class AutoWidthDropdownSelect {
init() {
const { dropdownClass } = this;
- this.$selectElement.select2({
- dropdownCssClass: dropdownClass,
- ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
- });
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ this.$selectElement.select2({
+ dropdownCssClass: dropdownClass,
+ ...AutoWidthDropdownSelect.selectOptions(this.dropdownClass),
+ });
+ })
+ .catch(() => {});
return this;
}
diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js
index b844e4c5e5b..bc9d7fcf30d 100644
--- a/app/assets/javascripts/issuable_bulk_update_actions.js
+++ b/app/assets/javascripts/issuable_bulk_update_actions.js
@@ -4,6 +4,7 @@ import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
+import { __ } from './locale';
export default {
init({ container, form, issues, prefixId } = {}) {
@@ -32,7 +33,7 @@ export default {
onFormSubmitFailure() {
this.form.find('[type="submit"]').enable();
- return new Flash('Issue update failed');
+ return new Flash(__('Issue update failed'));
},
getSelectedIssues() {
@@ -81,9 +82,6 @@ export default {
const formData = {
update: {
state_event: this.form.find('input[name="update[state_event]"]').val(),
- // For Merge Requests
- assignee_id: this.form.find('input[name="update[assignee_id]"]').val(),
- // For Issues
assignee_ids: [this.form.find('input[name="update[assignee_ids][]"]').val()],
milestone_id: this.form.find('input[name="update[milestone_id]"]').val(),
issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(),
diff --git a/app/assets/javascripts/issuable_context.js b/app/assets/javascripts/issuable_context.js
index f3d722409b0..48e7ed1318d 100644
--- a/app/assets/javascripts/issuable_context.js
+++ b/app/assets/javascripts/issuable_context.js
@@ -7,10 +7,14 @@ export default class IssuableContext {
constructor(currentUser) {
this.userSelect = new UsersSelect(currentUser);
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+ })
+ .catch(() => {});
$('.issuable-sidebar .inline-update').on('change', 'select', function onClickSelect() {
return $(this).submit();
diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js
index c81a2230310..7576d36f27d 100644
--- a/app/assets/javascripts/issuable_form.js
+++ b/app/assets/javascripts/issuable_form.js
@@ -1,8 +1,8 @@
import $ from 'jquery';
import Pikaday from 'pikaday';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import Autosave from './autosave';
import UsersSelect from './users_select';
-import GfmAutoComplete from './gfm_auto_complete';
import ZenMode from './zen_mode';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
import { parsePikadayDate, pikadayToString } from './lib/utils/datetime_utility';
@@ -44,6 +44,7 @@ export default class IssuableForm {
parse: dateString => parsePikadayDate(dateString),
toString: date => pikadayToString(date),
onSelect: dateText => $issuableDueDate.val(calendar.toString(dateText)),
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($issuableDueDate.val()));
}
@@ -120,35 +121,39 @@ export default class IssuableForm {
}
initTargetBranchDropdown() {
- this.$targetBranchSelect.select2({
- ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
- ajax: {
- url: this.$targetBranchSelect.data('endpoint'),
- dataType: 'JSON',
- quietMillis: 250,
- data(search) {
- return {
- search,
- };
- },
- results(data) {
- return {
- // `data` keys are translated so we can't just access them with a string based key
- results: data[Object.keys(data)[0]].map(name => ({
- id: name,
- text: name,
- })),
- };
- },
- },
- initSelection(el, callback) {
- const val = el.val();
-
- callback({
- id: val,
- text: val,
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ this.$targetBranchSelect.select2({
+ ...AutoWidthDropdownSelect.selectOptions('js-target-branch-select'),
+ ajax: {
+ url: this.$targetBranchSelect.data('endpoint'),
+ dataType: 'JSON',
+ quietMillis: 250,
+ data(search) {
+ return {
+ search,
+ };
+ },
+ results(data) {
+ return {
+ // `data` keys are translated so we can't just access them with a string based key
+ results: data[Object.keys(data)[0]].map(name => ({
+ id: name,
+ text: name,
+ })),
+ };
+ },
+ },
+ initSelection(el, callback) {
+ const val = el.val();
+
+ callback({
+ id: val,
+ text: val,
+ });
+ },
});
- },
- });
+ })
+ .catch(() => {});
}
}
diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js
index ffcbd7cf28c..f51c7a2f990 100644
--- a/app/assets/javascripts/issuable_index.js
+++ b/app/assets/javascripts/issuable_index.js
@@ -1,7 +1,7 @@
import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
-import { __ } from './locale';
+import { s__, __ } from './locale';
import IssuableBulkUpdateSidebar from './issuable_bulk_update_sidebar';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
@@ -29,7 +29,7 @@ export default class IssuableIndex {
$resetToken.on('click', e => {
e.preventDefault();
- $resetToken.text('resetting...');
+ $resetToken.text(s__('EmailToken|resetting...'));
axios
.put($resetToken.attr('href'))
@@ -38,12 +38,12 @@ export default class IssuableIndex {
.val(data.new_address)
.focus();
- $resetToken.text('reset it');
+ $resetToken.text(s__('EmailToken|reset it'));
})
.catch(() => {
flash(__('There was an error when reseting email token.'));
- $resetToken.text('reset it');
+ $resetToken.text(s__('EmailToken|reset it'));
});
});
}
diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js
index 2c80cf1797a..40916c9d27f 100644
--- a/app/assets/javascripts/issuable_suggestions/index.js
+++ b/app/assets/javascripts/issuable_suggestions/index.js
@@ -1,6 +1,6 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import defaultClient from '~/lib/graphql';
+import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
Vue.use(VueApollo);
@@ -10,7 +10,7 @@ export default function() {
const issueTitle = document.getElementById('issue_title');
const { projectPath } = el.dataset;
const apolloProvider = new VueApollo({
- defaultClient,
+ defaultClient: createDefaultClient(),
});
return new Vue({
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 94b78907d9a..db4607ca58d 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -7,6 +7,7 @@ import flash from './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import IssuablesHelper from './helpers/issuables_helper';
+import { __ } from './locale';
export default class Issue {
constructor() {
@@ -15,8 +16,9 @@ export default class Issue {
Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
- Issue.initMergeRequests();
- Issue.initRelatedBranches();
+ if (document.querySelector('#related-branches')) {
+ Issue.initRelatedBranches();
+ }
this.closeButtons = $('a.btn-close');
this.reopenButtons = $('a.btn-reopen');
@@ -43,7 +45,11 @@ export default class Issue {
* @param {Array} data
* @param {String} issueFailMessage
*/
- updateTopState(isClosed, data, issueFailMessage = 'Unable to update this issue at this time.') {
+ updateTopState(
+ isClosed,
+ data,
+ issueFailMessage = __('Unable to update this issue at this time.'),
+ ) {
if ('id' in data) {
const isClosedBadge = $('div.status-box-issue-closed');
const isOpenBadge = $('div.status-box-open');
@@ -80,7 +86,7 @@ export default class Issue {
}
initIssueBtnEventListeners() {
- const issueFailMessage = 'Unable to update this issue at this time.';
+ const issueFailMessage = __('Unable to update this issue at this time.');
return $(document).on(
'click',
@@ -141,19 +147,6 @@ export default class Issue {
}
}
- static initMergeRequests() {
- var $container;
- $container = $('#merge-requests');
- return axios
- .get($container.data('url'))
- .then(({ data }) => {
- if ('html' in data) {
- $container.html(data.html);
- }
- })
- .catch(() => flash('Failed to load referenced merge requests'));
- }
-
static initRelatedBranches() {
var $container;
$container = $('#related-branches');
@@ -164,6 +157,6 @@ export default class Issue {
$container.html(data.html);
}
})
- .catch(() => flash('Failed to load related branches'));
+ .catch(() => flash(__('Failed to load related branches')));
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index e4e2eab2acd..ab0b4231255 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -1,5 +1,7 @@
<script>
import Visibility from 'visibilityjs';
+import { __, s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
import { visitUrl } from '../../lib/utils/url_utility';
import Poll from '../../lib/utils/poll';
import eventHub from '../event_hub';
@@ -106,11 +108,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -129,6 +126,11 @@ export default {
required: false,
default: true,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
const store = new Store({
@@ -140,6 +142,7 @@ export default {
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
taskStatus: this.initialTaskStatus,
+ lock_version: this.lockVersion,
});
return {
@@ -156,9 +159,26 @@ export default {
return !!this.state.updatedAt;
},
issueChanged() {
- const descriptionChanged = this.initialDescriptionText !== this.store.formState.description;
- const titleChanged = this.initialTitleText !== this.store.formState.title;
- return descriptionChanged || titleChanged;
+ const {
+ store: {
+ formState: { description, title },
+ },
+ initialDescriptionText,
+ initialTitleText,
+ } = this;
+
+ if (initialDescriptionText || description) {
+ return initialDescriptionText !== description;
+ }
+
+ if (initialTitleText || title) {
+ return initialTitleText !== title;
+ }
+
+ return false;
+ },
+ defaultErrorMessage() {
+ return sprintf(s__('Error updating %{issuableType}'), { issuableType: this.issuableType });
},
},
created() {
@@ -201,11 +221,22 @@ export default {
methods: {
handleBeforeUnloadEvent(e) {
const event = e;
- if (this.showForm && this.issueChanged) {
- event.returnValue = 'Are you sure you want to lose your issue information?';
+ if (this.showForm && this.issueChanged && !this.showRecaptcha) {
+ event.returnValue = __('Are you sure you want to lose your issue information?');
}
return undefined;
},
+ updateStoreState() {
+ return this.service
+ .getData()
+ .then(res => res.data)
+ .then(data => {
+ this.store.updateState(data);
+ })
+ .catch(() => {
+ createFlash(this.defaultErrorMessage);
+ });
+ },
openForm() {
if (!this.showForm) {
@@ -213,6 +244,7 @@ export default {
this.store.setFormState({
title: this.state.titleText,
description: this.state.descriptionText,
+ lock_version: this.state.lock_version,
lockedWarningVisible: false,
updateLoading: false,
});
@@ -231,20 +263,24 @@ export default {
if (window.location.pathname !== data.web_url) {
visitUrl(data.web_url);
}
-
- return this.service.getData();
})
- .then(res => res.data)
- .then(data => {
- this.store.updateState(data);
+ .then(this.updateStoreState)
+ .then(() => {
eventHub.$emit('close.form');
})
- .catch(error => {
- if (error && error.name === 'SpamError') {
+ .catch((error = {}) => {
+ const { name, response = {} } = error;
+
+ if (name === 'SpamError') {
this.openRecaptcha();
} else {
- eventHub.$emit('close.form');
- window.Flash(`Error updating ${this.issuableType}`);
+ let errMsg = this.defaultErrorMessage;
+
+ if (response.data && response.data.errors) {
+ errMsg += `. ${response.data.errors.join(' ')}`;
+ }
+
+ createFlash(errMsg);
}
});
},
@@ -268,8 +304,9 @@ export default {
visitUrl(data.web_url);
})
.catch(() => {
- eventHub.$emit('close.form');
- window.Flash(`Error deleting ${this.issuableType}`);
+ createFlash(
+ sprintf(s__('Error deleting %{issuableType}'), { issuableType: this.issuableType }),
+ );
});
},
},
@@ -285,7 +322,6 @@ export default {
:issuable-templates="issuableTemplates"
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
- :markdown-version="markdownVersion"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
@@ -313,6 +349,8 @@ export default {
:task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
+ :lock-version="state.lock_version"
+ @taskListUpdateFailed="updateStoreState"
/>
<edited-component
v-if="hasUpdated"
diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue
index 5ca88d75063..f2462e50093 100644
--- a/app/assets/javascripts/issue_show/components/description.vue
+++ b/app/assets/javascripts/issue_show/components/description.vue
@@ -1,5 +1,7 @@
<script>
import $ from 'jquery';
+import { s__, sprintf } from '~/locale';
+import createFlash from '~/flash';
import animateMixin from '../mixins/animate';
import TaskList from '../../task_list';
import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor';
@@ -35,6 +37,11 @@ export default {
required: false,
default: null,
},
+ lockVersion: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
data() {
return {
@@ -67,8 +74,10 @@ export default {
new TaskList({
dataType: this.issuableType,
fieldName: 'description',
+ lockVersion: this.lockVersion,
selector: '.detail-page-description',
onSuccess: this.taskListUpdateSuccess.bind(this),
+ onError: this.taskListUpdateError.bind(this),
});
}
},
@@ -82,6 +91,21 @@ export default {
}
},
+ taskListUpdateError() {
+ createFlash(
+ sprintf(
+ s__(
+ 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.',
+ ),
+ {
+ issueType: this.issuableType,
+ },
+ ),
+ );
+
+ this.$emit('taskListUpdateFailed');
+ },
+
updateTaskStatusText() {
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
const $issuableHeader = $('.issuable-meta');
@@ -116,14 +140,16 @@ export default {
'issue-realtime-pre-pulse': preAnimation,
'issue-realtime-trigger-pulse': pulseAnimation,
}"
- class="wiki"
+ class="md"
v-html="descriptionHtml"
></div>
<textarea
v-if="descriptionText"
+ ref="textarea"
v-model="descriptionText"
:data-update-url="updateUrl"
class="hidden js-task-list-field"
+ dir="auto"
>
</textarea>
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 90258c0e044..d27dd873125 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -20,11 +20,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
canAttachFile: {
type: Boolean,
required: false,
@@ -48,7 +43,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
>
@@ -59,6 +53,7 @@ export default {
v-model="formState.description"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
+ dir="auto"
data-supports-quick-actions="false"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
diff --git a/app/assets/javascripts/issue_show/components/fields/title.vue b/app/assets/javascripts/issue_show/components/fields/title.vue
index c3d7ba4907f..ce4baf17d09 100644
--- a/app/assets/javascripts/issue_show/components/fields/title.vue
+++ b/app/assets/javascripts/issue_show/components/fields/title.vue
@@ -17,8 +17,10 @@ export default {
<label class="sr-only" for="issuable-title"> Title </label>
<input
id="issuable-title"
+ ref="input"
v-model="formState.title"
class="form-control qa-title-input"
+ dir="auto"
type="text"
placeholder="Title"
aria-label="Title"
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 45b60bc3392..528ccb77efc 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -1,9 +1,12 @@
<script>
+import $ from 'jquery';
import lockedWarning from './locked_warning.vue';
import titleField from './fields/title.vue';
import descriptionField from './fields/description.vue';
import editActions from './edit_actions.vue';
import descriptionTemplate from './fields/description_template.vue';
+import Autosave from '~/autosave';
+import eventHub from '../event_hub';
export default {
components: {
@@ -39,11 +42,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
projectPath: {
type: String,
required: true,
@@ -73,6 +71,47 @@ export default {
return this.issuableTemplates.length;
},
},
+ created() {
+ eventHub.$on('delete.issuable', this.resetAutosave);
+ eventHub.$on('update.issuable', this.resetAutosave);
+ eventHub.$on('close.form', this.resetAutosave);
+ },
+ mounted() {
+ this.initAutosave();
+ },
+ beforeDestroy() {
+ eventHub.$off('delete.issuable', this.resetAutosave);
+ eventHub.$off('update.issuable', this.resetAutosave);
+ eventHub.$off('close.form', this.resetAutosave);
+ },
+ methods: {
+ initAutosave() {
+ const {
+ description: {
+ $refs: { textarea },
+ },
+ title: {
+ $refs: { input },
+ },
+ } = this.$refs;
+
+ this.autosaveDescription = new Autosave($(textarea), [
+ document.location.pathname,
+ document.location.search,
+ 'description',
+ ]);
+
+ this.autosaveTitle = new Autosave($(input), [
+ document.location.pathname,
+ document.location.search,
+ 'title',
+ ]);
+ },
+ resetAutosave() {
+ this.autosaveDescription.reset();
+ this.autosaveTitle.reset();
+ },
+ },
};
</script>
@@ -94,14 +133,14 @@ export default {
'col-12': !hasIssuableTemplates,
}"
>
- <title-field :form-state="formState" :issuable-templates="issuableTemplates" />
+ <title-field ref="title" :form-state="formState" :issuable-templates="issuableTemplates" />
</div>
</div>
<description-field
+ ref="description"
:form-state="formState"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:can-attach-file="canAttachFile"
:enable-autocomplete="enableAutocomplete"
/>
diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue
index 3b5c95ccded..d2f33dc31a7 100644
--- a/app/assets/javascripts/issue_show/components/title.vue
+++ b/app/assets/javascripts/issue_show/components/title.vue
@@ -72,6 +72,7 @@ export default {
'issue-realtime-trigger-pulse': pulseAnimation,
}"
class="title"
+ dir="auto"
v-html="titleHtml"
></h2>
<button
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index d08e8ba0c4b..529b6386221 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -1,12 +1,9 @@
import Vue from 'vue';
-import sanitize from 'sanitize-html';
import issuableApp from './components/app.vue';
+import { parseIssuableData } from './utils/parse_data';
import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() {
- const initialDataEl = document.getElementById('js-issuable-app-initial-data');
- const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
-
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
@@ -14,7 +11,7 @@ export default function initIssueableApp() {
},
render(createElement) {
return createElement('issuable-app', {
- props,
+ props: parseIssuableData(),
});
},
});
diff --git a/app/assets/javascripts/issue_show/stores/index.js b/app/assets/javascripts/issue_show/stores/index.js
index 32044d6da25..3c17e73ccec 100644
--- a/app/assets/javascripts/issue_show/stores/index.js
+++ b/app/assets/javascripts/issue_show/stores/index.js
@@ -1,3 +1,5 @@
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
export default class Store {
constructor(initialState) {
this.state = initialState;
@@ -6,6 +8,7 @@ export default class Store {
description: '',
lockedWarningVisible: false,
updateLoading: false,
+ lock_version: 0,
};
}
@@ -14,14 +17,10 @@ export default class Store {
this.formState.lockedWarningVisible = true;
}
+ Object.assign(this.state, convertObjectPropsToCamelCase(data));
this.state.titleHtml = data.title;
- this.state.titleText = data.title_text;
this.state.descriptionHtml = data.description;
- this.state.descriptionText = data.description_text;
- this.state.taskStatus = data.task_status;
- this.state.updatedAt = data.updated_at;
- this.state.updatedByName = data.updated_by_name;
- this.state.updatedByPath = data.updated_by_path;
+ this.state.lock_version = data.lock_version;
}
stateShouldUpdate(data) {
diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js
new file mode 100644
index 00000000000..05e384adad3
--- /dev/null
+++ b/app/assets/javascripts/issue_show/utils/parse_data.js
@@ -0,0 +1,15 @@
+import sanitize from 'sanitize-html';
+
+export const parseIssuableData = () => {
+ try {
+ const initialDataEl = document.getElementById('js-issuable-app-initial-data');
+
+ return JSON.parse(sanitize(initialDataEl.textContent).replace(/&quot;/g, '"'));
+ } catch (e) {
+ console.error(e); // eslint-disable-line no-console
+
+ return {};
+ }
+};
+
+export default {};
diff --git a/app/assets/javascripts/issue_status_select.js b/app/assets/javascripts/issue_status_select.js
index c14803c80e7..75edff41a89 100644
--- a/app/assets/javascripts/issue_status_select.js
+++ b/app/assets/javascripts/issue_status_select.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from './locale';
export default function issueStatusSelect() {
$('.js-issue-status').each((i, el) => {
@@ -7,7 +8,7 @@ export default function issueStatusSelect() {
selectable: true,
fieldName,
toggleLabel(selected, element, instance) {
- let label = 'Author';
+ let label = __('Author');
const $item = instance.dropdown.find('.is-active');
if ($item.length) {
label = $item.text();
diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue
index 309b7427b9e..0bce860df91 100644
--- a/app/assets/javascripts/jobs/components/artifacts_block.vue
+++ b/app/assets/javascripts/jobs/components/artifacts_block.vue
@@ -28,27 +28,29 @@ export default {
</script>
<template>
<div class="block">
- <div class="title">{{ s__('Job|Job artifacts') }}</div>
+ <div class="title font-weight-bold">{{ s__('Job|Job artifacts') }}</div>
- <p v-if="isExpired" class="js-artifacts-removed build-detail-row">
- {{ s__('Job|The artifacts were removed') }}
+ <p
+ v-if="isExpired || willExpire"
+ :class="{
+ 'js-artifacts-removed': isExpired,
+ 'js-artifacts-will-be-removed': willExpire,
+ }"
+ class="build-detail-row"
+ >
+ <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
+ <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</span>
+ <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
</p>
- <p v-else-if="willExpire" class="js-artifacts-will-be-removed build-detail-row">
- {{ s__('Job|The artifacts will be removed in') }}
- </p>
-
- <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" />
-
- <div class="btn-group d-flex" role="group">
+ <div class="btn-group d-flex prepend-top-10" role="group">
<gl-link
v-if="artifact.keep_path"
:href="artifact.keep_path"
class="js-keep-artifacts btn btn-sm btn-default"
data-method="post"
+ >{{ s__('Job|Keep') }}</gl-link
>
- {{ s__('Job|Keep') }}
- </gl-link>
<gl-link
v-if="artifact.download_path"
@@ -56,17 +58,15 @@ export default {
class="js-download-artifacts btn btn-sm btn-default"
download
rel="nofollow"
+ >{{ s__('Job|Download') }}</gl-link
>
- {{ s__('Job|Download') }}
- </gl-link>
<gl-link
v-if="artifact.browse_path"
:href="artifact.browse_path"
class="js-browse-artifacts btn btn-sm btn-default"
+ >{{ s__('Job|Browse') }}</gl-link
>
- {{ s__('Job|Browse') }}
- </gl-link>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/commit_block.vue b/app/assets/javascripts/jobs/components/commit_block.vue
index 3b9c61bd48c..b651a6e4bfb 100644
--- a/app/assets/javascripts/jobs/components/commit_block.vue
+++ b/app/assets/javascripts/jobs/components/commit_block.vue
@@ -31,24 +31,27 @@ export default {
block: !isLastBlock,
}"
>
- <p>
- {{ __('Commit') }}
+ <p class="append-bottom-5">
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
- <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">{{
- commit.short_id
- }}</gl-link>
+ <gl-link :href="commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ commit.short_id }}
+ </gl-link>
<clipboard-button
- :text="commit.short_id"
+ :text="commit.id"
:title="__('Copy commit SHA to clipboard')"
css-class="btn btn-clipboard btn-transparent"
/>
- <gl-link v-if="mergeRequest" :href="mergeRequest.path" class="js-link-commit link-commit"
- >!{{ mergeRequest.iid }}</gl-link
- >
+ <span v-if="mergeRequest">
+ in
+ <gl-link :href="mergeRequest.path" class="js-link-commit link-commit"
+ >!{{ mergeRequest.iid }}</gl-link
+ >
+ </span>
</p>
- <p class="build-light-text append-bottom-0">{{ commit.title }}</p>
+ <p class="append-bottom-0">{{ commit.title }}</p>
</div>
</template>
diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue
index 668fcf3d673..04f910b6b80 100644
--- a/app/assets/javascripts/jobs/components/empty_state.vue
+++ b/app/assets/javascripts/jobs/components/empty_state.vue
@@ -49,7 +49,7 @@ export default {
<div class="text-content">
<h4 class="js-job-empty-state-title text-center">{{ title }}</h4>
- <p v-if="content" class="js-job-empty-state-content">{{ content }}</p>
+ <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p>
<div v-if="action" class="text-center">
<gl-link
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue
index 786ab16992d..79fb67d38cd 100644
--- a/app/assets/javascripts/jobs/components/job_app.vue
+++ b/app/assets/javascripts/jobs/components/job_app.vue
@@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue';
import Log from './job_log.vue';
import LogTopBar from './job_log_controllers.vue';
import StuckBlock from './stuck_block.vue';
+import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue';
import Sidebar from './sidebar.vue';
import { sprintf } from '~/locale';
import delayedJobMixin from '../mixins/delayed_job_mixin';
@@ -32,8 +33,10 @@ export default {
Log,
LogTopBar,
StuckBlock,
+ UnmetPrerequisitesBlock,
Sidebar,
GlLoadingIcon,
+ SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'),
},
mixins: [delayedJobMixin],
props: {
@@ -47,6 +50,11 @@ export default {
required: false,
default: null,
},
+ deploymentHelpUrl: {
+ type: String,
+ required: false,
+ default: null,
+ },
endpoint: {
type: String,
required: true,
@@ -78,13 +86,15 @@ export default {
'isScrollTopDisabled',
'isScrolledToBottomBeforeReceivingTrace',
'hasError',
+ 'selectedStage',
]),
...mapGetters([
- 'headerActions',
'headerTime',
+ 'hasUnmetPrerequisitesFailure',
'shouldRenderCalloutMessage',
'shouldRenderTriggeredLabel',
'hasEnvironment',
+ 'shouldRenderSharedRunnerLimitWarning',
'hasTrace',
'emptyStateIllustration',
'isScrollingDown',
@@ -112,7 +122,13 @@ export default {
// fetch the stages for the dropdown on the sidebar
job(newVal, oldVal) {
if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) {
- this.fetchStages();
+ const stages = this.job.pipeline.details.stages || [];
+
+ const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage);
+
+ if (defaultStage) {
+ this.fetchJobsForStage(defaultStage);
+ }
}
if (newVal.archived) {
@@ -151,7 +167,7 @@ export default {
'setJobEndpoint',
'setTraceOptions',
'fetchJob',
- 'fetchStages',
+ 'fetchJobsForStage',
'hideSidebar',
'showSidebar',
'toggleSidebar',
@@ -202,7 +218,6 @@ export default {
:item-id="job.id"
:time="headerTime"
:user="job.user"
- :actions="headerActions"
:has-sidebar-button="true"
:should-render-triggered-label="shouldRenderTriggeredLabel"
:item-name="__('Job')"
@@ -210,7 +225,10 @@ export default {
/>
</div>
- <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" />
+ <callout
+ v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure"
+ :message="job.callout_message"
+ />
</header>
<!-- EO Header Section -->
@@ -223,6 +241,20 @@ export default {
:runners-path="runnerSettingsUrl"
/>
+ <unmet-prerequisites-block
+ v-if="hasUnmetPrerequisitesFailure"
+ class="js-job-failed"
+ :help-path="deploymentHelpUrl"
+ />
+
+ <shared-runner
+ v-if="shouldRenderSharedRunnerLimitWarning"
+ class="js-shared-runner-limit"
+ :quota-used="job.runners.quota.used"
+ :quota-limit="job.runners.quota.limit"
+ :runners-path="runnerHelpUrl"
+ />
+
<environments-block
v-if="hasEnvironment"
class="js-job-environment"
@@ -240,14 +272,18 @@ export default {
<div
v-if="job.archived"
ref="sticky"
- class="js-archived-job prepend-top-default archived-sticky sticky-top"
+ class="js-archived-job prepend-top-default archived-job"
+ :class="{ 'sticky-top border-bottom-0': hasTrace }"
>
<icon name="lock" class="align-text-bottom" />
-
{{ __('This job is archived. Only the complete pipeline can be retried.') }}
</div>
<!-- job log -->
- <div v-if="hasTrace" class="build-trace-container">
+ <div
+ v-if="hasTrace"
+ class="build-trace-container position-relative"
+ :class="{ 'prepend-top-default': !job.archived }"
+ >
<log-top-bar
:class="{
'sidebar-expanded': isSidebarOpen,
diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue
index 845699a90b5..a55dffbe488 100644
--- a/app/assets/javascripts/jobs/components/job_container_item.vue
+++ b/app/assets/javascripts/jobs/components/job_container_item.vue
@@ -43,7 +43,7 @@ export default {
<template>
<div
- class="build-job"
+ class="build-job position-relative"
:class="{
retried: job.retried,
active: isActive,
@@ -56,7 +56,11 @@ export default {
data-boundary="viewport"
class="js-job-link"
>
- <icon v-if="isActive" name="arrow-right" class="js-arrow-right icon-arrow-right" />
+ <icon
+ v-if="isActive"
+ name="arrow-right"
+ class="js-arrow-right icon-arrow-right position-absolute d-block"
+ />
<ci-icon :status="job.status" />
diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue
index 52e14f954ee..607b2bd1c74 100644
--- a/app/assets/javascripts/jobs/components/job_log_controllers.vue
+++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue
@@ -75,7 +75,11 @@ export default {
<template v-if="isTraceSizeVisible">
{{ jobLogSize }}
- <gl-link v-if="rawPath" :href="rawPath" class="js-raw-link raw-link">
+ <gl-link
+ v-if="rawPath"
+ :href="rawPath"
+ class="js-raw-link text-plain text-underline prepend-left-5"
+ >
{{ s__('Job|Complete Raw') }}
</gl-link>
</template>
diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue
index 934ecd0e3ec..24276c06486 100644
--- a/app/assets/javascripts/jobs/components/sidebar.vue
+++ b/app/assets/javascripts/jobs/components/sidebar.vue
@@ -34,7 +34,7 @@ export default {
},
},
computed: {
- ...mapState(['job', 'stages', 'jobs', 'selectedStage', 'isLoadingStages']),
+ ...mapState(['job', 'stages', 'jobs', 'selectedStage']),
coverage() {
return `${this.job.coverage}%`;
},
@@ -48,8 +48,7 @@ export default {
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
retryButtonClass() {
- let className =
- 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block';
+ let className = 'js-retry-button btn btn-retry';
className +=
this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary';
return className;
@@ -110,60 +109,55 @@ export default {
<aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix">
<div class="sidebar-container">
<div class="blocks-container">
- <div class="block">
- <strong class="inline prepend-top-8"> {{ job.name }} </strong>
- <gl-link
- v-if="job.retry_path"
- :class="retryButtonClass"
- :href="job.retry_path"
- data-method="post"
- rel="nofollow"
- >
- {{ __('Retry') }}
- </gl-link>
- <gl-link
- v-if="job.terminal_path"
- :href="job.terminal_path"
- class="js-terminal-link pull-right btn btn-primary
- btn-inverted visible-md-block visible-lg-block"
- target="_blank"
- >
- {{ __('Debug') }} <icon name="external-link" />
- </gl-link>
+ <div class="block d-flex flex-nowrap align-items-center">
+ <h4 class="my-0 mr-2 text-break-word">{{ job.name }}</h4>
+ <div class="flex-grow-1 flex-shrink-0 text-right">
+ <gl-link
+ v-if="job.retry_path"
+ :class="retryButtonClass"
+ :href="job.retry_path"
+ data-method="post"
+ rel="nofollow"
+ >{{ __('Retry') }}</gl-link
+ >
+ <gl-link
+ v-if="job.cancel_path"
+ :href="job.cancel_path"
+ class="js-cancel-job btn btn-default"
+ data-method="post"
+ rel="nofollow"
+ >{{ __('Cancel') }}</gl-link
+ >
+ </div>
+
<gl-button
:aria-label="__('Toggle Sidebar')"
type="button"
- class="btn btn-blank gutter-toggle
- float-right d-block d-md-none js-sidebar-build-toggle"
+ class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle"
@click="toggleSidebar"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-angle-double-right"></i>
</gl-button>
</div>
- <div v-if="job.retry_path || job.new_issue_path" class="block retry-link">
+
+ <div v-if="job.terminal_path || job.new_issue_path" class="block retry-link">
<gl-link
v-if="job.new_issue_path"
:href="job.new_issue_path"
- class="js-new-issue btn btn-success btn-inverted"
+ class="js-new-issue btn btn-success btn-inverted float-left mr-2"
+ >{{ __('New issue') }}</gl-link
>
- {{ __('New issue') }}
- </gl-link>
<gl-link
- v-if="job.retry_path"
- :href="job.retry_path"
- class="js-retry-job btn btn-inverted-secondary"
- data-method="post"
- rel="nofollow"
+ v-if="job.terminal_path"
+ :href="job.terminal_path"
+ class="js-terminal-link btn btn-primary btn-inverted visible-md-block visible-lg-block float-left"
+ target="_blank"
>
- {{ __('Retry') }}
+ {{ __('Debug') }} <icon name="external-link" :size="14" />
</gl-link>
</div>
- <div :class="{ block: renderBlock }">
- <p v-if="job.merge_request" class="build-detail-row js-job-mr">
- <span class="build-light-text"> {{ __('Merge Request:') }} </span>
- <gl-link :href="job.merge_request.path"> !{{ job.merge_request.iid }} </gl-link>
- </p>
+ <div :class="{ block: renderBlock }">
<detail-row
v-if="job.duration"
:value="duration"
@@ -198,22 +192,11 @@ export default {
title="Coverage"
/>
<p v-if="job.tags.length" class="build-detail-row js-job-tags">
- <span class="build-light-text"> {{ __('Tags:') }} </span>
- <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary">
- {{ tag }}
- </span>
+ <span class="font-weight-bold">{{ __('Tags:') }}</span>
+ <span v-for="(tag, i) in job.tags" :key="i" class="badge badge-primary mr-1">{{
+ tag
+ }}</span>
</p>
-
- <div v-if="job.cancel_path" class="btn-group prepend-top-5" role="group">
- <gl-link
- :href="job.cancel_path"
- class="js-cancel-job btn btn-sm btn-default"
- data-method="post"
- rel="nofollow"
- >
- {{ __('Cancel') }}
- </gl-link>
- </div>
</div>
<artifacts-block v-if="hasArtifact" :artifact="job.artifact" />
@@ -225,7 +208,6 @@ export default {
/>
<stages-dropdown
- v-if="!isLoadingStages"
:stages="stages"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
index 77be295e802..b826007ec2c 100644
--- a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
+++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue
@@ -34,8 +34,7 @@ export default {
</script>
<template>
<p class="build-detail-row">
- <span v-if="hasTitle" class="build-light-text"> {{ title }}: </span> {{ value }}
-
+ <span v-if="hasTitle" class="font-weight-bold">{{ title }}:</span> {{ value }}
<span v-if="hasHelpURL" class="help-button float-right">
<gl-link :href="helpUrl" target="_blank" rel="noopener noreferrer nofollow">
<i class="fa fa-question-circle" aria-hidden="true"></i>
diff --git a/app/assets/javascripts/jobs/components/stages_dropdown.vue b/app/assets/javascripts/jobs/components/stages_dropdown.vue
index 90482500bbf..6e92b599b0a 100644
--- a/app/assets/javascripts/jobs/components/stages_dropdown.vue
+++ b/app/assets/javascripts/jobs/components/stages_dropdown.vue
@@ -1,5 +1,6 @@
<script>
import _ from 'underscore';
+import { GlLink } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
@@ -7,6 +8,7 @@ export default {
components: {
CiIcon,
Icon,
+ GlLink,
},
props: {
pipeline: {
@@ -26,6 +28,12 @@ export default {
hasRef() {
return !_.isEmpty(this.pipeline.ref);
},
+ isTriggeredByMergeRequest() {
+ return Boolean(this.pipeline.merge_request);
+ },
+ isMergeRequestPipeline() {
+ return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
+ },
},
methods: {
onStageClick(stage) {
@@ -36,14 +44,41 @@ export default {
</script>
<template>
<div class="block-last dropdown">
- <ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
+ <div class="js-pipeline-info">
+ <ci-icon :status="pipeline.details.status" class="vertical-align-middle" />
+
+ <span class="font-weight-bold">{{ s__('Job|Pipeline') }}</span>
+ <gl-link :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
+ >#{{ pipeline.id }}</gl-link
+ >
+ <template v-if="hasRef">
+ {{ s__('Job|for') }}
+
+ <template v-if="isTriggeredByMergeRequest">
+ <gl-link :href="pipeline.merge_request.path" class="link-commit ref-name js-mr-link"
+ >!{{ pipeline.merge_request.iid }}</gl-link
+ >
+ {{ s__('Job|with') }}
+ <gl-link
+ :href="pipeline.merge_request.source_branch_path"
+ class="link-commit ref-name js-source-branch-link"
+ >{{ pipeline.merge_request.source_branch }}</gl-link
+ >
- {{ __('Pipeline') }}
- <a :href="pipeline.path" class="js-pipeline-path link-commit"> #{{ pipeline.id }} </a>
- <template v-if="hasRef">
- {{ __('from') }}
- <a :href="pipeline.ref.path" class="link-commit ref-name"> {{ pipeline.ref.name }} </a>
- </template>
+ <template v-if="isMergeRequestPipeline">
+ {{ s__('Job|into') }}
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="link-commit ref-name js-target-branch-link"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ >
+ </template>
+ </template>
+ <gl-link v-else :href="pipeline.ref.path" class="link-commit ref-name">{{
+ pipeline.ref.name
+ }}</gl-link>
+ </template>
+ </div>
<button
type="button"
@@ -55,7 +90,7 @@ export default {
<ul class="dropdown-menu">
<li v-for="stage in stages" :key="stage.name">
- <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage);">
+ <button type="button" class="js-stage-item stage-item" @click="onStageClick(stage)">
{{ stage.name }}
</button>
</li>
diff --git a/app/assets/javascripts/jobs/components/trigger_block.vue b/app/assets/javascripts/jobs/components/trigger_block.vue
index 3cd3b743108..922f64d93fe 100644
--- a/app/assets/javascripts/jobs/components/trigger_block.vue
+++ b/app/assets/javascripts/jobs/components/trigger_block.vue
@@ -43,23 +43,24 @@ export default {
<template>
<div class="build-widget block">
- <h4 class="title">{{ __('Trigger') }}</h4>
-
<p
v-if="trigger.short_token"
class="js-short-token"
- :class="{ 'append-bottom-0': !hasVariables }"
+ :class="{ 'append-bottom-5': hasVariables, 'append-bottom-0': !hasVariables }"
>
- <span class="build-light-text"> {{ __('Token') }} </span> {{ trigger.short_token }}
+ <span class="font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }}
</p>
<template v-if="hasVariables">
- <p class="trigger-variables-btn-container">
- <span class="build-light-text"> {{ __('Variables:') }} </span>
+ <p class="trigger-variables-btn-container d-flex">
+ <span class="font-weight-bold">{{ __('Trigger variables:') }}</span>
- <gl-button v-if="hasValues" class="group js-reveal-variables" @click="toggleValues">
- {{ getToggleButtonText }}
- </gl-button>
+ <gl-button
+ v-if="hasValues"
+ class="btn-sm group js-reveal-variables trigger-variables-btn"
+ @click="toggleValues"
+ >{{ getToggleButtonText }}</gl-button
+ >
</p>
<table class="js-build-variables trigger-build-variables">
diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
new file mode 100644
index 00000000000..25a8da84873
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+/**
+ * Renders Unmet Prerequisites block for job's view.
+ */
+export default {
+ components: {
+ GlLink,
+ },
+ props: {
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="bs-callout bs-callout-danger">
+ <p class="js-failed-unmet-prerequisites append-bottom-0">
+ {{
+ s__(`Job|This job failed because the necessary resources were not successfully created.`)
+ }}
+
+ <gl-link :href="helpPath" class="js-help-path">
+ <strong> {{ __('More information') }} </strong>
+ </gl-link>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js
index a32e945627c..25132449458 100644
--- a/app/assets/javascripts/jobs/index.js
+++ b/app/assets/javascripts/jobs/index.js
@@ -12,6 +12,7 @@ export default () => {
render(createElement) {
return createElement('job-app', {
props: {
+ deploymentHelpUrl: element.dataset.deploymentHelpUrl,
runnerHelpUrl: element.dataset.runnerHelpUrl,
runnerSettingsUrl: element.dataset.runnerSettingsUrl,
endpoint: element.dataset.endpoint,
diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js
index 8045f6dc3ff..12d67a43599 100644
--- a/app/assets/javascripts/jobs/store/actions.js
+++ b/app/assets/javascripts/jobs/store/actions.js
@@ -179,37 +179,13 @@ export const receiveTraceError = ({ commit }) => {
};
/**
- * Stages dropdown on sidebar
- */
-export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES);
-export const fetchStages = ({ state, dispatch }) => {
- dispatch('requestStages');
-
- axios
- .get(`${state.job.pipeline.path}.json`)
- .then(({ data }) => {
- // Set selected stage
- dispatch('receiveStagesSuccess', data.details.stages);
- const selectedStage = data.details.stages.find(stage => stage.name === state.selectedStage);
- dispatch('fetchJobsForStage', selectedStage);
- })
- .catch(() => dispatch('receiveStagesError'));
-};
-export const receiveStagesSuccess = ({ commit }, data) =>
- commit(types.RECEIVE_STAGES_SUCCESS, data);
-export const receiveStagesError = ({ commit }) => {
- commit(types.RECEIVE_STAGES_ERROR);
- flash(__('An error occurred while fetching stages.'));
-};
-
-/**
* Jobs list on sidebar - depend on stages dropdown
*/
export const requestJobsForStage = ({ commit }, stage) =>
commit(types.REQUEST_JOBS_FOR_STAGE, stage);
// On stage click, set selected stage + fetch job
-export const fetchJobsForStage = ({ dispatch }, stage) => {
+export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
dispatch('requestJobsForStage', stage);
axios
diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js
index 35e92b0b5d9..406b1a2e375 100644
--- a/app/assets/javascripts/jobs/store/getters.js
+++ b/app/assets/javascripts/jobs/store/getters.js
@@ -1,24 +1,11 @@
import _ from 'underscore';
-import { __ } from '~/locale';
import { isScrolledToBottom } from '~/lib/utils/scroll_utils';
-export const headerActions = state => {
- if (state.job.new_issue_path) {
- return [
- {
- label: __('New issue'),
- path: state.job.new_issue_path,
- cssClass:
- 'js-new-issue btn btn-success btn-inverted d-none d-md-block d-lg-block d-xl-block',
- type: 'link',
- },
- ];
- }
- return [];
-};
-
export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at);
+export const hasUnmetPrerequisitesFailure = state =>
+ state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites';
+
export const shouldRenderCalloutMessage = state =>
!_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message);
@@ -44,6 +31,17 @@ export const emptyStateIllustration = state =>
export const emptyStateAction = state =>
(state.job && state.job.status && state.job.status.action) || null;
+/**
+ * Shared runners limit is only rendered when
+ * used quota is bigger or equal than the limit
+ *
+ * @returns {Boolean}
+ */
+export const shouldRenderSharedRunnerLimitWarning = state =>
+ !_.isEmpty(state.job.runners) &&
+ !_.isEmpty(state.job.runners.quota) &&
+ state.job.runners.quota.used >= state.job.runners.quota.limit;
+
export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceComplete;
export const hasRunnersForProject = state =>
diff --git a/app/assets/javascripts/jobs/store/mutation_types.js b/app/assets/javascripts/jobs/store/mutation_types.js
index fd098f13e90..39146b2eefd 100644
--- a/app/assets/javascripts/jobs/store/mutation_types.js
+++ b/app/assets/javascripts/jobs/store/mutation_types.js
@@ -24,10 +24,6 @@ export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
-export const REQUEST_STAGES = 'REQUEST_STAGES';
-export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS';
-export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR';
-
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE';
export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS';
diff --git a/app/assets/javascripts/jobs/store/mutations.js b/app/assets/javascripts/jobs/store/mutations.js
index cd440d21c1f..ad08f27b147 100644
--- a/app/assets/javascripts/jobs/store/mutations.js
+++ b/app/assets/javascripts/jobs/store/mutations.js
@@ -65,6 +65,11 @@ export default {
state.isLoading = false;
state.job = job;
+ state.stages =
+ job.pipeline && job.pipeline.details && job.pipeline.details.stages
+ ? job.pipeline.details.stages
+ : [];
+
/**
* We only update it on the first request
* The dropdown can be changed by the user
@@ -101,19 +106,7 @@ export default {
state.isScrolledToBottomBeforeReceivingTrace = toggle;
},
- [types.REQUEST_STAGES](state) {
- state.isLoadingStages = true;
- },
- [types.RECEIVE_STAGES_SUCCESS](state, stages) {
- state.isLoadingStages = false;
- state.stages = stages;
- },
- [types.RECEIVE_STAGES_ERROR](state) {
- state.isLoadingStages = false;
- state.stages = [];
- },
-
- [types.REQUEST_JOBS_FOR_STAGE](state, stage) {
+ [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) {
state.isLoadingJobs = true;
state.selectedStage = stage.name;
},
diff --git a/app/assets/javascripts/jobs/store/state.js b/app/assets/javascripts/jobs/store/state.js
index 04825187c99..6019214e62c 100644
--- a/app/assets/javascripts/jobs/store/state.js
+++ b/app/assets/javascripts/jobs/store/state.js
@@ -25,7 +25,6 @@ export default () => ({
traceState: null,
// sidebar dropdown & list of jobs
- isLoadingStages: false,
isLoadingJobs: false,
selectedStage: '',
stages: [],
diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js
index 062501d1d04..c6dd21cd2d4 100644
--- a/app/assets/javascripts/label_manager.js
+++ b/app/assets/javascripts/label_manager.js
@@ -5,22 +5,26 @@ import Sortable from 'sortablejs';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
export default class LabelManager {
constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) {
this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority');
this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels');
this.otherLabels = otherLabels || $('.js-other-labels');
- this.errorMessage = 'Unable to update label prioritization at this time';
+ this.errorMessage = __('Unable to update label prioritization at this time');
this.emptyState = document.querySelector('#js-priority-labels-empty-state');
this.$badgeItemTemplate = $('#js-badge-item-template');
- this.sortable = Sortable.create(this.prioritizedLabels.get(0), {
- filter: '.empty-message',
- forceFallback: true,
- fallbackClass: 'is-dragging',
- dataIdAttr: 'data-id',
- onUpdate: this.onPrioritySortUpdate.bind(this),
- });
+
+ if ('sortable' in this.prioritizedLabels.data()) {
+ Sortable.create(this.prioritizedLabels.get(0), {
+ filter: '.empty-message',
+ forceFallback: true,
+ fallbackClass: 'is-dragging',
+ dataIdAttr: 'data-id',
+ onUpdate: this.onPrioritySortUpdate.bind(this),
+ });
+ }
this.bindEvents();
}
@@ -70,7 +74,18 @@ export default class LabelManager {
const $detachedLabel = $label.detach();
this.toggleLabelPriorityBadge($detachedLabel, action);
- $detachedLabel.appendTo($target);
+
+ const $labelEls = $target.find('li.label-list-item');
+
+ /*
+ * If there is a label element in the target, we'd want to
+ * append the new label just right next to it.
+ */
+ if ($labelEls.length) {
+ $labelEls.last().after($detachedLabel);
+ } else {
+ $detachedLabel.appendTo($target);
+ }
if ($from.find('li').length) {
$from.find('.empty-message').removeClass('hidden');
diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js
index f7a611fbca0..3f954b43ee3 100644
--- a/app/assets/javascripts/labels_select.js
+++ b/app/assets/javascripts/labels_select.js
@@ -4,13 +4,14 @@
import $ from 'jquery';
import _ from 'underscore';
-import { sprintf, __ } from './locale';
+import { sprintf, s__, __ } from './locale';
import axios from './lib/utils/axios_utils';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
+import { isEE, isScopedLabel } from '~/lib/utils/common_utils';
export default class LabelsSelect {
constructor(els, options = {}) {
@@ -86,8 +87,9 @@ export default class LabelsSelect {
return this.value;
})
.get();
+ const scopedLabels = $dropdown.data('scopedLabels');
+ const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
-
$sidebarLabelTooltip.tooltip();
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
@@ -132,10 +134,51 @@ export default class LabelsSelect {
template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
+ enableScopedLabels: scopedLabels,
+ scopedLabelsDocumentationLink,
});
labelCount = data.labels.length;
+
+ // EE Specific
+ if (isEE) {
+ /**
+ * For Scoped labels, the last label selected with the
+ * same key will be applied to the current issueable.
+ *
+ * If these are the labels - priority::1, priority::2; and if
+ * we apply them in the same order, only priority::2 will stick
+ * with the issuable.
+ *
+ * In the current dropdown implementation, we keep track of all
+ * the labels selected via a hidden DOM element. Since a User
+ * can select priority::1 and priority::2 at the same time, the
+ * DOM will have 2 hidden input and the dropdown will show both
+ * the items selected but in reality server only applied
+ * priority::2.
+ *
+ * We find all the labels then find all the labels server accepted
+ * and then remove the excess ones.
+ */
+ const toRemoveIds = Array.from(
+ $form.find(`input[type="hidden"][name="${fieldName}"]`),
+ )
+ .map(el => el.value)
+ .map(Number);
+
+ data.labels.forEach(label => {
+ const index = toRemoveIds.indexOf(label.id);
+ toRemoveIds.splice(index, 1);
+ });
+
+ toRemoveIds.forEach(id => {
+ $form
+ .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`)
+ .last()
+ .remove();
+ });
+ }
} else {
- template = '<span class="no-value">None</span>';
+ template = `<span class="no-value">${__('None')}</span>`;
}
$value.removeAttr('style').html(template);
$sidebarCollapsedValue.text(labelCount);
@@ -147,7 +190,9 @@ export default class LabelsSelect {
if (labelTitles.length > 5) {
labelTitles = labelTitles.slice(0, 5);
- labelTitles.push('and ' + (data.labels.length - 5) + ' more');
+ labelTitles.push(
+ sprintf(s__('Labels|and %{count} more'), { count: data.labels.length - 5 }),
+ );
}
labelTooltipTitle = labelTitles.join(', ');
@@ -176,13 +221,13 @@ export default class LabelsSelect {
if (showNo) {
extraData.unshift({
id: 0,
- title: 'No Label',
+ title: __('No Label'),
});
}
if (showAny) {
extraData.unshift({
isAny: true,
- title: 'Any Label',
+ title: __('Any Label'),
});
}
if (extraData.length) {
@@ -199,8 +244,8 @@ export default class LabelsSelect {
.catch(() => flash(__('Error fetching labels.')));
},
renderRow: function(label, instance) {
- var $a,
- $li,
+ var linkEl,
+ listItemEl,
color,
colorEl,
indeterminate,
@@ -209,12 +254,11 @@ export default class LabelsSelect {
spacing,
i,
marked,
- dropdownName,
dropdownValue;
- $li = $('<li>');
- $a = $('<a href="#">');
+
selectedClass = [];
removesAll = label.id <= 0 || label.id == null;
+
if ($dropdown.hasClass('js-filter-bulk-update')) {
indeterminate = $dropdown.data('indeterminate') || [];
marked = $dropdown.data('marked') || [];
@@ -233,7 +277,6 @@ export default class LabelsSelect {
}
} else {
if (this.id(label)) {
- dropdownName = $dropdown.data('fieldName');
dropdownValue = this.id(label)
.toString()
.replace(/'/g, "\\'");
@@ -241,7 +284,7 @@ export default class LabelsSelect {
if (
$form.find(
"input[type='hidden'][name='" +
- dropdownName +
+ this.fieldName +
"'][value='" +
dropdownValue +
"']",
@@ -251,24 +294,34 @@ export default class LabelsSelect {
}
}
- if ($dropdown.hasClass('js-multiselect') && removesAll) {
+ if (this.multiSelect && removesAll) {
selectedClass.push('dropdown-clear-active');
}
}
+
if (label.color) {
colorEl =
"<span class='dropdown-label-box' style='background: " + label.color + "'></span>";
} else {
colorEl = '';
}
+
+ linkEl = document.createElement('a');
+ linkEl.href = '#';
+
// We need to identify which items are actually labels
if (label.id) {
selectedClass.push('label-item');
- $a.attr('data-label-id', label.id);
+ linkEl.dataset.labelId = label.id;
}
- $a.addClass(selectedClass.join(' ')).html(`${colorEl} ${_.escape(label.title)}`);
- // Return generated html
- return $li.html($a).prop('outerHTML');
+
+ linkEl.className = selectedClass.join(' ');
+ linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`;
+
+ listItemEl = document.createElement('li');
+ listItemEl.appendChild(linkEl);
+
+ return listItemEl;
},
search: {
fields: ['title'],
@@ -290,7 +343,7 @@ export default class LabelsSelect {
if (selected && selected.id === 0) {
this.selected = [];
- return 'No Label';
+ return __('No Label');
} else if (isSelected) {
this.selected.push(title);
} else if (!isSelected && title) {
@@ -350,6 +403,7 @@ export default class LabelsSelect {
} else {
if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData();
+ $dropdown.data('glDropdown').clearMenu();
}
}
}
@@ -463,19 +517,60 @@ export default class LabelsSelect {
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
- const tpl = _.template(
+
+ const labelTemplate = _.template(
[
- '<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
- '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
+ '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">',
'<%- label.title %>',
'</span>',
'</a>',
+ ].join(''),
+ );
+
+ const infoIconTemplate = _.template(
+ [
+ '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">',
+ '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>',
+ '</a>',
+ ].join(''),
+ );
+
+ const tooltipTitleTemplate = _.template(
+ [
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
+ '<br />',
+ '<%= escapeStr(label.description) %>',
+ '<% } else { %>',
+ '<%= escapeStr(label.description) %>',
+ '<% } %>',
+ ].join(''),
+ );
+
+ const tpl = _.template(
+ [
+ '<% _.each(labels, function(label){ %>',
+ '<% if (isScopedLabel(label) && enableScopedLabels) { %>',
+ '<span class="d-inline-block position-relative scoped-label-wrapper">',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
+ '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
+ '</span>',
+ '<% } else { %>',
+ '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
+ '<% } %>',
'<% }); %>',
].join(''),
);
- return tpl(tplData);
+ return tpl({
+ ...tplData,
+ labelTemplate,
+ infoIconTemplate,
+ tooltipTitleTemplate,
+ isScopedLabel,
+ escapeStr: _.escape,
+ });
}
bindEvents() {
@@ -486,7 +581,7 @@ export default class LabelsSelect {
if ($('.selected-issuable:checked').length) {
return;
}
- return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text('Label');
+ return $('.issues-bulk-update .labels-filter .dropdown-toggle-text').text(__('Label'));
}
// eslint-disable-next-line class-methods-use-this
enableBulkLabelDropdown() {
diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js
index b8c3c237eb3..4314e5e1afb 100644
--- a/app/assets/javascripts/layout_nav.js
+++ b/app/assets/javascripts/layout_nav.js
@@ -11,48 +11,53 @@ function hideEndFade($scrollingTabs) {
});
}
+function initDeferred() {
+ $(document).trigger('init.scrolling-tabs');
+}
+
export default function initLayoutNav() {
const contextualSidebar = new ContextualSidebar();
contextualSidebar.bindEvents();
initFlyOutNav();
- $(document)
- .on('init.scrolling-tabs', () => {
- const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
- $scrollingTabs.addClass('is-initialized');
+ // We need to init it on DomContentLoaded as others could also call it
+ $(document).on('init.scrolling-tabs', () => {
+ const $scrollingTabs = $('.scrolling-tabs').not('.is-initialized');
+ $scrollingTabs.addClass('is-initialized');
- $(window)
- .on('resize.nav', () => {
- hideEndFade($scrollingTabs);
- })
- .trigger('resize.nav');
+ $(window)
+ .on('resize.nav', () => {
+ hideEndFade($scrollingTabs);
+ })
+ .trigger('resize.nav');
- $scrollingTabs.on('scroll', function tabsScrollEvent() {
- const $this = $(this);
- const currentPosition = $this.scrollLeft();
- const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
+ $scrollingTabs.on('scroll', function tabsScrollEvent() {
+ const $this = $(this);
+ const currentPosition = $this.scrollLeft();
+ const maxPosition = $this.prop('scrollWidth') - $this.outerWidth();
- $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
- $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
- });
+ $this.siblings('.fade-left').toggleClass('scrolling', currentPosition > 0);
+ $this.siblings('.fade-right').toggleClass('scrolling', currentPosition < maxPosition - 1);
+ });
- $scrollingTabs.each(function scrollTabsEachLoop() {
- const $this = $(this);
- const scrollingTabWidth = $this.width();
- const $active = $this.find('.active');
- const activeWidth = $active.width();
+ $scrollingTabs.each(function scrollTabsEachLoop() {
+ const $this = $(this);
+ const scrollingTabWidth = $this.width();
+ const $active = $this.find('.active');
+ const activeWidth = $active.width();
- if ($active.length) {
- const offset = $active.offset().left + activeWidth;
+ if ($active.length) {
+ const offset = $active.offset().left + activeWidth;
- if (offset > scrollingTabWidth - 30) {
- const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2;
+ if (offset > scrollingTabWidth - 30) {
+ const scrollLeft = offset - scrollingTabWidth / 2 - activeWidth / 2;
- $this.scrollLeft(scrollLeft);
- }
+ $this.scrollLeft(scrollLeft);
}
- });
- })
- .trigger('init.scrolling-tabs');
+ }
+ });
+ });
+
+ requestIdleCallback(initDeferred);
}
diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js
index ee01a73a6e8..66f25b622e0 100644
--- a/app/assets/javascripts/lazy_loader.js
+++ b/app/assets/javascripts/lazy_loader.js
@@ -163,6 +163,7 @@ export default class LazyLoader {
img.removeAttribute('data-src');
img.classList.remove('lazy');
img.classList.add('js-lazy-loaded');
+ img.classList.add('qa-js-lazy-loaded');
}
}
}
diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js
index 20a0f142d9e..47e91dedd5a 100644
--- a/app/assets/javascripts/lib/graphql.js
+++ b/app/assets/javascripts/lib/graphql.js
@@ -1,9 +1,24 @@
-import ApolloClient from 'apollo-boost';
+import { ApolloClient } from 'apollo-client';
+import { InMemoryCache } from 'apollo-cache-inmemory';
+import { createUploadLink } from 'apollo-upload-client';
import csrf from '~/lib/utils/csrf';
-export default new ApolloClient({
- uri: `${gon.relative_url_root}/api/graphql`,
- headers: {
- [csrf.headerKey]: csrf.token,
- },
-});
+export default (resolvers = {}, config = {}) => {
+ let uri = `${gon.relative_url_root}/api/graphql`;
+
+ if (config.baseUrl) {
+ // Prepend baseUrl and ensure that `///` are replaced with `/`
+ uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/');
+ }
+
+ return new ApolloClient({
+ link: createUploadLink({
+ uri,
+ headers: {
+ [csrf.headerKey]: csrf.token,
+ },
+ }),
+ cache: new InMemoryCache(config.cacheConfig),
+ resolvers,
+ });
+};
diff --git a/app/assets/javascripts/lib/utils/autosave.js b/app/assets/javascripts/lib/utils/autosave.js
new file mode 100644
index 00000000000..023c336db02
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/autosave.js
@@ -0,0 +1,32 @@
+import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
+
+export const clearDraft = autosaveKey => {
+ try {
+ window.localStorage.removeItem(`autosave/${autosaveKey}`);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+};
+
+export const getDraft = autosaveKey => {
+ try {
+ return window.localStorage.getItem(`autosave/${autosaveKey}`);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ return null;
+ }
+};
+
+export const updateDraft = (autosaveKey, text) => {
+ try {
+ window.localStorage.setItem(`autosave/${autosaveKey}`, text);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ }
+};
+
+export const getDiscussionReplyKey = (noteableType, discussionId) =>
+ ['Note', capitalizeFirstCharacter(noteableType), discussionId, 'Reply'].join('/');
diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js
new file mode 100644
index 00000000000..0f78756aac8
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/chart_utils.js
@@ -0,0 +1,83 @@
+const commonTooltips = () => ({
+ mode: 'x',
+ intersect: false,
+});
+
+const adjustedFontScale = () => ({
+ fontSize: 8,
+});
+
+const yAxesConfig = (shouldAdjustFontSize = false) => ({
+ yAxes: [
+ {
+ ticks: {
+ beginAtZero: true,
+ ...(shouldAdjustFontSize ? adjustedFontScale() : {}),
+ },
+ },
+ ],
+});
+
+const xAxesConfig = (shouldAdjustFontSize = false) => ({
+ xAxes: [
+ {
+ ticks: {
+ ...(shouldAdjustFontSize ? adjustedFontScale() : {}),
+ },
+ },
+ ],
+});
+
+const commonChartOptions = () => ({
+ responsive: true,
+ maintainAspectRatio: false,
+ legend: false,
+});
+
+export const barChartOptions = shouldAdjustFontSize => ({
+ ...commonChartOptions(),
+ scales: {
+ ...yAxesConfig(shouldAdjustFontSize),
+ ...xAxesConfig(shouldAdjustFontSize),
+ },
+ tooltips: {
+ ...commonTooltips(),
+ displayColors: false,
+ callbacks: {
+ title() {
+ return '';
+ },
+ label({ xLabel, yLabel }) {
+ return `${xLabel}: ${yLabel}`;
+ },
+ },
+ },
+});
+
+export const pieChartOptions = commonChartOptions;
+
+export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize }) => ({
+ ...commonChartOptions(),
+ scales: {
+ ...yAxesConfig(shouldAdjustFontSize),
+ ...xAxesConfig(shouldAdjustFontSize),
+ },
+ elements: {
+ point: {
+ hitRadius: width / (numberOfPoints * 2),
+ },
+ },
+ tooltips: {
+ ...commonTooltips(),
+ caretSize: 0,
+ multiKeyBackground: 'rgba(0,0,0,0)',
+ callbacks: {
+ labelColor({ datasetIndex }, { config }) {
+ return {
+ backgroundColor: config.data.datasets[datasetIndex].backgroundColor,
+ borderColor: 'rgba(0,0,0,0)',
+ };
+ },
+ },
+ },
+});
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 9e22cdc04e9..b236daff1e0 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -1,8 +1,13 @@
+/**
+ * @module common-utils
+ */
+
import $ from 'jquery';
import axios from './axios_utils';
import { getLocationHash } from './url_utility';
import { convertToCamelCase } from './text_utility';
import { isObject } from './type_utility';
+import breakpointInstance from '../../breakpoints';
export const getPagePath = (index = 0) => {
const page = $('body').attr('data-page') || '';
@@ -118,14 +123,15 @@ export const handleLocationHash = () => {
// Check if element scrolled into viewport from above or below
// Courtesy http://stackoverflow.com/a/7557433/414749
-export const isInViewport = el => {
+export const isInViewport = (el, offset = {}) => {
const rect = el.getBoundingClientRect();
+ const { top, left } = offset;
return (
- rect.top >= 0 &&
- rect.left >= 0 &&
+ rect.top >= (top || 0) &&
+ rect.left >= (left || 0) &&
rect.bottom <= window.innerHeight &&
- rect.right <= window.innerWidth
+ parseInt(rect.right, 10) <= window.innerWidth
);
};
@@ -188,16 +194,23 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey;
export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2;
export const contentTop = () => {
- const perfBar = $('#js-peek').height() || 0;
- const mrTabsHeight = $('.merge-request-tabs').height() || 0;
- const headerHeight = $('.navbar-gitlab').height() || 0;
- const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
- const diffFileLargeEnoughScreen =
- 'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
+ const perfBar = $('#js-peek').outerHeight() || 0;
+ const mrTabsHeight = $('.merge-request-tabs').outerHeight() || 0;
+ const headerHeight = $('.navbar-gitlab').outerHeight() || 0;
+ const diffFilesChanged = $('.js-diff-files-changed').outerHeight() || 0;
+ const isDesktop = breakpointInstance.isDesktop();
const diffFileTitleBar =
- (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
+ (isDesktop && $('.diff-file .file-title-flex-parent:visible').outerHeight()) || 0;
+ const compareVersionsHeaderHeight = (isDesktop && $('.mr-version-controls').outerHeight()) || 0;
- return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
+ return (
+ perfBar +
+ mrTabsHeight +
+ headerHeight +
+ diffFilesChanged +
+ diffFileTitleBar +
+ compareVersionsHeaderHeight
+ );
};
export const scrollToElement = element => {
@@ -216,6 +229,22 @@ export const scrollToElement = element => {
};
/**
+ * Returns a function that can only be invoked once between
+ * each browser screen repaint.
+ * @param {Function} fn
+ */
+export const debounceByAnimationFrame = fn => {
+ let requestId;
+
+ return function debounced(...args) {
+ if (requestId) {
+ window.cancelAnimationFrame(requestId);
+ }
+ requestId = window.requestAnimationFrame(() => fn.apply(this, args));
+ };
+};
+
+/**
this will take in the `name` of the param you want to parse in the url
if the name does not exist this function will return `null`
otherwise it will return the value of the param key provided
@@ -425,34 +454,26 @@ export const historyPushState = newUrl => {
};
/**
- * Returns true for a String "true" and false otherwise.
- * This is the opposite of Boolean(...).toString()
+ * Returns true for a String value of "true" and false otherwise.
+ * This is the opposite of Boolean(...).toString().
+ * `parseBoolean` is idempotent.
*
* @param {String} value
* @returns {Boolean}
*/
-export const parseBoolean = value => value === 'true';
+export const parseBoolean = value => (value && value.toString()) === 'true';
/**
- * Converts permission provided as strings to booleans.
- *
- * @param {String} string
- * @returns {Boolean}
+ * @callback backOffCallback
+ * @param {Function} next
+ * @param {Function} stop
*/
-export const convertPermissionToBoolean = permission => {
- if (process.env.NODE_ENV !== 'production') {
- // eslint-disable-next-line no-console
- console.warn('convertPermissionToBoolean is deprecated! Please use parseBoolean instead.');
- }
-
- return parseBoolean(permission);
-};
/**
* Back Off exponential algorithm
* backOff :: (Function<next, stop>, Number) -> Promise<Any, Error>
*
- * @param {Function<next, stop>} fn function to be called
+ * @param {backOffCallback} fn function to be called
* @param {Number} timeout
* @return {Promise<Any, Error>}
* @example
@@ -602,10 +623,18 @@ export const spriteIcon = (icon, className = '') => {
/**
* This method takes in object with snake_case property names
- * and returns new object with camelCase property names
+ * and returns a new object with camelCase property names
*
* Reasoning for this method is to ensure consistent property
* naming conventions across JS code.
+ *
+ * This method also supports additional params in `options` object
+ *
+ * @param {Object} obj - Object to be converted.
+ * @param {Object} options - Object containing additional options.
+ * @param {boolean} options.deep - FLag to allow deep object converting
+ * @param {Array[]} dropKeys - List of properties to discard while building new object
+ * @param {Array[]} ignoreKeyNames - List of properties to leave intact (as snake_case) while building new object
*/
export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
if (obj === null) {
@@ -613,12 +642,26 @@ export const convertObjectPropsToCamelCase = (obj = {}, options = {}) => {
}
const initial = Array.isArray(obj) ? [] : {};
+ const { deep = false, dropKeys = [], ignoreKeyNames = [] } = options;
return Object.keys(obj).reduce((acc, prop) => {
const result = acc;
const val = obj[prop];
- if (options.deep && (isObject(val) || Array.isArray(val))) {
+ // Drop properties from new object if
+ // there are any mentioned in options
+ if (dropKeys.indexOf(prop) > -1) {
+ return acc;
+ }
+
+ // Skip converting properties in new object
+ // if there are any mentioned in options
+ if (ignoreKeyNames.indexOf(prop) > -1) {
+ result[prop] = obj[prop];
+ return acc;
+ }
+
+ if (deep && (isObject(val) || Array.isArray(val))) {
result[convertToCamelCase(prop)] = convertObjectPropsToCamelCase(val, options);
} else {
result[convertToCamelCase(prop)] = obj[prop];
@@ -673,6 +716,26 @@ export const NavigationType = {
TYPE_RESERVED: 255,
};
+/**
+ * Returns the value of `gon.ee`
+ * Used to check if it's the EE codebase or the CE one.
+ *
+ * @returns Boolean
+ */
+export const isEE = () => window.gon && window.gon.ee;
+
+/**
+ * Checks if the given Label has a special syntax `::` in
+ * it's title.
+ *
+ * Expected Label to be an Object with `title` as a key:
+ * { title: 'LabelTitle', ...otherProperties };
+ *
+ * @param {Object} label
+ * @returns Boolean
+ */
+export const isScopedLabel = ({ title = '' }) => title.indexOf('::') !== -1;
+
window.gl = window.gl || {};
window.gl.utils = {
...(window.gl.utils || {}),
diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js
index 59007d5950e..32cafb74d91 100644
--- a/app/assets/javascripts/lib/utils/datetime_utility.js
+++ b/app/assets/javascripts/lib/utils/datetime_utility.js
@@ -3,11 +3,19 @@ import _ from 'underscore';
import timeago from 'timeago.js';
import dateFormat from 'dateformat';
import { pluralize } from './text_utility';
-import { languageCode, s__ } from '../../locale';
+import { languageCode, s__, __ } from '../../locale';
window.timeago = timeago;
/**
+ * This method allows you to create new Date instance from existing
+ * date instance without keeping the reference.
+ *
+ * @param {Date} date
+ */
+export const newDate = date => (date instanceof Date ? new Date(date.getTime()) : new Date());
+
+/**
* Returns i18n month names array.
* If `abbreviated` is provided, returns abbreviated
* name.
@@ -55,7 +63,15 @@ export const pad = (val, len = 2) => `0${val}`.slice(-len);
* @returns {String}
*/
export const getDayName = date =>
- ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][date.getDay()];
+ [
+ __('Sunday'),
+ __('Monday'),
+ __('Tuesday'),
+ __('Wednesday'),
+ __('Thursday'),
+ __('Friday'),
+ __('Saturday'),
+ ][date.getDay()];
/**
* @example
@@ -63,7 +79,12 @@ export const getDayName = date =>
* @param {date} datetime
* @returns {String}
*/
-export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
+export const formatDate = datetime => {
+ if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
+ throw new Error('Invalid date');
+ }
+ return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
+};
/**
* Timeago uses underscores instead of dashes to separate language from country code.
@@ -79,44 +100,67 @@ let timeagoInstance;
*/
export const getTimeago = () => {
if (!timeagoInstance) {
- const localeRemaining = (number, index) =>
- [
- [s__('Timeago|just now'), s__('Timeago|right now')],
- [s__('Timeago|%s seconds ago'), s__('Timeago|%s seconds remaining')],
- [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
- [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
- [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
- [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
- [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
- [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
- [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
- [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
- [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
- [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
- [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
- [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
- ][index];
-
- const locale = (number, index) =>
- [
- [s__('Timeago|just now'), s__('Timeago|right now')],
- [s__('Timeago|%s seconds ago'), s__('Timeago|in %s seconds')],
- [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
- [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
- [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
- [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
- [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
- [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
- [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
- [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
- [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
- [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
- [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
- [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
- ][index];
-
- timeago.register(timeagoLanguageCode, locale);
- timeago.register(`${timeagoLanguageCode}-remaining`, localeRemaining);
+ const memoizedLocaleRemaining = () => {
+ const cache = [];
+
+ const timeAgoLocaleRemaining = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|%s seconds remaining')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|1 minute remaining')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|%s minutes remaining')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|1 hour remaining')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|%s hours remaining')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|1 day remaining')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|%s days remaining')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|1 week remaining')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|%s weeks remaining')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|1 month remaining')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|%s months remaining')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|1 year remaining')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|%s years remaining')],
+ ];
+
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocaleRemaining[index] && timeAgoLocaleRemaining[index]();
+ return cache[index];
+ };
+ };
+
+ const memoizedLocale = () => {
+ const cache = [];
+
+ const timeAgoLocale = [
+ () => [s__('Timeago|just now'), s__('Timeago|right now')],
+ () => [s__('Timeago|just now'), s__('Timeago|in %s seconds')],
+ () => [s__('Timeago|1 minute ago'), s__('Timeago|in 1 minute')],
+ () => [s__('Timeago|%s minutes ago'), s__('Timeago|in %s minutes')],
+ () => [s__('Timeago|1 hour ago'), s__('Timeago|in 1 hour')],
+ () => [s__('Timeago|%s hours ago'), s__('Timeago|in %s hours')],
+ () => [s__('Timeago|1 day ago'), s__('Timeago|in 1 day')],
+ () => [s__('Timeago|%s days ago'), s__('Timeago|in %s days')],
+ () => [s__('Timeago|1 week ago'), s__('Timeago|in 1 week')],
+ () => [s__('Timeago|%s weeks ago'), s__('Timeago|in %s weeks')],
+ () => [s__('Timeago|1 month ago'), s__('Timeago|in 1 month')],
+ () => [s__('Timeago|%s months ago'), s__('Timeago|in %s months')],
+ () => [s__('Timeago|1 year ago'), s__('Timeago|in 1 year')],
+ () => [s__('Timeago|%s years ago'), s__('Timeago|in %s years')],
+ ];
+
+ return (number, index) => {
+ if (cache[index]) {
+ return cache[index];
+ }
+ cache[index] = timeAgoLocale[index] && timeAgoLocale[index]();
+ return cache[index];
+ };
+ };
+
+ timeago.register(timeagoLanguageCode, memoizedLocale());
+ timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining());
+
timeagoInstance = timeago();
}
@@ -124,35 +168,32 @@ export const getTimeago = () => {
};
/**
- * For the given element, renders a timeago instance.
- * @param {jQuery} $els
- */
-export const renderTimeago = $els => {
- const timeagoEls = $els || document.querySelectorAll('.js-timeago-render');
-
- // timeago.js sets timeouts internally for each timeago value to be updated in real time
- getTimeago().render(timeagoEls, timeagoLanguageCode);
-};
-
-/**
* For the given elements, sets a tooltip with a formatted date.
- * @param {jQuery}
+ * @param {JQuery} $timeagoEls
* @param {Boolean} setTimeago
*/
export const localTimeAgo = ($timeagoEls, setTimeago = true) => {
+ getTimeago();
+
$timeagoEls.each((i, el) => {
- if (setTimeago) {
+ $(el).text(timeagoInstance.format($(el).attr('datetime'), timeagoLanguageCode));
+ });
+
+ if (!setTimeago) {
+ return;
+ }
+
+ function addTimeAgoTooltip() {
+ $timeagoEls.each((i, el) => {
// Recreate with custom template
$(el).tooltip({
template:
'<div class="tooltip local-timeago" role="tooltip"><div class="arrow"></div><div class="tooltip-inner"></div></div>',
});
- }
-
- el.classList.add('js-timeago-render');
- });
+ });
+ }
- renderTimeago($timeagoEls);
+ requestIdleCallback(addTimeAgoTooltip);
};
/**
@@ -292,13 +333,13 @@ export const getSundays = date => {
}
const daysToSunday = [
- 'Saturday',
- 'Friday',
- 'Thursday',
- 'Wednesday',
- 'Tuesday',
- 'Monday',
- 'Sunday',
+ __('Saturday'),
+ __('Friday'),
+ __('Thursday'),
+ __('Wednesday'),
+ __('Tuesday'),
+ __('Monday'),
+ __('Sunday'),
];
const month = date.getMonth();
@@ -308,7 +349,7 @@ export const getSundays = date => {
while (dateOfMonth.getMonth() === month) {
const dayName = getDayName(dateOfMonth);
- if (dayName === 'Sunday') {
+ if (dayName === __('Sunday')) {
sundays.push(new Date(dateOfMonth.getTime()));
}
@@ -321,23 +362,35 @@ export const getSundays = date => {
/**
* Returns list of Dates representing a timeframe of months from startDate and length
+ * This method also supports going back in time when `length` is negative number
*
- * @param {Date} startDate
+ * @param {Date} initialStartDate
* @param {Number} length
*/
-export const getTimeframeWindowFrom = (startDate, length) => {
- if (!(startDate instanceof Date) || !length) {
+export const getTimeframeWindowFrom = (initialStartDate, length) => {
+ if (!(initialStartDate instanceof Date) || !length) {
return [];
}
+ const startDate = newDate(initialStartDate);
+ const moveMonthBy = length > 0 ? 1 : -1;
+
+ startDate.setDate(1);
+ startDate.setHours(0, 0, 0, 0);
+
// Iterate and set date for the size of length
// and push date reference to timeframe list
- const timeframe = new Array(length)
- .fill()
- .map((val, i) => new Date(startDate.getFullYear(), startDate.getMonth() + i, 1));
+ const timeframe = new Array(Math.abs(length)).fill().map(() => {
+ const currentMonth = startDate.getTime();
+ startDate.setMonth(startDate.getMonth() + moveMonthBy);
+ return new Date(currentMonth);
+ });
// Change date of last timeframe item to last date of the month
- timeframe[length - 1].setDate(totalDaysInMonth(timeframe[length - 1]));
+ // when length is positive
+ if (length > 0) {
+ timeframe[timeframe.length - 1].setDate(totalDaysInMonth(timeframe[timeframe.length - 1]));
+ }
return timeframe;
};
diff --git a/app/assets/javascripts/lib/utils/file_upload.js b/app/assets/javascripts/lib/utils/file_upload.js
index b41ffb44971..82ee83e4348 100644
--- a/app/assets/javascripts/lib/utils/file_upload.js
+++ b/app/assets/javascripts/lib/utils/file_upload.js
@@ -1,6 +1,9 @@
export default (buttonSelector, fileSelector) => {
const btn = document.querySelector(buttonSelector);
const fileInput = document.querySelector(fileSelector);
+
+ if (!btn || !fileInput) return;
+
const form = btn.closest('form');
btn.addEventListener('click', () => {
diff --git a/app/assets/javascripts/lib/utils/grammar.js b/app/assets/javascripts/lib/utils/grammar.js
new file mode 100644
index 00000000000..18f9e2ed846
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/grammar.js
@@ -0,0 +1,40 @@
+import { sprintf, s__ } from '~/locale';
+
+/**
+ * Combines each given item into a noun series sentence fragment. It does this
+ * in a way that supports i18n by giving context and punctuation to the locale
+ * functions.
+ *
+ * **Examples:**
+ *
+ * - `["A", "B"] => "A and B"`
+ * - `["A", "B", "C"] => "A, B, and C"`
+ *
+ * **Why only nouns?**
+ *
+ * Some languages need a bit more context to translate other series.
+ *
+ * @param {String[]} items
+ */
+export const toNounSeriesText = items => {
+ if (items.length === 0) {
+ return '';
+ } else if (items.length === 1) {
+ return items[0];
+ } else if (items.length === 2) {
+ return sprintf(s__('nounSeries|%{firstItem} and %{lastItem}'), {
+ firstItem: items[0],
+ lastItem: items[1],
+ });
+ }
+
+ return items.reduce((item, nextItem, idx) =>
+ idx === items.length - 1
+ ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem })
+ : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }),
+ );
+};
+
+export default {
+ toNounSeriesText,
+};
diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js
new file mode 100644
index 00000000000..8f0afa3467d
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/highlight.js
@@ -0,0 +1,44 @@
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import _ from 'underscore';
+import sanitize from 'sanitize-html';
+
+/**
+ * Wraps substring matches with HTML `<span>` elements.
+ * Inputs are sanitized before highlighting, so this
+ * filter is safe to use with `v-html` (as long as `matchPrefix`
+ * and `matchSuffix` are not being dynamically generated).
+ *
+ * Note that this function can't be used inside `v-html` as a filter
+ * (Vue filters cannot be used inside `v-html`).
+ *
+ * @param {String} string The string to highlight
+ * @param {String} match The substring match to highlight in the string
+ * @param {String} matchPrefix The string to insert at the beginning of a match
+ * @param {String} matchSuffix The string to insert at the end of a match
+ */
+export default function highlight(string, match = '', matchPrefix = '<b>', matchSuffix = '</b>') {
+ if (_.isUndefined(string) || _.isNull(string)) {
+ return '';
+ }
+
+ if (_.isUndefined(match) || _.isNull(match) || match === '') {
+ return string;
+ }
+
+ const sanitizedValue = sanitize(string.toString(), { allowedTags: [] });
+
+ // occurrences is an array of character indices that should be
+ // highlighted in the original string, i.e. [3, 4, 5, 7]
+ const occurrences = fuzzaldrinPlus.match(sanitizedValue, match.toString());
+
+ return sanitizedValue
+ .split('')
+ .map((character, i) => {
+ if (_.contains(occurrences, i)) {
+ return `${matchPrefix}${character}${matchSuffix}`;
+ }
+
+ return character;
+ })
+ .join('');
+}
diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js
index 14c02218990..37ad1676f7a 100644
--- a/app/assets/javascripts/lib/utils/http_status.js
+++ b/app/assets/javascripts/lib/utils/http_status.js
@@ -16,6 +16,7 @@ const httpStatusCodes = {
IM_USED: 226,
MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400,
+ UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
diff --git a/app/assets/javascripts/lib/utils/icon_utils.js b/app/assets/javascripts/lib/utils/icon_utils.js
new file mode 100644
index 00000000000..7b8dd9bbef7
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/icon_utils.js
@@ -0,0 +1,18 @@
+/* eslint-disable import/prefer-default-export */
+
+import axios from '~/lib/utils/axios_utils';
+
+/**
+ * Retrieve SVG icon path content from gitlab/svg sprite icons
+ * @param {String} name
+ */
+export const getSvgIconPathContent = name =>
+ axios
+ .get(gon.sprite_icons)
+ .then(({ data: svgs }) =>
+ new DOMParser()
+ .parseFromString(svgs, 'text/xml')
+ .querySelector(`#${name} path`)
+ .getAttribute('d'),
+ )
+ .catch(() => null);
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js
index 2ccc51c35f7..9ddfb4bca11 100644
--- a/app/assets/javascripts/lib/utils/number_utils.js
+++ b/app/assets/javascripts/lib/utils/number_utils.js
@@ -1,4 +1,5 @@
import { BYTES_IN_KIB } from './constants';
+import { sprintf, __ } from '~/locale';
/**
* Function that allows a number with an X amount of decimals
@@ -72,11 +73,30 @@ export function bytesToGiB(number) {
*/
export function numberToHumanSize(size) {
if (size < BYTES_IN_KIB) {
- return `${size} bytes`;
+ return sprintf(__('%{size} bytes'), { size });
} else if (size < BYTES_IN_KIB * BYTES_IN_KIB) {
- return `${bytesToKiB(size).toFixed(2)} KiB`;
+ return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) });
} else if (size < BYTES_IN_KIB * BYTES_IN_KIB * BYTES_IN_KIB) {
- return `${bytesToMiB(size).toFixed(2)} MiB`;
+ return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) });
}
- return `${bytesToGiB(size).toFixed(2)} GiB`;
+ return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) });
}
+
+/**
+ * A simple method that returns the value of a + b
+ * It seems unessesary, but when combined with a reducer it
+ * adds up all the values in an array.
+ *
+ * e.g. `[1, 2, 3, 4, 5].reduce(sum) // => 15`
+ *
+ * @param {Float} a
+ * @param {Float} b
+ * @example
+ * // return 15
+ * [1, 2, 3, 4, 5].reduce(sum);
+ *
+ * // returns 6
+ * Object.values([{a: 1, b: 2, c: 3].reduce(sum);
+ * @returns {Float} The summed value
+ */
+export const sum = (a = 0, b = 0) => a + b;
diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js
index 198711cf427..a900ff34bf5 100644
--- a/app/assets/javascripts/lib/utils/poll.js
+++ b/app/assets/javascripts/lib/utils/poll.js
@@ -63,6 +63,10 @@ export default class Poll {
const headers = normalizeHeaders(response.headers);
const pollInterval = parseInt(headers[this.intervalHeader], 10);
if (pollInterval > 0 && successCodes.indexOf(response.status) !== -1 && this.canPoll) {
+ if (this.timeoutID) {
+ clearTimeout(this.timeoutID);
+ }
+
this.timeoutID = setTimeout(() => {
this.makeRequest();
}, pollInterval);
@@ -101,15 +105,25 @@ export default class Poll {
}
/**
- * Restarts polling after it has been stoped
+ * Enables polling after it has been stopped
*/
- restart(options) {
- // update data
+ enable(options) {
if (options && options.data) {
this.options.data = options.data;
}
this.canPoll = true;
+
+ if (options && options.response) {
+ this.checkConditions(options.response);
+ }
+ }
+
+ /**
+ * Restarts polling after it has been stopped and makes a request
+ */
+ restart(options) {
+ this.enable(options);
this.makeRequest();
}
}
diff --git a/app/assets/javascripts/lib/utils/simple_poll.js b/app/assets/javascripts/lib/utils/simple_poll.js
index 473f179ad86..576a9ec880c 100644
--- a/app/assets/javascripts/lib/utils/simple_poll.js
+++ b/app/assets/javascripts/lib/utils/simple_poll.js
@@ -1,10 +1,10 @@
-export default (fn, interval = 2000, timeout = 60000) => {
+export default (fn, { interval = 2000, timeout = 60000 } = {}) => {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg));
const next = () => {
- if (Date.now() - startTime < timeout) {
+ if (timeout === 0 || Date.now() - startTime < timeout) {
setTimeout(fn.bind(null, next, stop), interval);
} else {
reject(new Error('SIMPLE_POLL_TIMEOUT'));
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index c095a017866..84a617acb42 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -8,6 +8,10 @@ function selectedText(text, textarea) {
return text.substring(textarea.selectionStart, textarea.selectionEnd);
}
+function addBlockTags(blockTag, selected) {
+ return `${blockTag}\n${selected}\n${blockTag}`;
+}
+
function lineBefore(text, textarea) {
var split;
split = text
@@ -24,19 +28,45 @@ function lineAfter(text, textarea) {
.split('\n')[0];
}
+function editorBlockTagText(text, blockTag, selected, editor) {
+ const lines = text.split('\n');
+ const selectionRange = editor.getSelectionRange();
+ const shouldRemoveBlock =
+ lines[selectionRange.start.row - 1] === blockTag &&
+ lines[selectionRange.end.row + 1] === blockTag;
+
+ if (shouldRemoveBlock) {
+ if (blockTag !== null) {
+ // ace is globally defined
+ // eslint-disable-next-line no-undef
+ const { Range } = ace.require('ace/range');
+ const lastLine = lines[selectionRange.end.row + 1];
+ const rangeWithBlockTags = new Range(
+ lines[selectionRange.start.row - 1],
+ 0,
+ selectionRange.end.row + 1,
+ lastLine.length,
+ );
+ editor.getSelection().setSelectionRange(rangeWithBlockTags);
+ }
+ return selected;
+ }
+ return addBlockTags(blockTag, selected);
+}
+
function blockTagText(text, textArea, blockTag, selected) {
- const before = lineBefore(text, textArea);
- const after = lineAfter(text, textArea);
- if (before === blockTag && after === blockTag) {
+ const shouldRemoveBlock =
+ lineBefore(text, textArea) === blockTag && lineAfter(text, textArea) === blockTag;
+
+ if (shouldRemoveBlock) {
// To remove the block tag we have to select the line before & after
if (blockTag != null) {
textArea.selectionStart = textArea.selectionStart - (blockTag.length + 1);
textArea.selectionEnd = textArea.selectionEnd + (blockTag.length + 1);
}
return selected;
- } else {
- return blockTag + '\n' + selected + '\n' + blockTag;
}
+ return addBlockTags(blockTag, selected);
}
function moveCursor({
@@ -46,33 +76,48 @@ function moveCursor({
positionBetweenTags,
removedLastNewLine,
select,
+ editor,
+ editorSelectionStart,
+ editorSelectionEnd,
}) {
var pos;
- if (!textArea.setSelectionRange) {
+ if (textArea && !textArea.setSelectionRange) {
return;
}
if (select && select.length > 0) {
- // calculate the part of the text to be selected
- const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select));
- const endPosition = startPosition + select.length;
- return textArea.setSelectionRange(startPosition, endPosition);
- }
- if (textArea.selectionStart === textArea.selectionEnd) {
- if (positionBetweenTags) {
- pos = textArea.selectionStart - tag.length;
- } else {
- pos = textArea.selectionStart;
+ if (textArea) {
+ // calculate the part of the text to be selected
+ const startPosition = textArea.selectionStart - (tag.length - tag.indexOf(select));
+ const endPosition = startPosition + select.length;
+ return textArea.setSelectionRange(startPosition, endPosition);
+ } else if (editor) {
+ editor.navigateLeft(tag.length - tag.indexOf(select));
+ editor.getSelection().selectAWord();
+ return;
}
+ }
+ if (textArea) {
+ if (textArea.selectionStart === textArea.selectionEnd) {
+ if (positionBetweenTags) {
+ pos = textArea.selectionStart - tag.length;
+ } else {
+ pos = textArea.selectionStart;
+ }
- if (removedLastNewLine) {
- pos -= 1;
- }
+ if (removedLastNewLine) {
+ pos -= 1;
+ }
- if (cursorOffset) {
- pos -= cursorOffset;
- }
+ if (cursorOffset) {
+ pos -= cursorOffset;
+ }
- return textArea.setSelectionRange(pos, pos);
+ return textArea.setSelectionRange(pos, pos);
+ }
+ } else if (editor && editorSelectionStart.row === editorSelectionEnd.row) {
+ if (positionBetweenTags) {
+ editor.navigateLeft(tag.length);
+ }
}
}
@@ -82,9 +127,10 @@ export function insertMarkdownText({
tag,
cursorOffset,
blockTag,
- selected,
+ selected = '',
wrap,
select,
+ editor,
}) {
var textToInsert,
selectedSplit,
@@ -92,11 +138,20 @@ export function insertMarkdownText({
removedLastNewLine,
removedFirstNewLine,
currentLineEmpty,
- lastNewLine;
+ lastNewLine,
+ editorSelectionStart,
+ editorSelectionEnd;
removedLastNewLine = false;
removedFirstNewLine = false;
currentLineEmpty = false;
+ if (editor) {
+ const selectionRange = editor.getSelectionRange();
+
+ editorSelectionStart = selectionRange.start;
+ editorSelectionEnd = selectionRange.end;
+ }
+
// check for link pattern and selected text is an URL
// if so fill in the url part instead of the text part of the pattern.
if (tag === LINK_TAG_PATTERN) {
@@ -119,14 +174,27 @@ export function insertMarkdownText({
}
// Remove the last newline
- if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
- removedLastNewLine = true;
- selected = selected.replace(/\n$/, '');
+ if (textArea) {
+ if (textArea.selectionEnd - textArea.selectionStart > selected.replace(/\n$/, '').length) {
+ removedLastNewLine = true;
+ selected = selected.replace(/\n$/, '');
+ }
+ } else if (editor) {
+ if (editorSelectionStart.row !== editorSelectionEnd.row) {
+ removedLastNewLine = true;
+ selected = selected.replace(/\n$/, '');
+ }
}
selectedSplit = selected.split('\n');
- if (!wrap) {
+ if (editor && !wrap) {
+ lastNewLine = editor.getValue().split('\n')[editorSelectionStart.row];
+
+ if (/^\s*$/.test(lastNewLine)) {
+ currentLineEmpty = true;
+ }
+ } else if (textArea && !wrap) {
lastNewLine = textArea.value.substr(0, textArea.selectionStart).lastIndexOf('\n');
// Check whether the current line is empty or consists only of spaces(=handle as empty)
@@ -135,13 +203,19 @@ export function insertMarkdownText({
}
}
- startChar = !wrap && !currentLineEmpty && textArea.selectionStart > 0 ? '\n' : '';
+ const isBeginning =
+ (textArea && textArea.selectionStart === 0) ||
+ (editor && editorSelectionStart.column === 0 && editorSelectionStart.row === 0);
+
+ startChar = !wrap && !currentLineEmpty && !isBeginning ? '\n' : '';
const textPlaceholder = '{text}';
if (selectedSplit.length > 1 && (!wrap || (blockTag != null && blockTag !== ''))) {
if (blockTag != null && blockTag !== '') {
- textToInsert = blockTagText(text, textArea, blockTag, selected);
+ textToInsert = editor
+ ? editorBlockTagText(text, blockTag, selected, editor)
+ : blockTagText(text, textArea, blockTag, selected);
} else {
textToInsert = selectedSplit
.map(function(val) {
@@ -170,7 +244,11 @@ export function insertMarkdownText({
textToInsert += '\n';
}
- insertText(textArea, textToInsert);
+ if (editor) {
+ editor.insert(textToInsert);
+ } else {
+ insertText(textArea, textToInsert);
+ }
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
@@ -178,6 +256,9 @@ export function insertMarkdownText({
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
+ editor,
+ editorSelectionStart,
+ editorSelectionEnd,
});
}
@@ -212,8 +293,27 @@ export function addMarkdownListeners(form) {
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
- tagContent: $this.data('mdTagContent').toString(),
+ tagContent: $this.data('mdTagContent'),
+ });
+ });
+}
+
+export function addEditorMarkdownListeners(editor) {
+ $('.js-md')
+ .off('click')
+ .on('click', function(e) {
+ const { mdTag, mdBlock, mdPrepend, mdSelect } = $(e.currentTarget).data();
+
+ insertMarkdownText({
+ tag: mdTag,
+ blockTag: mdBlock,
+ wrap: !mdPrepend,
+ select: mdSelect,
+ selected: editor.getSelectedText(),
+ text: editor.getValue(),
+ editor,
});
+ editor.focus();
});
}
diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js
index 7cc7cd6d20e..cc1d85fd97d 100644
--- a/app/assets/javascripts/lib/utils/text_utility.js
+++ b/app/assets/javascripts/lib/utils/text_utility.js
@@ -1,3 +1,5 @@
+import _ from 'underscore';
+
/**
* Adds a , to a string composed by numbers, at every 3 chars.
*
@@ -42,18 +44,18 @@ export const pluralize = (str, count) => str + (count > 1 || count === 0 ? 's' :
export const dasherize = str => str.replace(/[_\s]+/g, '-');
/**
- * Removes accents and converts to lower case
+ * Replaces whitespaces with hyphens and converts to lower case
* @param {String} str
* @returns {String}
*/
-export const slugify = str => str.trim().toLowerCase();
+export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
/**
- * Replaces whitespaces with hyphens and converts to lower case
+ * Replaces whitespaces with underscore and converts to lower case
* @param {String} str
* @returns {String}
*/
-export const slugifyWithHyphens = str => str.toLowerCase().replace(/\s+/g, '-');
+export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_');
/**
* Truncates given text
@@ -72,6 +74,29 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3
*/
export const truncateSha = sha => sha.substr(0, 8);
+const ELLIPSIS_CHAR = '…';
+export const truncatePathMiddleToLength = (text, maxWidth) => {
+ let returnText = text;
+ let ellipsisCount = 0;
+
+ while (returnText.length >= maxWidth) {
+ const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR);
+ const middleIndex = Math.floor(textSplit.length / 2);
+
+ returnText = textSplit
+ .slice(0, middleIndex)
+ .concat(
+ new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR),
+ textSplit.slice(middleIndex + 1),
+ )
+ .join('/');
+
+ ellipsisCount += 1;
+ }
+
+ return returnText;
+};
+
/**
* Capitalizes first character
*
@@ -137,3 +162,33 @@ export const splitCamelCase = string =>
.replace(/([A-Z]+)([A-Z][a-z])/g, ' $1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
.trim();
+
+/**
+ * Intelligently truncates an item's namespace by doing two things:
+ * 1. Only include group names in path by removing the item name
+ * 2. Only include the first and last group names in the path
+ * when the namespace includes more than 2 groups
+ *
+ * @param {String} string A string namespace,
+ * i.e. "My Group / My Subgroup / My Project"
+ */
+export const truncateNamespace = (string = '') => {
+ if (_.isNull(string) || !_.isString(string)) {
+ return '';
+ }
+
+ const namespaceArray = string.split(' / ');
+
+ if (namespaceArray.length === 1) {
+ return string;
+ }
+
+ namespaceArray.splice(-1, 1);
+ let namespace = namespaceArray.join(' / ');
+
+ if (namespaceArray.length > 2) {
+ namespace = `${namespaceArray[0]} / ... / ${namespaceArray.pop()}`;
+ }
+
+ return namespace;
+};
diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js
index 9850f7ce782..bdfd06fc250 100644
--- a/app/assets/javascripts/lib/utils/url_utility.js
+++ b/app/assets/javascripts/lib/utils/url_utility.js
@@ -42,22 +42,35 @@ export function mergeUrlParams(params, url) {
return `${urlparts[1]}?${query}${urlparts[3]}`;
}
-export function removeParamQueryString(url, param) {
- const decodedUrl = decodeURIComponent(url);
- const urlVariables = decodedUrl.split('&');
-
- return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&');
-}
-
-export function removeParams(params, source = window.location.href) {
- const url = document.createElement('a');
- url.href = source;
+/**
+ * Removes specified query params from the url by returning a new url string that no longer
+ * includes the param/value pair. If no url is provided, `window.location.href` is used as
+ * the default value.
+ *
+ * @param {string[]} params - the query param names to remove
+ * @param {string} [url=windowLocation().href] - url from which the query param will be removed
+ * @returns {string} A copy of the original url but without the query param
+ */
+export function removeParams(params, url = window.location.href) {
+ const [rootAndQuery, fragment] = url.split('#');
+ const [root, query] = rootAndQuery.split('?');
+
+ if (!query) {
+ return url;
+ }
- params.forEach(param => {
- url.search = removeParamQueryString(url.search, param);
- });
+ const encodedParams = params.map(param => encodeURIComponent(param));
+ const updatedQuery = query
+ .split('&')
+ .filter(paramPair => {
+ const [foundParam] = paramPair.split('=');
+ return encodedParams.indexOf(foundParam) < 0;
+ })
+ .join('&');
- return url.href;
+ const writableQuery = updatedQuery.length > 0 ? `?${updatedQuery}` : '';
+ const writableFragment = fragment ? `#${fragment}` : '';
+ return `${root}${writableQuery}${writableFragment}`;
}
export function getLocationHash(url = window.location.href) {
@@ -66,6 +79,20 @@ export function getLocationHash(url = window.location.href) {
return hashIndex === -1 ? null : url.substring(hashIndex + 1);
}
+/**
+ * Apply the fragment to the given url by returning a new url string that includes
+ * the fragment. If the given url already contains a fragment, the original fragment
+ * will be removed.
+ *
+ * @param {string} url - url to which the fragment will be applied
+ * @param {string} fragment - fragment to append
+ */
+export const setUrlFragment = (url, fragment) => {
+ const [rootUrl] = url.split('#');
+ const encodedFragment = encodeURIComponent(fragment.replace(/^#/, ''));
+ return `${rootUrl}#${encodedFragment}`;
+};
+
export function visitUrl(url, external = false) {
if (external) {
// Simulate `target="blank" rel="noopener noreferrer"`
@@ -93,3 +120,5 @@ export function webIDEUrl(route = undefined) {
}
return returnUrl;
}
+
+export { join as joinPaths } from 'path';
diff --git a/app/assets/javascripts/lib/utils/webpack.js b/app/assets/javascripts/lib/utils/webpack.js
new file mode 100644
index 00000000000..37b17f0fe23
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/webpack.js
@@ -0,0 +1,18 @@
+import { joinPaths } from '~/lib/utils/url_utility';
+
+// tell webpack to load assets from origin so that web workers don't break
+// eslint-disable-next-line import/prefer-default-export
+export function resetServiceWorkersPublicPath() {
+ // __webpack_public_path__ is a global variable that can be used to adjust
+ // the webpack publicPath setting at runtime.
+ // see: https://webpack.js.org/guides/public-path/
+ const relativeRootPath = (gon && gon.relative_url_root) || '';
+ const webpackAssetPath = joinPaths(relativeRootPath, '/assets/webpack/');
+ __webpack_public_path__ = webpackAssetPath; // eslint-disable-line camelcase
+
+ // monaco-editor-webpack-plugin currently (incorrectly) references the
+ // public path as a property of `window`. Once this is fixed upstream we
+ // can remove this line
+ // see: https://github.com/Microsoft/monaco-editor-webpack-plugin/pull/63
+ window.__webpack_public_path__ = webpackAssetPath; // eslint-disable-line
+}
diff --git a/app/assets/javascripts/locale/index.js b/app/assets/javascripts/locale/index.js
index 1ae3362c4bc..41aa0f4ddb9 100644
--- a/app/assets/javascripts/locale/index.js
+++ b/app/assets/javascripts/locale/index.js
@@ -11,7 +11,7 @@ delete window.translations;
@param text The text to be translated
@returns {String} The translated text
*/
-const gettext = text => locale.gettext.bind(locale)(ensureSingleLine(text));
+const gettext = text => locale.gettext(ensureSingleLine(text));
/**
Translate the text with a number
diff --git a/app/assets/javascripts/locale/sprintf.js b/app/assets/javascripts/locale/sprintf.js
index 5246c49842e..68b64a3a16a 100644
--- a/app/assets/javascripts/locale/sprintf.js
+++ b/app/assets/javascripts/locale/sprintf.js
@@ -4,8 +4,8 @@ import _ from 'underscore';
Very limited implementation of sprintf supporting only named parameters.
@param input (translated) text with parameters (e.g. '%{num_users} users use us')
- @param parameters object mapping parameter names to values (e.g. { num_users: 5 })
- @param escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
+ @param {Object} parameters object mapping parameter names to values (e.g. { num_users: 5 })
+ @param {Boolean} escapeParameters whether parameter values should be escaped (see http://underscorejs.org/#escape)
@returns {String} the text with parameters replaces (e.g. '5 users use us')
@see https://ruby-doc.org/core-2.3.3/Kernel.html#method-i-sprintf
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index c866e8d180a..9f30a989295 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -31,6 +31,7 @@ import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
+import { __ } from './locale';
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
@@ -66,15 +67,11 @@ gl.lazyLoader = new LazyLoader({
observerNode: '#content-body',
});
-document.addEventListener('DOMContentLoaded', () => {
+// Put all initialisations here that can also wait after everything is rendered and ready
+function deferredInitialisation() {
const $body = $('body');
- const $document = $(document);
- const $window = $(window);
- const $sidebarGutterToggle = $('.js-sidebar-toggle');
- let bootstrapBreakpoint = bp.getBreakpointSize();
initBreadcrumbs();
- initLayoutNav();
initImporterStatus();
initTodoToggle();
initLogoAnimation();
@@ -82,35 +79,6 @@ document.addEventListener('DOMContentLoaded', () => {
initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
- if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
-
- // Set the default path for all cookies to GitLab's root directory
- Cookies.defaults.path = gon.relative_url_root || '/';
-
- // `hashchange` is not triggered when link target is already in window.location
- $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() {
- const href = this.getAttribute('href');
- if (href.substr(1) === getLocationHash()) {
- setTimeout(handleLocationHash, 1);
- }
- });
-
- if (bootstrapBreakpoint === 'xs') {
- const $rightSidebar = $('aside.right-sidebar, .layout-page');
-
- $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
- }
-
- // prevent default action for disabled buttons
- $('.btn').click(function clickDisabledButtonCallback(e) {
- if ($(this).hasClass('disabled')) {
- e.preventDefault();
- e.stopImmediatePropagation();
- return false;
- }
-
- return true;
- });
addSelectOnFocusBehaviour('.js-select-on-focus');
@@ -132,27 +100,30 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Initialize select2 selects
- $('select.select2').select2({
- width: 'resolve',
- dropdownAutoWidth: true,
- });
-
- // Close select2 on escape
- $('.js-select2').on('select2-close', () => {
- setTimeout(() => {
- $('.select2-container-active').removeClass('select2-container-active');
- $(':focus').blur();
- }, 1);
- });
+ if ($('select.select2').length) {
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('select.select2').select2({
+ width: 'resolve',
+ dropdownAutoWidth: true,
+ });
+
+ // Close select2 on escape
+ $('.js-select2').on('select2-close', () => {
+ setTimeout(() => {
+ $('.select2-container-active').removeClass('select2-container-active');
+ $(':focus').blur();
+ }, 1);
+ });
+ })
+ .catch(() => {});
+ }
// Initialize tooltips
$body.tooltip({
selector: '.has-tooltip, [data-toggle="tooltip"]',
trigger: 'hover',
boundary: 'viewport',
- placement(tip, el) {
- return $(el).data('placement') || 'bottom';
- },
});
// Initialize popovers
@@ -164,6 +135,68 @@ document.addEventListener('DOMContentLoaded', () => {
viewport: '.layout-page',
});
+ loadAwardsHandler();
+
+ /**
+ * Toggle Canary Badge
+ *
+ * For GitLab.com only, when the user is using canary
+ * we render a Next badge and hide the option to switch
+ * to canay
+ */
+ if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') {
+ const canaryBadge = document.querySelector('.js-canary-badge');
+ const canaryLink = document.querySelector('.js-canary-link');
+ if (canaryBadge) {
+ canaryBadge.classList.remove('hidden');
+ }
+ if (canaryLink) {
+ canaryLink.classList.add('hidden');
+ }
+ }
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const $body = $('body');
+ const $document = $(document);
+ const $window = $(window);
+ const $sidebarGutterToggle = $('.js-sidebar-toggle');
+ let bootstrapBreakpoint = bp.getBreakpointSize();
+
+ if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
+
+ initLayoutNav();
+
+ // Set the default path for all cookies to GitLab's root directory
+ Cookies.defaults.path = gon.relative_url_root || '/';
+
+ // `hashchange` is not triggered when link target is already in window.location
+ $body.on('click', 'a[href^="#"]', function clickHashLinkCallback() {
+ const href = this.getAttribute('href');
+ if (href.substr(1) === getLocationHash()) {
+ setTimeout(handleLocationHash, 1);
+ }
+ });
+
+ if (bootstrapBreakpoint === 'xs') {
+ const $rightSidebar = $('aside.right-sidebar, .layout-page');
+
+ $rightSidebar.removeClass('right-sidebar-expanded').addClass('right-sidebar-collapsed');
+ }
+
+ // prevent default action for disabled buttons
+ $('.btn').click(function clickDisabledButtonCallback(e) {
+ if ($(this).hasClass('disabled')) {
+ e.preventDefault();
+ e.stopImmediatePropagation();
+ return false;
+ }
+
+ return true;
+ });
+
+ localTimeAgo($('abbr.timeago, .js-timeago'), true);
+
// Form submitter
$('.trigger-submit').on('change', function triggerSubmitCallback() {
$(this)
@@ -171,8 +204,6 @@ document.addEventListener('DOMContentLoaded', () => {
.submit();
});
- localTimeAgo($('abbr.timeago, .js-timeago'), true);
-
// Disable form buttons while a form is submitting
$body.on('ajax:complete, ajax:beforeSend, submit', 'form', function ajaxCompleteCallback(e) {
const $buttons = $('[type="submit"], .js-disable-on-submit', this);
@@ -189,12 +220,16 @@ document.addEventListener('DOMContentLoaded', () => {
const ref = xhrObj.status;
if (ref === 401) {
- Flash('You need to be logged in.');
+ Flash(__('You need to be logged in.'));
} else if (ref === 404 || ref === 500) {
- Flash('Something went wrong on our end.');
+ Flash(__('Something went wrong on our end.'));
}
});
+ $('.navbar-toggler').on('click', () => {
+ $('.header-content').toggleClass('menu-expanded');
+ });
+
// Commit show suppressed diff
$document.on('click', '.diff-content .js-show-suppressed-diff', function showDiffCallback() {
const $container = $(this).parent();
@@ -202,10 +237,6 @@ document.addEventListener('DOMContentLoaded', () => {
$container.remove();
});
- $('.navbar-toggler').on('click', () => {
- $('.header-content').toggleClass('menu-expanded');
- });
-
// Show/hide comments on diff
$body.on('click', '.js-toggle-diff-comments', function toggleDiffCommentsCallback(e) {
const $this = $(this);
@@ -250,8 +281,6 @@ document.addEventListener('DOMContentLoaded', () => {
$window.on('resize.app', fitSidebarForSize);
- loadAwardsHandler();
-
$('form.filter-form').on('submit', function filterFormSubmitCallback(event) {
const link = document.createElement('a');
link.href = this.action;
@@ -274,4 +303,6 @@ document.addEventListener('DOMContentLoaded', () => {
// initialize field errors
$('.gl-show-field-errors').each((i, form) => new GlFieldErrors(form));
+
+ requestIdleCallback(deferredInitialisation);
});
diff --git a/app/assets/javascripts/member_expiration_date.js b/app/assets/javascripts/member_expiration_date.js
index 0beedcacf33..0dabb28ea66 100644
--- a/app/assets/javascripts/member_expiration_date.js
+++ b/app/assets/javascripts/member_expiration_date.js
@@ -33,6 +33,7 @@ export default function memberExpirationDate(selector = '.js-access-expiration-d
toggleClearInput.call($input);
},
+ firstDay: gon.first_day_of_week,
});
calendar.setDate(parsePikadayDate($input.val()));
diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js
index bd263c75a3d..af2697444f2 100644
--- a/app/assets/javascripts/members.js
+++ b/app/assets/javascripts/members.js
@@ -16,25 +16,33 @@ export default class Members {
gl.utils.disableButtonIfEmptyField('#user_ids', 'input[name=commit]', 'change');
}
+ dropdownClicked(options) {
+ this.formSubmit(null, options.$el);
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ dropdownToggleLabel(selected, $el) {
+ return $el.text();
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ dropdownIsSelectable(selected, $el) {
+ return !$el.hasClass('is-active');
+ }
+
initGLDropdown() {
$('.js-member-permissions-dropdown').each((i, btn) => {
const $btn = $(btn);
$btn.glDropdown({
selectable: true,
- isSelectable(selected, $el) {
- return !$el.hasClass('is-active');
- },
+ isSelectable: (selected, $el) => this.dropdownIsSelectable(selected, $el),
fieldName: $btn.data('fieldName'),
id(selected, $el) {
return $el.data('id');
},
- toggleLabel(selected, $el) {
- return $el.text();
- },
- clicked: options => {
- this.formSubmit(null, options.$el);
- },
+ toggleLabel: (selected, $el) => this.dropdownToggleLabel(selected, $el, $btn),
+ clicked: options => this.dropdownClicked(options),
});
});
}
@@ -55,6 +63,7 @@ export default class Members {
$toggle.enable();
$dateInput.enable();
}
+
// eslint-disable-next-line class-methods-use-this
getMemberListItems($el) {
const $memberListItem = $el.is('.member') ? $el : $(`#${$el.data('elId')}`);
diff --git a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
index c2de0379d23..3cb406b819d 100644
--- a/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
+++ b/app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
@@ -16,7 +16,7 @@ import utilsMixin from '../mixins/line_conflict_utils';
},
},
template: `
- <table>
+ <table class="diff-wrap-lines code js-syntax-highlight">
<tr class="line_holder parallel" v-for="section in file.parallelLines">
<template v-for="line in section">
<td class="diff-line-num header" :class="lineCssClass(line)" v-if="line.isHeader"></td>
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
index 0333335de06..88bc0940741 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflict_store.js
@@ -3,15 +3,16 @@
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';
+import { s__ } from '~/locale';
(global => {
global.mergeConflicts = global.mergeConflicts || {};
const diffViewType = Cookies.get('diff_view');
- const HEAD_HEADER_TEXT = 'HEAD//our changes';
- const ORIGIN_HEADER_TEXT = 'origin//their changes';
- const HEAD_BUTTON_TITLE = 'Use ours';
- const ORIGIN_BUTTON_TITLE = 'Use theirs';
+ const HEAD_HEADER_TEXT = s__('MergeConflict|HEAD//our changes');
+ const ORIGIN_HEADER_TEXT = s__('MergeConflict|origin//their changes');
+ const HEAD_BUTTON_TITLE = s__('MergeConflict|Use ours');
+ const ORIGIN_BUTTON_TITLE = s__('MergeConflict|Use theirs');
const INTERACTIVE_RESOLVE_MODE = 'interactive';
const EDIT_RESOLVE_MODE = 'edit';
const DEFAULT_RESOLVE_MODE = INTERACTIVE_RESOLVE_MODE;
@@ -173,7 +174,7 @@ import Cookies from 'js-cookie';
getConflictsCountText() {
const count = this.getConflictsCount();
- const text = count > 1 ? 'conflicts' : 'conflict';
+ const text = count > 1 ? s__('MergeConflict|conflicts') : s__('MergeConflict|conflict');
return `${count} ${text}`;
},
@@ -348,8 +349,8 @@ import Cookies from 'js-cookie';
},
getCommitButtonText() {
- const initial = 'Commit to source branch';
- const inProgress = 'Committing...';
+ const initial = s__('MergeConflict|Commit to source branch');
+ const inProgress = s__('MergeConflict|Committing...');
return this.state ? (this.state.isSubmitting ? inProgress : initial) : initial;
},
diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
index 7badd68089c..d8d203e0616 100644
--- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
+++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js
@@ -8,6 +8,7 @@ import './components/diff_file_editor';
import './components/inline_conflict_lines';
import './components/parallel_conflict_lines';
import syntaxHighlight from '../syntax_highlight';
+import { __ } from '~/locale';
export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive';
@@ -92,7 +93,7 @@ export default function initMergeConflicts() {
})
.catch(() => {
mergeConflictsStore.setSubmitState(false);
- createFlash('Failed to save merge conflicts resolutions. Please try again!');
+ createFlash(__('Failed to save merge conflicts resolutions. Please try again!'));
});
},
},
diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js
index 0deae478deb..3b42a154af8 100644
--- a/app/assets/javascripts/merge_request.js
+++ b/app/assets/javascripts/merge_request.js
@@ -2,6 +2,7 @@
import $ from 'jquery';
import { __ } from '~/locale';
+import createFlash from '~/flash';
import TaskList from './task_list';
import MergeRequestTabs from './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
@@ -35,10 +36,18 @@ function MergeRequest(opts) {
dataType: 'merge_request',
fieldName: 'description',
selector: '.detail-page-description',
+ lockVersion: this.$el.data('lockVersion'),
onSuccess: result => {
document.querySelector('#task_status').innerText = result.task_status;
document.querySelector('#task_status_short').innerText = result.task_status_short;
},
+ onError: () => {
+ createFlash(
+ __(
+ 'Someone edited this merge request at the same time you did. Please refresh the page to see changes.',
+ ),
+ );
+ },
});
}
}
diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js
index b0dc5697018..e5cf43e8289 100644
--- a/app/assets/javascripts/merge_request_tabs.js
+++ b/app/assets/javascripts/merge_request_tabs.js
@@ -21,6 +21,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility';
import syntaxHighlight from './syntax_highlight';
import Notes from './notes';
import { polyfillSticky } from './lib/utils/sticky';
+import { __ } from './locale';
// MergeRequestTabs
//
@@ -326,7 +327,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
this.toggleLoading(false);
- flash('An error occurred while fetching this tab.');
+ flash(__('An error occurred while fetching this tab.'));
});
}
@@ -398,7 +399,7 @@ export default class MergeRequestTabs {
const hash = getLocationHash();
const anchor = hash && $container.find(`.note[id="${hash}"]`);
if (anchor && anchor.length > 0) {
- const notesContent = anchor.closest('.notes_content');
+ const notesContent = anchor.closest('.notes-content');
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
Notes.instance.toggleDiffNote({
target: anchor,
@@ -416,7 +417,7 @@ export default class MergeRequestTabs {
})
.catch(() => {
this.toggleLoading(false);
- flash('An error occurred while fetching this tab.');
+ flash(__('An error occurred while fetching this tab.'));
});
}
@@ -428,7 +429,7 @@ export default class MergeRequestTabs {
}
diffViewType() {
- return $('.inline-parallel-buttons button.active').data('viewType');
+ return $('.js-diff-view-buttons button.active').data('viewType');
}
isDiffAction(action) {
diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js
index f211632cf24..6aaba4e7c74 100644
--- a/app/assets/javascripts/milestone.js
+++ b/app/assets/javascripts/milestone.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import flash from './flash';
import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover';
+import { __ } from './locale';
export default class Milestone {
constructor() {
@@ -42,7 +43,7 @@ export default class Milestone {
$(tabElId).html(data.html);
$target.addClass('is-loaded');
})
- .catch(() => flash('Error loading milestone tab'));
+ .catch(() => flash(__('Error loading milestone tab')));
}
}
diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js
index 75c18a9b6a0..43949d5cc86 100644
--- a/app/assets/javascripts/milestone_select.js
+++ b/app/assets/javascripts/milestone_select.js
@@ -56,14 +56,15 @@ export default class MilestoneSelect {
const $value = $block.find('.value');
const $loading = $block.find('.block-loading').fadeOut();
selectedMilestoneDefault = showAny ? '' : null;
- selectedMilestoneDefault = showNo && defaultNo ? 'No Milestone' : selectedMilestoneDefault;
+ selectedMilestoneDefault =
+ showNo && defaultNo ? __('No Milestone') : selectedMilestoneDefault;
selectedMilestone = $dropdown.data('selected') || selectedMilestoneDefault;
if (issueUpdateURL) {
milestoneLinkTemplate = _.template(
'<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
);
- milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
+ milestoneLinkNoneTemplate = `<span class="no-value">${__('None')}</span>`;
}
return $dropdown.glDropdown({
showMenuAbove: showMenuAbove,
@@ -74,28 +75,28 @@ export default class MilestoneSelect {
extraOptions.push({
id: null,
name: null,
- title: 'Any Milestone',
+ title: __('Any Milestone'),
});
}
if (showNo) {
extraOptions.push({
id: -1,
- name: 'No Milestone',
- title: 'No Milestone',
+ name: __('No Milestone'),
+ title: __('No Milestone'),
});
}
if (showUpcoming) {
extraOptions.push({
id: -2,
name: '#upcoming',
- title: 'Upcoming',
+ title: __('Upcoming'),
});
}
if (showStarted) {
extraOptions.push({
id: -3,
name: '#started',
- title: 'Started',
+ title: __('Started'),
});
}
if (extraOptions.length) {
diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
index 81ab9d8be4b..b39ad764f01 100644
--- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js
+++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import flash from './flash';
import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
/**
* In each pipelines table we have a mini pipeline graph for each pipeline.
@@ -98,7 +99,7 @@ export default class MiniPipelineGraph {
) {
$(button).dropdown('toggle');
}
- flash('An error occurred while fetching the builds.', 'alert');
+ flash(__('An error occurred while fetching the builds.'), 'alert');
});
}
diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js
index 196b84621b6..33e9b1c4e46 100644
--- a/app/assets/javascripts/mirrors/mirror_repos.js
+++ b/app/assets/javascripts/mirrors/mirror_repos.js
@@ -87,7 +87,7 @@ export default class MirrorRepos {
project: {
remote_mirrors_attributes: {
id: $target.data('mirrorId'),
- enabled: 0,
+ _destroy: 1,
},
},
};
diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js
index 5bdf5d6277a..bb5ae6ce2d1 100644
--- a/app/assets/javascripts/mirrors/ssh_mirror.js
+++ b/app/assets/javascripts/mirrors/ssh_mirror.js
@@ -20,15 +20,10 @@ export default class SSHMirror {
this.$btnDetectHostKeys = this.$form.find('.js-detect-host-keys');
this.$btnSSHHostsShowAdvanced = this.$form.find('.btn-show-advanced');
this.$dropdownAuthType = this.$form.find('.js-mirror-auth-type');
+ this.$hiddenAuthType = this.$form.find('.js-hidden-mirror-auth-type');
this.$wellAuthTypeChanging = this.$form.find('.js-well-changing-auth');
this.$wellPasswordAuth = this.$form.find('.js-well-password-auth');
- this.$wellSSHAuth = this.$form.find('.js-well-ssh-auth');
- this.$sshPublicKeyWrap = this.$form.find('.js-ssh-public-key-wrap');
- this.$regeneratePublicSshKeyButton = this.$wellSSHAuth.find('.js-btn-regenerate-ssh-key');
- this.$regeneratePublicSshKeyModal = this.$wellSSHAuth.find(
- '.js-regenerate-public-ssh-key-confirm-modal',
- );
}
init() {
@@ -39,15 +34,6 @@ export default class SSHMirror {
this.$dropdownAuthType.on('change', e => this.handleAuthTypeChange(e));
this.$btnDetectHostKeys.on('click', e => this.handleDetectHostKeys(e));
this.$btnSSHHostsShowAdvanced.on('click', e => this.handleSSHHostsAdvanced(e));
- this.$regeneratePublicSshKeyButton.on('click', () =>
- this.$regeneratePublicSshKeyModal.toggle(true),
- );
- $('.js-confirm', this.$regeneratePublicSshKeyModal).on('click', e =>
- this.regeneratePublicSshKey(e),
- );
- $('.js-cancel', this.$regeneratePublicSshKeyModal).on('click', () =>
- this.$regeneratePublicSshKeyModal.toggle(false),
- );
}
/**
@@ -161,53 +147,11 @@ export default class SSHMirror {
* Authentication method dropdown change event listener
*/
handleAuthTypeChange() {
- const projectMirrorAuthTypeEndpoint = `${this.$form.attr('action')}.json`;
- const $sshPublicKey = this.$sshPublicKeyWrap.find('.ssh-public-key');
const selectedAuthType = this.$dropdownAuthType.val();
this.$wellPasswordAuth.collapse('hide');
- this.$wellSSHAuth.collapse('hide');
-
- // This request should happen only if selected Auth type was SSH
- // and SSH Public key was not present on page load
- if (selectedAuthType === AUTH_METHOD.SSH && !$sshPublicKey.text().trim()) {
- if (!this.$wellSSHAuth.length) return;
-
- // Construct request body
- const authTypeData = {
- project: {
- ...this.$regeneratePublicSshKeyButton.data().projectData,
- },
- };
-
- this.$wellAuthTypeChanging.collapse('show');
- this.$dropdownAuthType.disable();
-
- axios
- .put(projectMirrorAuthTypeEndpoint, JSON.stringify(authTypeData), {
- headers: {
- 'Content-Type': 'application/json; charset=utf-8',
- },
- })
- .then(({ data }) => {
- // Show SSH public key container and fill in public key
- this.toggleAuthWell(selectedAuthType);
- this.toggleSSHAuthWellMessage(true);
- this.setSSHPublicKey(data.import_data_attributes.ssh_public_key);
-
- this.$wellAuthTypeChanging.collapse('hide');
- this.$dropdownAuthType.enable();
- })
- .catch(() => {
- Flash(__('Something went wrong on our end.'));
-
- this.$wellAuthTypeChanging.collapse('hide');
- this.$dropdownAuthType.enable();
- });
- } else {
- this.toggleAuthWell(selectedAuthType);
- this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse('show');
- }
+ this.updateHiddenAuthType(selectedAuthType);
+ this.toggleAuthWell(selectedAuthType);
}
/**
@@ -233,57 +177,12 @@ export default class SSHMirror {
*/
toggleAuthWell(authType) {
this.$wellPasswordAuth.collapse(authType === AUTH_METHOD.PASSWORD ? 'show' : 'hide');
- this.$wellSSHAuth.collapse(authType === AUTH_METHOD.SSH ? 'show' : 'hide');
+ this.updateHiddenAuthType(authType);
}
- /**
- * Toggle SSH auth information message
- */
- toggleSSHAuthWellMessage(sshKeyPresent) {
- this.$sshPublicKeyWrap.collapse(sshKeyPresent ? 'show' : 'hide');
- this.$wellSSHAuth.find('.js-ssh-public-key-present').collapse(sshKeyPresent ? 'show' : 'hide');
- this.$regeneratePublicSshKeyButton.collapse(sshKeyPresent ? 'show' : 'hide');
- this.$wellSSHAuth.find('.js-ssh-public-key-pending').collapse(sshKeyPresent ? 'hide' : 'show');
- }
-
- /**
- * Sets SSH Public key to Clipboard button and shows it on UI.
- */
- setSSHPublicKey(sshPublicKey) {
- this.$sshPublicKeyWrap.find('.ssh-public-key').text(sshPublicKey);
- this.$sshPublicKeyWrap
- .find('.btn-copy-ssh-public-key')
- .attr('data-clipboard-text', sshPublicKey);
- }
-
- regeneratePublicSshKey(event) {
- event.preventDefault();
-
- this.$regeneratePublicSshKeyModal.toggle(false);
-
- const button = this.$regeneratePublicSshKeyButton;
- const spinner = $('.js-spinner', button);
- const endpoint = button.data('endpoint');
- const authTypeData = {
- project: {
- ...this.$regeneratePublicSshKeyButton.data().projectData,
- },
- };
-
- button.attr('disabled', 'disabled');
- spinner.removeClass('d-none');
-
- axios
- .patch(endpoint, authTypeData)
- .then(({ data }) => {
- button.removeAttr('disabled');
- spinner.addClass('d-none');
-
- this.setSSHPublicKey(data.import_data_attributes.ssh_public_key);
- })
- .catch(() => {
- Flash(_('Unable to regenerate public ssh key.'));
- });
+ updateHiddenAuthType(authType) {
+ this.$hiddenAuthType.val(authType);
+ this.$hiddenAuthType.prop('disabled', authType === AUTH_METHOD.SSH);
}
destroy() {
@@ -292,8 +191,5 @@ export default class SSHMirror {
this.$dropdownAuthType.off('change');
this.$btnDetectHostKeys.off('click');
this.$btnSSHHostsShowAdvanced.off('click');
- this.$regeneratePublicSshKeyButton.off('click');
- $('.js-confirm', this.$regeneratePublicSshKeyModal).off('click');
- $('.js-cancel', this.$regeneratePublicSshKeyModal).off('click');
}
}
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
index 12224e36ba2..c43791f2426 100644
--- a/app/assets/javascripts/monitoring/components/charts/area.vue
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -1,18 +1,27 @@
<script>
-import { GlAreaChart } from '@gitlab/ui';
+import { GlAreaChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
+import Icon from '~/vue_shared/components/icon.vue';
+import { chartHeight, graphTypes, lineTypes } from '../../constants';
+import { makeDataSeries } from '~/helpers/monitor_helper';
+
+let debouncedResize;
export default {
components: {
GlAreaChart,
+ GlChartSeriesLabel,
+ Icon,
},
+ inheritAttrs: false,
props: {
graphData: {
type: Object,
required: true,
validator(data) {
return (
- data.queries &&
Array.isArray(data.queries) &&
data.queries.filter(query => {
if (Array.isArray(query.result)) {
@@ -25,19 +34,67 @@ export default {
);
},
},
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ deploymentData: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ thresholds: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ tooltip: {
+ title: '',
+ content: [],
+ isDeployment: false,
+ sha: '',
+ },
+ width: 0,
+ height: chartHeight,
+ svgs: {},
+ primaryColor: null,
+ };
},
computed: {
chartData() {
- return this.graphData.queries.reduce((accumulator, query) => {
- const xLabel = `${query.unit}`;
- accumulator[xLabel] = {};
- query.result.forEach(res =>
- res.values.forEach(v => {
- accumulator[xLabel][v.time.toISOString()] = v.value;
- }),
- );
- return accumulator;
- }, {});
+ // Transforms & supplements query data to render appropriate labels & styles
+ // Input: [{ queryAttributes1 }, { queryAttributes2 }]
+ // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
+ return this.graphData.queries.reduce((acc, query) => {
+ const { appearance } = query;
+ const lineType =
+ appearance && appearance.line && appearance.line.type
+ ? appearance.line.type
+ : lineTypes.default;
+ const lineWidth =
+ appearance && appearance.line && appearance.line.width
+ ? appearance.line.width
+ : undefined;
+
+ const series = makeDataSeries(query.result, {
+ name: this.formatLegendLabel(query),
+ lineStyle: {
+ type: lineType,
+ width: lineWidth,
+ },
+ areaStyle: {
+ opacity:
+ appearance && appearance.area && typeof appearance.area.opacity === 'number'
+ ? appearance.area.opacity
+ : undefined,
+ },
+ });
+
+ return acc.concat(series);
+ }, []);
},
chartOptions() {
return {
@@ -45,53 +102,185 @@ export default {
name: 'Time',
type: 'time',
axisLabel: {
- formatter: date => dateFormat(date, 'h:MMtt'),
+ formatter: date => dateFormat(date, 'h:MM TT'),
},
- nameTextStyle: {
- padding: [18, 0, 0, 0],
+ axisPointer: {
+ snap: true,
},
},
yAxis: {
- name: this.graphData.y_label,
+ name: this.yAxisLabel,
axisLabel: {
formatter: value => value.toFixed(3),
},
- nameTextStyle: {
- padding: [0, 0, 36, 0],
- },
},
- legend: {
- formatter: this.xAxisLabel,
+ series: this.scatterSeries,
+ dataZoom: this.dataZoomConfig,
+ };
+ },
+ dataZoomConfig() {
+ const handleIcon = this.svgs['scroll-handle'];
+
+ return handleIcon ? { handleIcon } : {};
+ },
+ earliestDatapoint() {
+ return this.chartData.reduce((acc, series) => {
+ const { data } = series;
+ const { length } = data;
+ if (!length) {
+ return acc;
+ }
+
+ const [first] = data[0];
+ const [last] = data[length - 1];
+ const seriesEarliest = first < last ? first : last;
+
+ return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
+ }, null);
+ },
+ isMultiSeries() {
+ return this.tooltip.content.length > 1;
+ },
+ recentDeployments() {
+ return this.deploymentData.reduce((acc, deployment) => {
+ if (deployment.created_at >= this.earliestDatapoint) {
+ acc.push({
+ id: deployment.id,
+ createdAt: deployment.created_at,
+ sha: deployment.sha,
+ commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
+ tag: deployment.tag,
+ tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
+ ref: deployment.ref.name,
+ showDeploymentFlag: false,
+ });
+ }
+
+ return acc;
+ }, []);
+ },
+ scatterSeries() {
+ return {
+ type: graphTypes.deploymentData,
+ data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
+ symbol: this.svgs.rocket,
+ symbolSize: 14,
+ itemStyle: {
+ color: this.primaryColor,
},
};
},
- xAxisLabel() {
- return this.graphData.queries.map(query => query.label).join(', ');
+ yAxisLabel() {
+ return `${this.graphData.y_label}`;
},
},
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ this.setSvg('rocket');
+ this.setSvg('scroll-handle');
+ },
methods: {
+ formatLegendLabel(query) {
+ return `${query.label}`;
+ },
formatTooltipText(params) {
- const [date, value] = params;
- return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
+ this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ this.tooltip.content = [];
+ params.seriesData.forEach(seriesData => {
+ if (seriesData.componentSubType === graphTypes.deploymentData) {
+ this.tooltip.isDeployment = true;
+ const [deploy] = this.recentDeployments.filter(
+ deployment => deployment.createdAt === seriesData.value[0],
+ );
+ this.tooltip.sha = deploy.sha.substring(0, 8);
+ } else {
+ const { seriesName, color } = seriesData;
+ // seriesData.value contains the chart's [x, y] value pair
+ // seriesData.value[1] is threfore the chart y value
+ const value = seriesData.value[1].toFixed(3);
+
+ this.tooltip.content.push({
+ name: seriesName,
+ value,
+ color,
+ });
+ }
+ });
+ },
+ setSvg(name) {
+ getSvgIconPathContent(name)
+ .then(path => {
+ if (path) {
+ this.$set(this.svgs, name, `path://${path}`);
+ }
+ })
+ .catch(() => {});
+ },
+ onChartUpdated(chart) {
+ [this.primaryColor] = chart.getOption().color;
},
- onCreated(chart) {
- this.$emit('created', chart);
+ onResize() {
+ const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
},
},
};
</script>
<template>
- <div class="prometheus-graph">
+ <div class="prometheus-graph col-12 col-lg-6">
<div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets"><slot></slot></div>
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
+ ref="areaChart"
+ v-bind="$attrs"
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
- @created="onCreated"
- />
+ :thresholds="thresholds"
+ :width="width"
+ :height="height"
+ @updated="onChartUpdated"
+ >
+ <template v-if="tooltip.isDeployment">
+ <template slot="tooltipTitle">
+ {{ __('Deployed') }}
+ </template>
+ <div slot="tooltipContent" class="d-flex align-items-center">
+ <icon name="commit" class="mr-2" />
+ {{ tooltip.sha }}
+ </div>
+ </template>
+ <template v-else>
+ <template slot="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </template>
+ <template slot="tooltipContent">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
+ </div>
+ </template>
+ </template>
+ </gl-area-chart>
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 2d9c5050c9b..a55a47c277d 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,31 +1,49 @@
<script>
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlModalDirective,
+ GlLink,
+} from '@gitlab/ui';
import _ from 'underscore';
+import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
-import Flash from '../../flash';
-import MonitoringService from '../services/monitoring_service';
+import '~/vue_shared/mixins/is_ee';
+import { getParameterValues } from '~/lib/utils/url_utility';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
-import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
-import MonitoringStore from '../stores/monitoring_store';
-import eventHub from '../event_hub';
+import { timeWindows, timeWindowsKeyNames } from '../constants';
+import { getTimeDiff } from '../utils';
+
+const sidebarAnimationDuration = 150;
+let sidebarMutationObserver;
export default {
components: {
MonitorAreaChart,
- Graph,
GraphGroup,
EmptyState,
Icon,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
},
props: {
- hasMetrics: {
- type: Boolean,
+ externalDashboardPath: {
+ type: String,
required: false,
- default: true,
+ default: '',
},
- showLegend: {
+ hasMetrics: {
type: Boolean,
required: false,
default: true,
@@ -35,11 +53,6 @@ export default {
required: false,
default: true,
},
- forceSmallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
documentationPath: {
type: String,
required: true,
@@ -93,145 +106,237 @@ export default {
type: String,
required: true,
},
+ showTimeWindowDropdown: {
+ type: Boolean,
+ required: true,
+ },
+ customMetricsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customMetricsPath: {
+ type: String,
+ required: true,
+ },
+ validateQueryPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- store: new MonitoringStore(),
state: 'gettingStarted',
- showEmptyState: true,
- hoverData: {},
elWidth: 0,
+ selectedTimeWindow: '',
+ selectedTimeWindowKey: '',
+ formIsValid: null,
};
},
computed: {
- graphComponent() {
- return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
- },
- forceRedraw() {
- return this.elWidth;
+ canAddMetrics() {
+ return this.customMetricsAvailable && this.customMetricsPath.length;
},
+ ...mapState('monitoringDashboard', [
+ 'groups',
+ 'emptyState',
+ 'showEmptyState',
+ 'environments',
+ 'deploymentData',
+ ]),
},
created() {
- this.service = new MonitoringService({
+ this.setEndpoints({
metricsEndpoint: this.metricsEndpoint,
- deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
+ deploymentsEndpoint: this.deploymentEndpoint,
});
- this.mutationObserverConfig = {
- attributes: true,
- childList: false,
- subtree: false,
- };
- eventHub.$on('hoverChanged', this.hoverChanged);
+
+ this.timeWindows = timeWindows;
+ this.selectedTimeWindowKey =
+ _.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours;
+
+ // Set default time window if the selectedTimeWindowKey is bogus
+ if (!Object.keys(this.timeWindows).includes(this.selectedTimeWindowKey)) {
+ this.selectedTimeWindowKey = timeWindowsKeyNames.eightHours;
+ }
+
+ this.selectedTimeWindow = this.timeWindows[this.selectedTimeWindowKey];
},
beforeDestroy() {
- eventHub.$off('hoverChanged', this.hoverChanged);
- window.removeEventListener('resize', this.resizeThrottled, false);
- this.sidebarMutationObserver.disconnect();
+ if (sidebarMutationObserver) {
+ sidebarMutationObserver.disconnect();
+ }
},
mounted() {
- this.resizeThrottled = _.debounce(this.resize, 100);
if (!this.hasMetrics) {
- this.state = 'gettingStarted';
+ this.setGettingStartedEmptyState();
} else {
- this.getGraphsData();
- window.addEventListener('resize', this.resizeThrottled, false);
+ this.fetchData(getTimeDiff(this.timeWindows.eightHours));
- const sidebarEl = document.querySelector('.nav-sidebar');
- // The sidebar listener
- this.sidebarMutationObserver = new MutationObserver(this.resizeThrottled);
- this.sidebarMutationObserver.observe(sidebarEl, this.mutationObserverConfig);
+ sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
+ sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
+ attributes: true,
+ childList: false,
+ subtree: false,
+ });
}
},
methods: {
- getGraphsData() {
- this.state = 'loading';
- Promise.all([
- this.service.getGraphsData().then(data => this.store.storeMetrics(data)),
- this.service
- .getDeploymentData()
- .then(data => this.store.storeDeploymentData(data))
- .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
- this.service
- .getEnvironmentsData()
- .then(data => this.store.storeEnvironmentsData(data))
- .catch(() => Flash(s__('Metrics|There was an error getting environments information.'))),
- ])
- .then(() => {
- if (this.store.groups.length < 1) {
- this.state = 'noData';
- return;
- }
-
- this.showEmptyState = false;
- })
- .then(this.resize)
- .catch(() => {
- this.state = 'unableToConnect';
- });
+ ...mapActions('monitoringDashboard', [
+ 'fetchData',
+ 'setGettingStartedEmptyState',
+ 'setEndpoints',
+ ]),
+ getGraphAlerts(queries) {
+ if (!this.allAlerts) return {};
+ const metricIdsForChart = queries.map(q => q.metricId);
+ return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId));
+ },
+ getGraphAlertValues(queries) {
+ return Object.values(this.getGraphAlerts(queries));
},
- resize() {
- this.elWidth = this.$el.clientWidth;
+ hideAddMetricModal() {
+ this.$refs.addMetricModal.hide();
},
- hoverChanged(data) {
- this.hoverData = data;
+ onSidebarMutation() {
+ setTimeout(() => {
+ this.elWidth = this.$el.clientWidth;
+ }, sidebarAnimationDuration);
},
+ setFormValidity(isValid) {
+ this.formIsValid = isValid;
+ },
+ submitCustomMetricsForm() {
+ this.$refs.customMetricsForm.submit();
+ },
+ activeTimeWindow(key) {
+ return this.timeWindows[key] === this.selectedTimeWindow;
+ },
+ setTimeWindowParameter(key) {
+ return `?time_window=${key}`;
+ },
+ },
+ addMetric: {
+ title: s__('Metrics|Add metric'),
+ modalId: 'add-metric',
},
};
</script>
<template>
- <div v-if="!showEmptyState" :key="forceRedraw" class="prometheus-graphs prepend-top-default">
- <div class="environments d-flex align-items-center">
- {{ s__('Metrics|Environment') }}
- <div class="dropdown prepend-left-10">
- <button class="dropdown-menu-toggle" data-toggle="dropdown" type="button">
- <span> {{ currentEnvironmentName }} </span> <icon name="chevron-down" />
- </button>
- <div
- v-if="store.environmentsData.length > 0"
- class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"
- >
- <ul>
- <li v-for="environment in store.environmentsData" :key="environment.latest.id">
- <a
- :href="environment.latest.metrics_path"
- :class="{ 'is-active': environment.latest.name == currentEnvironmentName }"
- class="dropdown-item"
+ <div v-if="!showEmptyState" class="prometheus-graphs">
+ <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between">
+ <div
+ v-if="environmentsEndpoint"
+ class="dropdowns d-flex align-items-center justify-content-between"
+ >
+ <div class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Environment') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-environments-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="currentEnvironmentName"
+ :disabled="environments.length === 0"
+ >
+ <gl-dropdown-item
+ v-for="environment in environments"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div v-if="showTimeWindowDropdown" class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Show last') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-time-window-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedTimeWindow"
+ >
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ ><gl-link :href="setTimeWindowParameter(key)">{{ value }}</gl-link></gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ </div>
+ <div class="d-flex">
+ <div v-if="isEE && canAddMetrics">
+ <gl-button
+ v-gl-modal-directive="$options.addMetric.modalId"
+ class="js-add-metric-button text-success border-success"
+ >
+ {{ $options.addMetric.title }}
+ </gl-button>
+ <gl-modal
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
+ >
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
>
- {{ environment.latest.name }}
- </a>
- </li>
- </ul>
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </gl-modal>
</div>
+ <gl-button
+ v-if="externalDashboardPath.length"
+ class="js-external-dashboard-link prepend-left-8"
+ variant="primary"
+ :href="externalDashboardPath"
+ >
+ {{ __('View full dashboard') }}
+ <icon name="external-link" />
+ </gl-button>
</div>
</div>
<graph-group
- v-for="(groupData, index) in store.groups"
+ v-for="(groupData, index) in groups"
:key="index"
:name="groupData.group"
:show-panels="showPanels"
>
- <component
- :is="graphComponent"
+ <monitor-area-chart
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
- :hover-data="hoverData"
- :deployment-data="store.deploymentData"
- :project-path="projectPath"
- :tags-path="tagsPath"
- :show-legend="showLegend"
- :small-graph="forceSmallGraph"
+ :deployment-data="deploymentData"
+ :thresholds="getGraphAlertValues(graphData.queries)"
+ :container-width="elWidth"
+ group-id="monitor-area-chart"
>
- <!-- EE content -->
- {{ null }}
- </component>
+ <alert-widget
+ v-if="isEE && prometheusAlertsAvailable && alertsEndpoint && graphData"
+ :alerts-endpoint="alertsEndpoint"
+ :relevant-queries="graphData.queries"
+ :alerts-to-manage="getGraphAlerts(graphData.queries)"
+ @setAlerts="setAlerts"
+ />
+ </monitor-area-chart>
</graph-group>
</div>
<empty-state
v-else
- :selected-state="state"
+ :selected-state="emptyState"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:clusters-path="clustersPath"
diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue
deleted file mode 100644
index 64a1df80a8e..00000000000
--- a/app/assets/javascripts/monitoring/components/graph.vue
+++ /dev/null
@@ -1,329 +0,0 @@
-<script>
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { axisLeft, axisBottom } from 'd3-axis';
-import _ from 'underscore';
-import { max, extent } from 'd3-array';
-import { select } from 'd3-selection';
-import GraphAxis from './graph/axis.vue';
-import GraphLegend from './graph/legend.vue';
-import GraphFlag from './graph/flag.vue';
-import GraphDeployment from './graph/deployment.vue';
-import GraphPath from './graph/path.vue';
-import MonitoringMixin from '../mixins/monitoring_mixins';
-import eventHub from '../event_hub';
-import measurements from '../utils/measurements';
-import { bisectDate, timeScaleFormat } from '../utils/date_time_formatters';
-import createTimeSeries from '../utils/multiple_time_series';
-import bp from '../../breakpoints';
-
-const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select };
-
-export default {
- components: {
- GraphAxis,
- GraphFlag,
- GraphDeployment,
- GraphPath,
- GraphLegend,
- },
- mixins: [MonitoringMixin],
- props: {
- graphData: {
- type: Object,
- required: true,
- },
- deploymentData: {
- type: Array,
- required: true,
- },
- hoverData: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- projectPath: {
- type: String,
- required: true,
- },
- tagsPath: {
- type: String,
- required: true,
- },
- showLegend: {
- type: Boolean,
- required: false,
- default: true,
- },
- smallGraph: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- data() {
- return {
- baseGraphHeight: 450,
- baseGraphWidth: 600,
- graphHeight: 450,
- graphWidth: 600,
- graphHeightOffset: 120,
- margin: {},
- unitOfDisplay: '',
- yAxisLabel: '',
- legendTitle: '',
- reducedDeploymentData: [],
- measurements: measurements.large,
- currentData: {
- time: new Date(),
- value: 0,
- },
- currentXCoordinate: 0,
- currentCoordinates: {},
- showFlag: false,
- showFlagContent: false,
- timeSeries: [],
- graphDrawData: {},
- realPixelRatio: 1,
- seriesUnderMouse: [],
- };
- },
- computed: {
- outerViewBox() {
- return `0 0 ${this.baseGraphWidth} ${this.baseGraphHeight}`;
- },
- innerViewBox() {
- return `0 0 ${this.baseGraphWidth - 150} ${this.baseGraphHeight}`;
- },
- axisTransform() {
- return `translate(70, ${this.graphHeight - 100})`;
- },
- paddingBottomRootSvg() {
- return {
- paddingBottom: `${Math.ceil(this.baseGraphHeight * 100) / this.baseGraphWidth || 0}%`,
- };
- },
- deploymentFlagData() {
- return this.reducedDeploymentData.find(deployment => deployment.showDeploymentFlag);
- },
- shouldRenderData() {
- return this.graphData.queries.filter(s => s.result.length > 0).length > 0;
- },
- },
- watch: {
- hoverData() {
- this.positionFlag();
- },
- },
- mounted() {
- this.draw();
- },
- methods: {
- showDot(path) {
- return this.showFlagContent && this.seriesUnderMouse.includes(path);
- },
- draw() {
- const breakpointSize = bp.getBreakpointSize();
- const svgWidth = this.$refs.baseSvg.getBoundingClientRect().width;
-
- this.margin = measurements.large.margin;
-
- if (this.smallGraph || breakpointSize === 'xs' || breakpointSize === 'sm') {
- this.graphHeight = 300;
- this.margin = measurements.small.margin;
- this.measurements = measurements.small;
- }
-
- this.yAxisLabel = this.graphData.y_label || 'Values';
- this.graphWidth = svgWidth - this.margin.left - this.margin.right;
- this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
- this.baseGraphHeight = this.graphHeight - 50;
- this.baseGraphWidth = this.graphWidth;
-
- // pixel offsets inside the svg and outside are not 1:1
- this.realPixelRatio = svgWidth / this.baseGraphWidth;
-
- // set the legends on the axes
- const [query] = this.graphData.queries;
- this.legendTitle = query ? query.label : 'Average';
- this.unitOfDisplay = query ? query.unit : '';
-
- if (this.shouldRenderData) {
- this.renderAxesPaths();
- this.formatDeployments();
- }
- },
- handleMouseOverGraph(e) {
- let point = this.$refs.graphData.createSVGPoint();
- point.x = e.clientX;
- point.y = e.clientY;
- point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse());
- point.x += 7;
-
- this.seriesUnderMouse = this.timeSeries.filter(series => {
- const mouseX = series.timeSeriesScaleX.invert(point.x);
- let minDistance = Infinity;
-
- const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => {
- const distance = Math.abs(Number(new Date(x)) - Number(mouseX));
- if (distance < minDistance) {
- minDistance = distance;
- return x;
- }
- return closest;
- });
-
- return series.values.find(v => v.time.toString() === closestTickMark);
- });
-
- const firstTimeSeries = this.seriesUnderMouse[0];
- const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x);
- const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1);
- const d0 = firstTimeSeries.values[overlayIndex - 1];
- const d1 = firstTimeSeries.values[overlayIndex];
- if (d0 === undefined || d1 === undefined) return;
- const evalTime = timeValueOverlay - d0[0] > d1[0] - timeValueOverlay;
- const hoveredDataIndex = evalTime ? overlayIndex : overlayIndex - 1;
- const hoveredDate = firstTimeSeries.values[hoveredDataIndex].time;
- const currentDeployXPos = this.mouseOverDeployInfo(point.x);
-
- eventHub.$emit('hoverChanged', {
- hoveredDate,
- currentDeployXPos,
- });
- },
- renderAxesPaths() {
- ({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries(
- this.graphData.queries,
- this.graphWidth,
- this.graphHeight,
- this.graphHeightOffset,
- ));
-
- if (_.findWhere(this.timeSeries, { renderCanary: true })) {
- this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
- }
-
- const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
- const axisYScale = d3.scaleLinear().range([this.graphHeight - this.graphHeightOffset, 0]);
-
- const allValues = this.timeSeries.reduce((all, { values }) => all.concat(values), []);
- axisXScale.domain(d3.extent(allValues, d => d.time));
- axisYScale.domain([0, d3.max(allValues.map(d => d.value))]);
-
- this.allXAxisValues = this.timeSeries.reduce((obj, series) => {
- const seriesKeys = {};
- series.values.forEach(v => {
- seriesKeys[v.time] = true;
- });
- return {
- ...obj,
- ...seriesKeys,
- };
- }, {});
-
- const xAxis = d3
- .axisBottom()
- .scale(axisXScale)
- .ticks(this.graphWidth / 120)
- .tickFormat(timeScaleFormat);
-
- const yAxis = d3
- .axisLeft()
- .scale(axisYScale)
- .ticks(measurements.yTicks);
-
- d3.select(this.$refs.baseSvg)
- .select('.x-axis')
- .call(xAxis);
-
- const width = this.graphWidth;
- d3.select(this.$refs.baseSvg)
- .select('.y-axis')
- .call(yAxis)
- .selectAll('.tick')
- .each(function createTickLines(d, i) {
- if (i > 0) {
- d3.select(this)
- .select('line')
- .attr('x2', width)
- .attr('class', 'axis-tick');
- } // Avoid adding the class to the first tick, to prevent coloring
- }); // This will select all of the ticks once they're rendered
- },
- },
-};
-</script>
-
-<template>
- <div
- class="prometheus-graph"
- @mouseover="showFlagContent = true;"
- @mouseleave="showFlagContent = false;"
- >
- <div class="prometheus-graph-header">
- <h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
- <div class="prometheus-graph-widgets"><slot></slot></div>
- </div>
- <div :style="paddingBottomRootSvg" class="prometheus-svg-container">
- <svg ref="baseSvg" :viewBox="outerViewBox">
- <g :transform="axisTransform" class="x-axis" />
- <g class="y-axis" transform="translate(70, 20)" />
- <graph-axis
- :graph-width="graphWidth"
- :graph-height="graphHeight"
- :margin="margin"
- :measurements="measurements"
- :y-axis-label="yAxisLabel"
- :unit-of-display="unitOfDisplay"
- />
- <svg v-if="shouldRenderData" ref="graphData" :viewBox="innerViewBox" class="graph-data">
- <slot name="additionalSvgContent" :graphDrawData="graphDrawData" />
- <graph-path
- v-for="(path, index) in timeSeries"
- :key="index"
- :generated-line-path="path.linePath"
- :generated-area-path="path.areaPath"
- :line-style="path.lineStyle"
- :line-color="path.lineColor"
- :area-color="path.areaColor"
- :current-coordinates="currentCoordinates[path.metricTag]"
- :show-dot="showDot(path)"
- />
- <graph-deployment
- :deployment-data="reducedDeploymentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- />
- <rect
- ref="graphOverlay"
- :width="graphWidth - 70"
- :height="graphHeight - 100"
- class="prometheus-graph-overlay"
- transform="translate(-5, 20)"
- @mousemove="handleMouseOverGraph($event);"
- />
- </svg>
- <svg v-else :viewBox="innerViewBox" class="js-no-data-to-display">
- <text x="50%" y="50%" alignment-baseline="middle" text-anchor="middle">
- {{ s__('Metrics|No data to display') }}
- </text>
- </svg>
- </svg>
- <graph-flag
- v-if="shouldRenderData"
- :real-pixel-ratio="realPixelRatio"
- :current-x-coordinate="currentXCoordinate"
- :current-data="currentData"
- :graph-height="graphHeight"
- :graph-height-offset="graphHeightOffset"
- :show-flag-content="showFlagContent"
- :time-series="seriesUnderMouse"
- :unit-of-display="unitOfDisplay"
- :legend-title="legendTitle"
- :deployment-flag-data="deploymentFlagData"
- :current-coordinates="currentCoordinates"
- />
- </div>
- <graph-legend v-if="showLegend" :legend-title="legendTitle" :time-series="timeSeries" />
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/axis.vue b/app/assets/javascripts/monitoring/components/graph/axis.vue
deleted file mode 100644
index 8f046857a20..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/axis.vue
+++ /dev/null
@@ -1,118 +0,0 @@
-<script>
-import { convertToSentenceCase } from '~/lib/utils/text_utility';
-import { s__ } from '~/locale';
-
-export default {
- props: {
- graphWidth: {
- type: Number,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- margin: {
- type: Object,
- required: true,
- },
- measurements: {
- type: Object,
- required: true,
- },
- yAxisLabel: {
- type: String,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- },
- data() {
- return {
- yLabelWidth: 0,
- yLabelHeight: 0,
- };
- },
- computed: {
- textTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
-
- return `translate(15, ${yCoordinate}) rotate(-90)`;
- },
-
- rectTransform() {
- const yCoordinate =
- (this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
- this.yLabelWidth / 2 || 0;
-
- return `translate(0, ${yCoordinate}) rotate(-90)`;
- },
-
- xPosition() {
- return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
- },
-
- yPosition() {
- return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
- },
-
- yAxisLabelSentenceCase() {
- return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
- },
-
- timeString() {
- return s__('PrometheusDashboard|Time');
- },
- },
- mounted() {
- this.$nextTick(() => {
- const bbox = this.$refs.ylabel.getBBox();
- this.yLabelWidth = bbox.width + 10; // Added some padding
- this.yLabelHeight = bbox.height + 5;
- });
- },
-};
-</script>
-<template>
- <g class="axis-label-container">
- <line
- :y1="yPosition"
- :x2="graphWidth + 20"
- :y2="yPosition"
- class="label-x-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- />
- <line
- :x2="10"
- :y2="yPosition"
- class="label-y-axis-line"
- stroke="#000000"
- stroke-width="1"
- x1="10"
- y1="0"
- />
- <rect
- :transform="rectTransform"
- :width="yLabelWidth"
- :height="yLabelHeight"
- class="rect-axis-text"
- />
- <text
- ref="ylabel"
- :transform="textTransform"
- class="label-axis-text y-label-text"
- text-anchor="middle"
- >
- {{ yAxisLabelSentenceCase }}
- </text>
- <rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" />
- <text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em">
- {{ timeString }}
- </text>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue
deleted file mode 100644
index bee9784692c..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/deployment.vue
+++ /dev/null
@@ -1,48 +0,0 @@
-<script>
-export default {
- props: {
- deploymentData: {
- type: Array,
- required: true,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- },
- computed: {
- calculatedHeight() {
- return this.graphHeight - this.graphHeightOffset;
- },
- },
- methods: {
- transformDeploymentGroup(deployment) {
- return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
- },
- },
-};
-</script>
-<template>
- <g class="deploy-info">
- <g
- v-for="(deployment, index) in deploymentData"
- :key="index"
- :transform="transformDeploymentGroup(deployment)"
- >
- <rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" />
- <line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" />
- </g>
- <svg height="0" width="0">
- <defs>
- <linearGradient id="shadow-gradient">
- <stop offset="0%" stop-color="#000" stop-opacity="0.4" />
- <stop offset="100%" stop-color="#000" stop-opacity="0" />
- </linearGradient>
- </defs>
- </svg>
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue
deleted file mode 100644
index 9d6d1caef80..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/flag.vue
+++ /dev/null
@@ -1,151 +0,0 @@
-<script>
-import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
-import { formatRelevantDigits } from '../../../lib/utils/number_utils';
-import Icon from '../../../vue_shared/components/icon.vue';
-import TrackLine from './track_line.vue';
-
-export default {
- components: {
- Icon,
- TrackLine,
- },
- props: {
- currentXCoordinate: {
- type: Number,
- required: true,
- },
- currentData: {
- type: Object,
- required: true,
- },
- deploymentFlagData: {
- type: Object,
- required: false,
- default: null,
- },
- graphHeight: {
- type: Number,
- required: true,
- },
- graphHeightOffset: {
- type: Number,
- required: true,
- },
- realPixelRatio: {
- type: Number,
- required: true,
- },
- showFlagContent: {
- type: Boolean,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- unitOfDisplay: {
- type: String,
- required: true,
- },
- legendTitle: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: true,
- },
- },
- computed: {
- formatTime() {
- return this.deploymentFlagData
- ? timeFormat(this.deploymentFlagData.time)
- : timeFormat(this.currentData.time);
- },
- formatDate() {
- return this.deploymentFlagData
- ? dateFormat(this.deploymentFlagData.time)
- : dateFormat(this.currentData.time);
- },
- cursorStyle() {
- const xCoordinate = this.deploymentFlagData
- ? this.deploymentFlagData.xPos
- : this.currentXCoordinate;
-
- const offsetTop = 20 * this.realPixelRatio;
- const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
- const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
-
- return {
- top: `${offsetTop}px`,
- left: `${offsetLeft}px`,
- height: `${height}px`,
- };
- },
- flagOrientation() {
- if (this.currentXCoordinate * this.realPixelRatio > 120) {
- return 'left';
- }
- return 'right';
- },
- },
- methods: {
- seriesMetricValue(seriesIndex, series) {
- const indexFromCoordinates = this.currentCoordinates[series.metricTag]
- ? this.currentCoordinates[series.metricTag].currentDataIndex
- : 0;
- const index = this.deploymentFlagData
- ? this.deploymentFlagData.seriesIndex
- : indexFromCoordinates;
- const value = series.values[index] && series.values[index].value;
- if (Number.isNaN(value)) {
- return '-';
- }
- return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
- },
- seriesMetricLabel(index, series) {
- if (this.timeSeries.length < 2) {
- return this.legendTitle;
- }
- if (series.metricTag) {
- return series.metricTag;
- }
- return `series ${index + 1}`;
- },
- },
-};
-</script>
-
-<template>
- <div :style="cursorStyle" class="prometheus-graph-cursor">
- <div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover">
- <div class="arrow-shadow"></div>
- <div class="arrow"></div>
- <div class="popover-title">
- <h5 v-if="deploymentFlagData">Deployed</h5>
- {{ formatDate }} <strong>{{ formatTime }}</strong>
- </div>
- <div v-if="deploymentFlagData" class="popover-content deploy-meta-content">
- <div>
- <icon :size="12" name="commit" />
- <a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a>
- </div>
- <div v-if="deploymentFlagData.tag">
- <icon :size="12" name="label" />
- <a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a>
- </div>
- </div>
- <div class="popover-content">
- <table class="prometheus-table">
- <tr v-for="(series, index) in timeSeries" :key="index">
- <track-line :track="series" />
- <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
- <td>
- <strong>{{ seriesMetricValue(index, series) }}</strong>
- </td>
- </tr>
- </table>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/legend.vue b/app/assets/javascripts/monitoring/components/graph/legend.vue
deleted file mode 100644
index b5211c306a3..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/legend.vue
+++ /dev/null
@@ -1,62 +0,0 @@
-<script>
-import TrackLine from './track_line.vue';
-import TrackInfo from './track_info.vue';
-
-export default {
- components: {
- TrackLine,
- TrackInfo,
- },
- props: {
- legendTitle: {
- type: String,
- required: true,
- },
- timeSeries: {
- type: Array,
- required: true,
- },
- },
- methods: {
- isStable(track) {
- return {
- 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
- };
- },
- },
-};
-</script>
-<template>
- <div class="prometheus-graph-legends prepend-left-10">
- <table class="prometheus-table">
- <tr
- v-for="(series, index) in timeSeries"
- v-if="series.shouldRenderLegend"
- :key="index"
- :class="isStable(series)"
- >
- <td>
- <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
- </td>
- <track-line :track="series" />
- <td v-if="timeSeries.length > 1" class="legend-metric-title">
- <track-info v-if="series.metricTag" :track="series" />
- <track-info v-else :track="series">
- <strong>{{ legendTitle }}</strong> series {{ index + 1 }}
- </track-info>
- </td>
- <td v-else>
- <track-info :track="series">
- <strong>{{ legendTitle }}</strong>
- </track-info>
- </td>
- <template v-for="(track, trackIndex) in series.tracksLegend">
- <track-line :key="`track-line-${trackIndex}`" :track="track" />
- <td :key="`track-info-${trackIndex}`">
- <track-info :track="track" class="legend-metric-title" />
- </td>
- </template>
- </tr>
- </table>
- </div>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/path.vue b/app/assets/javascripts/monitoring/components/graph/path.vue
deleted file mode 100644
index f2c237ec391..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/path.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-export default {
- props: {
- generatedLinePath: {
- type: String,
- required: true,
- },
- generatedAreaPath: {
- type: String,
- required: true,
- },
- lineStyle: {
- type: String,
- required: false,
- default: '',
- },
- lineColor: {
- type: String,
- required: true,
- },
- areaColor: {
- type: String,
- required: true,
- },
- currentCoordinates: {
- type: Object,
- required: false,
- default: () => ({ currentX: 0, currentY: 0 }),
- },
- showDot: {
- type: Boolean,
- required: true,
- },
- },
- computed: {
- strokeDashArray() {
- if (this.lineStyle === 'dashed') return '3, 1';
- if (this.lineStyle === 'dotted') return '1, 1';
- return null;
- },
- },
-};
-</script>
-<template>
- <g transform="translate(-5, 20)">
- <circle
- v-if="showDot"
- :cx="currentCoordinates.currentX"
- :cy="currentCoordinates.currentY"
- :fill="lineColor"
- :stroke="lineColor"
- class="circle-path"
- r="3"
- />
- <path :d="generatedAreaPath" :fill="areaColor" class="metric-area" />
- <path
- :d="generatedLinePath"
- :stroke="lineColor"
- :stroke-dasharray="strokeDashArray"
- class="metric-line"
- fill="none"
- stroke-width="1"
- />
- </g>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_info.vue b/app/assets/javascripts/monitoring/components/graph/track_info.vue
deleted file mode 100644
index 3464067834f..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_info.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { formatRelevantDigits } from '~/lib/utils/number_utils';
-
-export default {
- name: 'TrackInfo',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- summaryMetrics() {
- return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
- this.track.max,
- )}`;
- },
- },
-};
-</script>
-<template>
- <span>
- <slot>
- <strong> {{ track.metricTag }} </strong>
- </slot>
- {{ summaryMetrics }}
- </span>
-</template>
diff --git a/app/assets/javascripts/monitoring/components/graph/track_line.vue b/app/assets/javascripts/monitoring/components/graph/track_line.vue
deleted file mode 100644
index d2ed1ba113e..00000000000
--- a/app/assets/javascripts/monitoring/components/graph/track_line.vue
+++ /dev/null
@@ -1,33 +0,0 @@
-<script>
-export default {
- name: 'TrackLine',
- props: {
- track: {
- type: Object,
- required: true,
- },
- },
- computed: {
- stylizedLine() {
- if (this.track.lineStyle === 'dashed') return '6, 3';
- if (this.track.lineStyle === 'dotted') return '3, 3';
- return null;
- },
- },
-};
-</script>
-<template>
- <td>
- <svg width="16" height="8">
- <line
- :stroke-dasharray="stylizedLine"
- :stroke="track.lineColor"
- :x1="0"
- :x2="16"
- :y1="4"
- :y2="4"
- stroke-width="4"
- />
- </svg>
- </td>
-</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
new file mode 100644
index 00000000000..26f1bf3f68d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -0,0 +1,29 @@
+import { __ } from '~/locale';
+
+export const chartHeight = 300;
+
+export const graphTypes = {
+ deploymentData: 'scatter',
+};
+
+export const lineTypes = {
+ default: 'solid',
+};
+
+export const timeWindows = {
+ thirtyMinutes: __('30 minutes'),
+ threeHours: __('3 hours'),
+ eightHours: __('8 hours'),
+ oneDay: __('1 day'),
+ threeDays: __('3 days'),
+ oneWeek: __('1 week'),
+};
+
+export const timeWindowsKeyNames = {
+ thirtyMinutes: 'thirtyMinutes',
+ threeHours: 'threeHours',
+ eightHours: 'eightHours',
+ oneDay: 'oneDay',
+ threeDays: 'threeDays',
+ oneWeek: 'oneWeek',
+};
diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
deleted file mode 100644
index 87c3d969de4..00000000000
--- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js
+++ /dev/null
@@ -1,86 +0,0 @@
-import { bisectDate } from '../utils/date_time_formatters';
-
-const mixins = {
- methods: {
- mouseOverDeployInfo(mouseXPos) {
- if (!this.reducedDeploymentData) return false;
-
- let dataFound = false;
- this.reducedDeploymentData = this.reducedDeploymentData.map(d => {
- const deployment = d;
- if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
- dataFound = d.xPos + 1;
-
- deployment.showDeploymentFlag = true;
- } else {
- deployment.showDeploymentFlag = false;
- }
- return deployment;
- });
-
- return dataFound;
- },
-
- formatDeployments() {
- this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
- const time = new Date(deployment.created_at);
- const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
-
- time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
-
- if (xPos >= 0) {
- const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
-
- deploymentDataArray.push({
- id: deployment.id,
- time,
- sha: deployment.sha,
- commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
- tag: deployment.tag,
- tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
- ref: deployment.ref.name,
- xPos,
- seriesIndex,
- showDeploymentFlag: false,
- });
- }
-
- return deploymentDataArray;
- }, []);
- },
-
- positionFlag() {
- const timeSeries = this.seriesUnderMouse[0];
- if (!timeSeries) {
- return;
- }
- const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate);
-
- this.currentData = timeSeries.values[hoveredDataIndex];
- this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
-
- this.currentCoordinates = {};
-
- this.seriesUnderMouse.forEach(series => {
- const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate);
- const currentData = series.values[currentDataIndex];
- const currentX = Math.floor(series.timeSeriesScaleX(currentData.time));
- const currentY = Math.floor(series.timeSeriesScaleY(currentData.value));
-
- this.currentCoordinates[series.metricTag] = {
- currentX,
- currentY,
- currentDataIndex,
- };
- });
-
- if (this.hoverData.currentDeployXPos) {
- this.showFlag = false;
- } else {
- this.showFlag = true;
- }
- },
- },
-};
-
-export default mixins;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 9d78b5ea110..57771ccf4d9 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -1,19 +1,23 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
-import Dashboard from './components/dashboard.vue';
+import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
+import store from './stores';
-export default () => {
+export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
// eslint-disable-next-line no-new
new Vue({
el,
+ store,
render(createElement) {
return createElement(Dashboard, {
props: {
...el.dataset,
hasMetrics: parseBoolean(el.dataset.hasMetrics),
+ showTimeWindowDropdown: gon.features.metricsTimeWindow,
+ ...props,
},
});
},
diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js
deleted file mode 100644
index 24b4acaf6da..00000000000
--- a/app/assets/javascripts/monitoring/services/monitoring_service.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import axios from '../../lib/utils/axios_utils';
-import statusCodes from '../../lib/utils/http_status';
-import { backOff } from '../../lib/utils/common_utils';
-import { s__ } from '../../locale';
-
-const MAX_REQUESTS = 3;
-
-function backOffRequest(makeRequestCallback) {
- let requestCounter = 0;
- return backOff((next, stop) => {
- makeRequestCallback()
- .then(resp => {
- if (resp.status === statusCodes.NO_CONTENT) {
- requestCounter += 1;
- if (requestCounter < MAX_REQUESTS) {
- next();
- } else {
- stop(new Error('Failed to connect to the prometheus server'));
- }
- } else {
- stop(resp);
- }
- })
- .catch(stop);
- });
-}
-
-export default class MonitoringService {
- constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) {
- this.metricsEndpoint = metricsEndpoint;
- this.deploymentEndpoint = deploymentEndpoint;
- this.environmentsEndpoint = environmentsEndpoint;
- }
-
- getGraphsData() {
- return backOffRequest(() => axios.get(this.metricsEndpoint))
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.data) {
- throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
- }
- return response.data;
- });
- }
-
- getDeploymentData() {
- if (!this.deploymentEndpoint) {
- return Promise.resolve([]);
- }
- return backOffRequest(() => axios.get(this.deploymentEndpoint))
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.deployments) {
- throw new Error(
- s__('Metrics|Unexpected deployment data response from prometheus endpoint'),
- );
- }
- return response.deployments;
- });
- }
-
- getEnvironmentsData() {
- return axios
- .get(this.environmentsEndpoint)
- .then(resp => resp.data)
- .then(response => {
- if (!response || !response.environments) {
- throw new Error(
- s__('Metrics|There was an error fetching the environments data, please try again'),
- );
- }
- return response.environments;
- });
- }
-}
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
new file mode 100644
index 00000000000..63c23e8449d
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -0,0 +1,117 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import statusCodes from '../../lib/utils/http_status';
+import { backOff } from '../../lib/utils/common_utils';
+import { s__, __ } from '../../locale';
+
+const MAX_REQUESTS = 3;
+
+function backOffRequest(makeRequestCallback) {
+ let requestCounter = 0;
+ return backOff((next, stop) => {
+ makeRequestCallback()
+ .then(resp => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ requestCounter += 1;
+ if (requestCounter < MAX_REQUESTS) {
+ next();
+ } else {
+ stop(new Error(__('Failed to connect to the prometheus server')));
+ }
+ } else {
+ stop(resp);
+ }
+ })
+ .catch(stop);
+ });
+}
+
+export const setGettingStartedEmptyState = ({ commit }) => {
+ commit(types.SET_GETTING_STARTED_EMPTY_STATE);
+};
+
+export const setEndpoints = ({ commit }, endpoints) => {
+ commit(types.SET_ENDPOINTS, endpoints);
+};
+
+export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
+export const receiveMetricsDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
+export const receiveMetricsDataFailure = ({ commit }, error) =>
+ commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
+export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
+export const receiveDeploymentsDataFailure = ({ commit }) =>
+ commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
+export const receiveEnvironmentsDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
+export const receiveEnvironmentsDataFailure = ({ commit }) =>
+ commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
+
+export const fetchData = ({ dispatch }, params) => {
+ dispatch('fetchMetricsData', params);
+ dispatch('fetchDeploymentsData');
+ dispatch('fetchEnvironmentsData');
+};
+
+export const fetchMetricsData = ({ state, dispatch }, params) => {
+ dispatch('requestMetricsData');
+
+ return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.data || !response.success) {
+ dispatch('receiveMetricsDataFailure', null);
+ createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
+ }
+ dispatch('receiveMetricsDataSuccess', response.data);
+ })
+ .catch(error => {
+ dispatch('receiveMetricsDataFailure', error);
+ createFlash(s__('Metrics|There was an error while retrieving metrics'));
+ });
+};
+
+export const fetchDeploymentsData = ({ state, dispatch }) => {
+ if (!state.deploymentEndpoint) {
+ return Promise.resolve([]);
+ }
+ return backOffRequest(() => axios.get(state.deploymentEndpoint))
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.deployments) {
+ createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint'));
+ }
+
+ dispatch('receiveDeploymentsDataSuccess', response.deployments);
+ })
+ .catch(() => {
+ dispatch('receiveDeploymentsDataFailure');
+ createFlash(s__('Metrics|There was an error getting deployment information.'));
+ });
+};
+
+export const fetchEnvironmentsData = ({ state, dispatch }) => {
+ if (!state.environmentsEndpoint) {
+ return Promise.resolve([]);
+ }
+ return axios
+ .get(state.environmentsEndpoint)
+ .then(resp => resp.data)
+ .then(response => {
+ if (!response || !response.environments) {
+ createFlash(
+ s__('Metrics|There was an error fetching the environments data, please try again'),
+ );
+ }
+ dispatch('receiveEnvironmentsDataSuccess', response.environments);
+ })
+ .catch(() => {
+ dispatch('receiveEnvironmentsDataFailure');
+ createFlash(s__('Metrics|There was an error getting environments information.'));
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js
new file mode 100644
index 00000000000..d58398c54ae
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import mutations from './mutations';
+import state from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ modules: {
+ monitoringDashboard: {
+ namespaced: true,
+ actions,
+ mutations,
+ state,
+ },
+ },
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/monitoring/stores/monitoring_store.js b/app/assets/javascripts/monitoring/stores/monitoring_store.js
deleted file mode 100644
index 8692c873a41..00000000000
--- a/app/assets/javascripts/monitoring/stores/monitoring_store.js
+++ /dev/null
@@ -1,77 +0,0 @@
-import _ from 'underscore';
-
-function sortMetrics(metrics) {
- return _.chain(metrics)
- .sortBy('title')
- .sortBy('weight')
- .value();
-}
-
-function checkQueryEmptyData(query) {
- return {
- ...query,
- result: query.result.filter(timeSeries => {
- const newTimeSeries = timeSeries;
- const hasValue = series =>
- !Number.isNaN(series.value) && (series.value !== null || series.value !== undefined);
- const hasNonNullValue = timeSeries.values.find(hasValue);
-
- newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
-
- return newTimeSeries.values.length > 0;
- }),
- };
-}
-
-function removeTimeSeriesNoData(queries) {
- return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
-}
-
-function normalizeMetrics(metrics) {
- return metrics.map(metric => {
- const queries = metric.queries.map(query => ({
- ...query,
- result: query.result.map(result => ({
- ...result,
- values: result.values.map(([timestamp, value]) => ({
- time: new Date(timestamp * 1000),
- value: Number(value),
- })),
- })),
- }));
-
- return {
- ...metric,
- queries: removeTimeSeriesNoData(queries),
- };
- });
-}
-
-export default class MonitoringStore {
- constructor() {
- this.groups = [];
- this.deploymentData = [];
- this.environmentsData = [];
- }
-
- storeMetrics(groups = []) {
- this.groups = groups.map(group => ({
- ...group,
- metrics: normalizeMetrics(sortMetrics(group.metrics)),
- }));
- }
-
- storeDeploymentData(deploymentData = []) {
- this.deploymentData = deploymentData;
- }
-
- storeEnvironmentsData(environmentsData = []) {
- this.environmentsData = environmentsData.filter(
- environment => !!environment.latest.last_deployment,
- );
- }
-
- getMetricsCount() {
- return this.groups.reduce((count, group) => count + group.metrics.length, 0);
- }
-}
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
new file mode 100644
index 00000000000..74c4ae64712
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -0,0 +1,15 @@
+export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA';
+export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS';
+export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE';
+export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
+export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
+export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE';
+export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
+export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
+export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
+export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
+export const SET_METRICS_ENDPOINT = 'SET_METRICS_ENDPOINT';
+export const SET_ENVIRONMENTS_ENDPOINT = 'SET_ENVIRONMENTS_ENDPOINT';
+export const SET_DEPLOYMENTS_ENDPOINT = 'SET_DEPLOYMENTS_ENDPOINT';
+export const SET_ENDPOINTS = 'SET_ENDPOINTS';
+export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
new file mode 100644
index 00000000000..c1779333d75
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -0,0 +1,45 @@
+import * as types from './mutation_types';
+import { normalizeMetrics, sortMetrics } from './utils';
+
+export default {
+ [types.REQUEST_METRICS_DATA](state) {
+ state.emptyState = 'loading';
+ state.showEmptyState = true;
+ },
+ [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
+ state.groups = groupData.map(group => ({
+ ...group,
+ metrics: normalizeMetrics(sortMetrics(group.metrics)),
+ }));
+
+ if (!state.groups.length) {
+ state.emptyState = 'noData';
+ } else {
+ state.showEmptyState = false;
+ }
+ },
+ [types.RECEIVE_METRICS_DATA_FAILURE](state, error) {
+ state.emptyState = error ? 'unableToConnect' : 'noData';
+ state.showEmptyState = true;
+ },
+ [types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) {
+ state.deploymentData = deployments;
+ },
+ [types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
+ state.deploymentData = [];
+ },
+ [types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
+ state.environments = environments;
+ },
+ [types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
+ state.environments = [];
+ },
+ [types.SET_ENDPOINTS](state, endpoints) {
+ state.metricsEndpoint = endpoints.metricsEndpoint;
+ state.environmentsEndpoint = endpoints.environmentsEndpoint;
+ state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
+ },
+ [types.SET_GETTING_STARTED_EMPTY_STATE](state) {
+ state.emptyState = 'gettingStarted';
+ },
+};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
new file mode 100644
index 00000000000..5103122612a
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -0,0 +1,12 @@
+export default () => ({
+ hasMetrics: false,
+ showPanels: true,
+ metricsEndpoint: null,
+ environmentsEndpoint: null,
+ deploymentsEndpoint: null,
+ emptyState: 'gettingStarted',
+ showEmptyState: true,
+ groups: [],
+ deploymentData: [],
+ environments: [],
+});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
new file mode 100644
index 00000000000..9216554ecbf
--- /dev/null
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -0,0 +1,83 @@
+import _ from 'underscore';
+
+function checkQueryEmptyData(query) {
+ return {
+ ...query,
+ result: query.result.filter(timeSeries => {
+ const newTimeSeries = timeSeries;
+ const hasValue = series =>
+ !Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined);
+ const hasNonNullValue = timeSeries.values.find(hasValue);
+
+ newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
+
+ return newTimeSeries.values.length > 0;
+ }),
+ };
+}
+
+function removeTimeSeriesNoData(queries) {
+ return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
+}
+
+// Metrics and queries are currently stored 1:1, so `queries` is an array of length one.
+// We want to group queries onto a single chart by title & y-axis label.
+// This function will no longer be required when metrics:queries are 1:many,
+// though there is no consequence if the function stays in use.
+// @param metrics [Array<Object>]
+// Ex) [
+// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] },
+// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] },
+// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] }
+// ]
+// @return [Array<Object>]
+// Ex) [
+// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs },
+// { metricId: 2, ...query2Attrs }] },
+// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
+// ]
+function groupQueriesByChartInfo(metrics) {
+ const metricsByChart = metrics.reduce((accumulator, metric) => {
+ const { queries, ...chart } = metric;
+ const metricId = chart.id ? chart.id.toString() : null;
+
+ const chartKey = `${chart.title}|${chart.y_label}`;
+ accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
+
+ queries.forEach(queryAttrs => accumulator[chartKey].queries.push({ metricId, ...queryAttrs }));
+
+ return accumulator;
+ }, {});
+
+ return Object.values(metricsByChart);
+}
+
+export const sortMetrics = metrics =>
+ _.chain(metrics)
+ .sortBy('title')
+ .sortBy('weight')
+ .value();
+
+export const normalizeMetrics = metrics => {
+ const groupedMetrics = groupQueriesByChartInfo(metrics);
+
+ return groupedMetrics.map(metric => {
+ const queries = metric.queries.map(query => ({
+ ...query,
+ // custom metrics do not require a label, so we should ensure this attribute is defined
+ label: query.label || metric.y_label,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => [
+ new Date(timestamp * 1000).toISOString(),
+ Number(value),
+ ]),
+ })),
+ }));
+
+ return {
+ ...metric,
+ queries: removeTimeSeriesNoData(queries),
+ };
+ });
+};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
new file mode 100644
index 00000000000..ef309c8a398
--- /dev/null
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -0,0 +1,33 @@
+import { timeWindows } from './constants';
+
+/**
+ * method that converts a predetermined time window to minutes
+ * defaults to 8 hours as the default option
+ * @param {String} timeWindow - The time window to convert to minutes
+ * @returns {number} The time window in minutes
+ */
+const getTimeDifferenceSeconds = timeWindow => {
+ switch (timeWindow) {
+ case timeWindows.thirtyMinutes:
+ return 60 * 30;
+ case timeWindows.threeHours:
+ return 60 * 60 * 3;
+ case timeWindows.oneDay:
+ return 60 * 60 * 24 * 1;
+ case timeWindows.threeDays:
+ return 60 * 60 * 24 * 3;
+ case timeWindows.oneWeek:
+ return 60 * 60 * 24 * 7 * 1;
+ default:
+ return 60 * 60 * 8;
+ }
+};
+
+export const getTimeDiff = selectedTimeWindow => {
+ const end = Date.now() / 1000; // convert milliseconds to seconds
+ const start = end - getTimeDifferenceSeconds(selectedTimeWindow);
+
+ return { start, end };
+};
+
+export default {};
diff --git a/app/assets/javascripts/monitoring/utils/date_time_formatters.js b/app/assets/javascripts/monitoring/utils/date_time_formatters.js
deleted file mode 100644
index d88c13609dc..00000000000
--- a/app/assets/javascripts/monitoring/utils/date_time_formatters.js
+++ /dev/null
@@ -1,42 +0,0 @@
-import { timeFormat as time } from 'd3-time-format';
-import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
-import { bisector } from 'd3-array';
-
-const d3 = {
- time,
- bisector,
- timeSecond,
- timeMinute,
- timeHour,
- timeDay,
- timeWeek,
- timeMonth,
- timeYear,
-};
-
-export const dateFormat = d3.time('%d %b %Y, ');
-export const timeFormat = d3.time('%-I:%M%p');
-export const dateFormatWithName = d3.time('%a, %b %-d');
-export const bisectDate = d3.bisector(d => d.time).left;
-
-export function timeScaleFormat(date) {
- let formatFunction;
- if (d3.timeSecond(date) < date) {
- formatFunction = d3.time('.%L');
- } else if (d3.timeMinute(date) < date) {
- formatFunction = d3.time(':%S');
- } else if (d3.timeHour(date) < date) {
- formatFunction = d3.time('%-I:%M');
- } else if (d3.timeDay(date) < date) {
- formatFunction = d3.time('%-I %p');
- } else if (d3.timeWeek(date) < date) {
- formatFunction = d3.time('%a %d');
- } else if (d3.timeMonth(date) < date) {
- formatFunction = d3.time('%b %d');
- } else if (d3.timeYear(date) < date) {
- formatFunction = d3.time('%B');
- } else {
- formatFunction = d3.time('%Y');
- }
- return formatFunction(date);
-}
diff --git a/app/assets/javascripts/monitoring/utils/measurements.js b/app/assets/javascripts/monitoring/utils/measurements.js
deleted file mode 100644
index 7c771f43eee..00000000000
--- a/app/assets/javascripts/monitoring/utils/measurements.js
+++ /dev/null
@@ -1,44 +0,0 @@
-export default {
- small: {
- // Covers both xs and sm screen sizes
- margin: {
- top: 40,
- right: 40,
- bottom: 50,
- left: 40,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 32,
- },
- backgroundLegend: {
- width: 30,
- height: 50,
- },
- axisLabelLineOffset: -20,
- },
- large: {
- // This covers both md and lg screen sizes
- margin: {
- top: 80,
- right: 80,
- bottom: 100,
- left: 80,
- },
- legends: {
- width: 15,
- height: 3,
- offsetX: 20,
- offsetY: 34,
- },
- backgroundLegend: {
- width: 30,
- height: 150,
- },
- axisLabelLineOffset: 20,
- },
- xTicks: 8,
- yTicks: 3,
-};
diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js
deleted file mode 100644
index bb24a1acdb3..00000000000
--- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js
+++ /dev/null
@@ -1,219 +0,0 @@
-import _ from 'underscore';
-import { scaleLinear, scaleTime } from 'd3-scale';
-import { line, area, curveLinear } from 'd3-shape';
-import { extent, max, sum } from 'd3-array';
-import { timeMinute, timeSecond } from 'd3-time';
-import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
-
-const d3 = {
- scaleLinear,
- scaleTime,
- line,
- area,
- curveLinear,
- extent,
- max,
- timeMinute,
- timeSecond,
- sum,
-};
-
-const defaultColorPalette = {
- blue: ['#1f78d1', '#8fbce8'],
- orange: ['#fc9403', '#feca81'],
- red: ['#db3b21', '#ed9d90'],
- green: ['#1aaa55', '#8dd5aa'],
- purple: ['#6666c4', '#d1d1f0'],
-};
-
-const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
-
-const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
-
-function queryTimeSeries(query, graphDrawData, lineStyle) {
- let usedColors = [];
- let renderCanary = false;
- const timeSeriesParsed = [];
-
- function pickColor(name) {
- let pick;
- if (name && defaultColorPalette[name]) {
- pick = name;
- } else {
- const unusedColors = _.difference(defaultColorOrder, usedColors);
- if (unusedColors.length > 0) {
- [pick] = unusedColors;
- } else {
- usedColors = [];
- [pick] = defaultColorOrder;
- }
- }
- usedColors.push(pick);
- return defaultColorPalette[pick];
- }
-
- function findByDate(series, time) {
- const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60);
- if (val) {
- return val.value;
- }
- return NaN;
- }
-
- // The timeseries data may have gaps in it
- // but we need a regularly-spaced set of time/value pairs
- // this gives us a complete range of one minute intervals
- // offset the same amount as the original data
- const [minX, maxX] = graphDrawData.xDom;
- const offset = d3.timeMinute(minX) - Number(minX);
- const datesWithoutGaps = d3.timeSecond
- .every(60)
- .range(d3.timeMinute.offset(minX, -1), maxX)
- .map(d => d - offset);
-
- query.result.forEach((timeSeries, timeSeriesNumber) => {
- let metricTag = '';
- let lineColor = '';
- let areaColor = '';
- let shouldRenderLegend = true;
- const timeSeriesValues = timeSeries.values.map(d => d.value);
- const maximumValue = d3.max(timeSeriesValues);
- const accum = d3.sum(timeSeriesValues);
- const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
-
- if (trackName === 'Canary') {
- renderCanary = true;
- }
-
- const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
- const seriesCustomizationData =
- query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
-
- if (seriesCustomizationData) {
- metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
- [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
- shouldRenderLegend = false;
- } else {
- metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
- [lineColor, areaColor] = pickColor();
- if (timeSeriesParsed.length > 1) {
- shouldRenderLegend = false;
- }
- }
-
- if (!shouldRenderLegend) {
- if (!timeSeriesParsed[0].tracksLegend) {
- timeSeriesParsed[0].tracksLegend = [];
- }
- timeSeriesParsed[0].tracksLegend.push({
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- metricTag,
- });
- }
-
- const values = datesWithoutGaps.map(time => ({
- time,
- value: findByDate(timeSeries.values, time),
- }));
-
- timeSeriesParsed.push({
- linePath: graphDrawData.lineFunction(values),
- areaPath: graphDrawData.areaBelowLine(values),
- timeSeriesScaleX: graphDrawData.timeSeriesScaleX,
- timeSeriesScaleY: graphDrawData.timeSeriesScaleY,
- values: timeSeries.values,
- max: maximumValue,
- average: accum / timeSeries.values.length,
- lineStyle,
- lineColor,
- areaColor,
- metricTag,
- trackName,
- shouldRenderLegend,
- renderCanary,
- });
- });
-
- return timeSeriesParsed;
-}
-
-function xyDomain(queries) {
- const allValues = queries.reduce(
- (allQueryResults, query) =>
- allQueryResults.concat(
- query.result.reduce((allResults, result) => allResults.concat(result.values), []),
- ),
- [],
- );
-
- const xDom = d3.extent(allValues, d => d.time);
- const yDom = [0, d3.max(allValues.map(d => d.value))];
-
- return {
- xDom,
- yDom,
- };
-}
-
-export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) {
- const { xDom, yDom } = xyDomain(queries);
-
- const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
- const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
-
- timeSeriesScaleX.domain(xDom);
- timeSeriesScaleX.ticks(d3.timeMinute, 60);
- timeSeriesScaleY.domain(yDom);
-
- const defined = d => !Number.isNaN(d.value) && d.value != null;
-
- const lineFunction = d3
- .line()
- .defined(defined)
- .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
- .x(d => timeSeriesScaleX(d.time))
- .y(d => timeSeriesScaleY(d.value));
-
- const areaBelowLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(graphHeight - graphHeightOffset)
- .y1(d => timeSeriesScaleY(d.value));
-
- const areaAboveLine = d3
- .area()
- .defined(defined)
- .curve(d3.curveLinear)
- .x(d => timeSeriesScaleX(d.time))
- .y0(0)
- .y1(d => timeSeriesScaleY(d.value));
-
- return {
- lineFunction,
- areaBelowLine,
- areaAboveLine,
- xDom,
- yDom,
- timeSeriesScaleX,
- timeSeriesScaleY,
- };
-}
-
-export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
- const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset);
-
- const timeSeries = queries.reduce((series, query, index) => {
- const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
- return series.concat(queryTimeSeries(query, graphDrawData, lineStyle));
- }, []);
-
- return {
- timeSeries,
- graphDrawData,
- };
-}
diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js
index e4d72eb8318..8eccba07c38 100644
--- a/app/assets/javascripts/mr_notes/index.js
+++ b/app/assets/javascripts/mr_notes/index.js
@@ -1,82 +1,22 @@
-import $ from 'jquery';
import Vue from 'vue';
-import { mapActions, mapState, mapGetters } from 'vuex';
+import store from 'ee_else_ce/mr_notes/stores';
+import initNotesApp from './init_notes';
import initDiffsApp from '../diffs';
-import notesApp from '../notes/components/notes_app.vue';
import discussionCounter from '../notes/components/discussion_counter.vue';
import initDiscussionFilters from '../notes/discussion_filters';
-import store from './stores';
import MergeRequest from '../merge_request';
+import { resetServiceWorkersPublicPath } from '../lib/utils/webpack';
export default function initMrNotes() {
+ resetServiceWorkersPublicPath();
+
const mrShowNode = document.querySelector('.merge-request');
// eslint-disable-next-line no-new
new MergeRequest({
action: mrShowNode.dataset.mrAction,
});
- // eslint-disable-next-line no-new
- new Vue({
- el: '#js-vue-mr-discussions',
- name: 'MergeRequestDiscussions',
- components: {
- notesApp,
- },
- store,
- data() {
- const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
- const noteableData = JSON.parse(notesDataset.noteableData);
- noteableData.noteableType = notesDataset.noteableType;
- noteableData.targetType = notesDataset.targetType;
-
- return {
- noteableData,
- currentUserData: JSON.parse(notesDataset.currentUserData),
- notesData: JSON.parse(notesDataset.notesData),
- helpPagePath: notesDataset.helpPagePath,
- };
- },
- computed: {
- ...mapGetters(['discussionTabCounter']),
- ...mapState({
- activeTab: state => state.page.activeTab,
- }),
- },
- watch: {
- discussionTabCounter() {
- this.updateDiscussionTabCounter();
- },
- },
- created() {
- this.setActiveTab(window.mrTabs.getCurrentAction());
- },
- mounted() {
- this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
- $(document).on('visibilitychange', this.updateDiscussionTabCounter);
- window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab);
- },
- beforeDestroy() {
- $(document).off('visibilitychange', this.updateDiscussionTabCounter);
- window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
- },
- methods: {
- ...mapActions(['setActiveTab']),
- updateDiscussionTabCounter() {
- this.notesCountBadge.text(this.discussionTabCounter);
- },
- },
- render(createElement) {
- return createElement('notes-app', {
- props: {
- noteableData: this.noteableData,
- notesData: this.notesData,
- userData: this.currentUserData,
- shouldShow: this.activeTab === 'show',
- helpPagePath: this.helpPagePath,
- },
- });
- },
- });
+ initNotesApp();
// eslint-disable-next-line no-new
new Vue({
diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js
new file mode 100644
index 00000000000..842a209a545
--- /dev/null
+++ b/app/assets/javascripts/mr_notes/init_notes.js
@@ -0,0 +1,70 @@
+import $ from 'jquery';
+import Vue from 'vue';
+import { mapActions, mapState, mapGetters } from 'vuex';
+import store from 'ee_else_ce/mr_notes/stores';
+import notesApp from '../notes/components/notes_app.vue';
+
+export default () => {
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: '#js-vue-mr-discussions',
+ name: 'MergeRequestDiscussions',
+ components: {
+ notesApp,
+ },
+ store,
+ data() {
+ const notesDataset = document.getElementById('js-vue-mr-discussions').dataset;
+ const noteableData = JSON.parse(notesDataset.noteableData);
+ noteableData.noteableType = notesDataset.noteableType;
+ noteableData.targetType = notesDataset.targetType;
+
+ return {
+ noteableData,
+ currentUserData: JSON.parse(notesDataset.currentUserData),
+ notesData: JSON.parse(notesDataset.notesData),
+ helpPagePath: notesDataset.helpPagePath,
+ };
+ },
+ computed: {
+ ...mapGetters(['discussionTabCounter']),
+ ...mapState({
+ activeTab: state => state.page.activeTab,
+ }),
+ },
+ watch: {
+ discussionTabCounter() {
+ this.updateDiscussionTabCounter();
+ },
+ },
+ created() {
+ this.setActiveTab(window.mrTabs.getCurrentAction());
+ },
+ mounted() {
+ this.notesCountBadge = $('.issuable-details').find('.notes-tab .badge');
+ $(document).on('visibilitychange', this.updateDiscussionTabCounter);
+ window.mrTabs.eventHub.$on('MergeRequestTabChange', this.setActiveTab);
+ },
+ beforeDestroy() {
+ $(document).off('visibilitychange', this.updateDiscussionTabCounter);
+ window.mrTabs.eventHub.$off('MergeRequestTabChange', this.setActiveTab);
+ },
+ methods: {
+ ...mapActions(['setActiveTab']),
+ updateDiscussionTabCounter() {
+ this.notesCountBadge.text(this.discussionTabCounter);
+ },
+ },
+ render(createElement) {
+ return createElement('notes-app', {
+ props: {
+ noteableData: this.noteableData,
+ notesData: this.notesData,
+ userData: this.currentUserData,
+ shouldShow: this.activeTab === 'show',
+ helpPagePath: this.helpPagePath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/mr_popover/components/mr_popover.vue b/app/assets/javascripts/mr_popover/components/mr_popover.vue
new file mode 100644
index 00000000000..8e2d8fa816a
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/components/mr_popover.vue
@@ -0,0 +1,110 @@
+<script>
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import Icon from '../../vue_shared/components/icon.vue';
+import CiIcon from '../../vue_shared/components/ci_icon.vue';
+import timeagoMixin from '../../vue_shared/mixins/timeago';
+import query from '../queries/merge_request.graphql';
+import { mrStates, humanMRStates } from '../constants';
+
+export default {
+ name: 'MRPopover',
+ components: {
+ GlPopover,
+ GlSkeletonLoading,
+ Icon,
+ CiIcon,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ mergeRequestIID: {
+ type: String,
+ required: true,
+ },
+ mergeRequestTitle: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ mergeRequest: {},
+ };
+ },
+ computed: {
+ detailedStatus() {
+ return this.mergeRequest.headPipeline && this.mergeRequest.headPipeline.detailedStatus;
+ },
+ formattedTime() {
+ return this.timeFormated(this.mergeRequest.createdAt);
+ },
+ statusBoxClass() {
+ switch (this.mergeRequest.state) {
+ case mrStates.merged:
+ return 'status-box-mr-merged';
+ case mrStates.closed:
+ return 'status-box-closed';
+ default:
+ return 'status-box-open';
+ }
+ },
+ stateHumanName() {
+ switch (this.mergeRequest.state) {
+ case mrStates.merged:
+ return humanMRStates.merged;
+ case mrStates.closed:
+ return humanMRStates.closed;
+ default:
+ return humanMRStates.open;
+ }
+ },
+ showDetails() {
+ return Object.keys(this.mergeRequest).length > 0;
+ },
+ },
+ apollo: {
+ mergeRequest: {
+ query,
+ update: data => data.project.mergeRequest,
+ variables() {
+ const { projectPath, mergeRequestIID } = this;
+
+ return {
+ projectPath,
+ mergeRequestIID,
+ };
+ },
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-popover :target="target" boundary="viewport" placement="top" show>
+ <div class="mr-popover">
+ <div v-if="$apollo.loading">
+ <gl-skeleton-loading :lines="1" class="animation-container-small mt-1" />
+ </div>
+ <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between">
+ <div class="d-inline-flex align-items-center">
+ <div :class="`issuable-status-box status-box ${statusBoxClass}`">
+ {{ stateHumanName }}
+ </div>
+ <span class="text-secondary">Opened <time v-text="formattedTime"></time></span>
+ </div>
+ <ci-icon v-if="detailedStatus" :status="detailedStatus" />
+ </div>
+ <h5 class="my-2">{{ mergeRequestTitle }}</h5>
+ <div class="text-secondary">
+ {{ `${projectPath}!${mergeRequestIID}` }}
+ </div>
+ </div>
+ </gl-popover>
+</template>
diff --git a/app/assets/javascripts/mr_popover/constants.js b/app/assets/javascripts/mr_popover/constants.js
new file mode 100644
index 00000000000..c13c417cc18
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/constants.js
@@ -0,0 +1,12 @@
+import { __ } from '~/locale';
+
+export const mrStates = {
+ merged: 'merged',
+ closed: 'closed',
+};
+
+export const humanMRStates = {
+ merged: __('Merged'),
+ closed: __('Closed'),
+ open: __('Open'),
+};
diff --git a/app/assets/javascripts/mr_popover/index.js b/app/assets/javascripts/mr_popover/index.js
new file mode 100644
index 00000000000..18c0e201300
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/index.js
@@ -0,0 +1,67 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import MRPopover from './components/mr_popover.vue';
+import createDefaultClient from '~/lib/graphql';
+
+let renderedPopover;
+let renderFn;
+
+const handleUserPopoverMouseOut = ({ target }) => {
+ target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ if (renderFn) {
+ clearTimeout(renderFn);
+ }
+ if (renderedPopover) {
+ renderedPopover.$destroy();
+ renderedPopover = null;
+ }
+};
+
+/**
+ * Adds a MergeRequestPopover component to the body, hands over as much data as the target element has in data attributes.
+ * loads based on data-project-path and data-iid more data about an MR from the API and sets it on the popover
+ */
+const handleMRPopoverMount = ({ apolloProvider, projectPath, mrTitle, iid }) => ({ target }) => {
+ // Add listener to actually remove it again
+ target.addEventListener('mouseleave', handleUserPopoverMouseOut);
+
+ renderFn = setTimeout(() => {
+ const MRPopoverComponent = Vue.extend(MRPopover);
+ renderedPopover = new MRPopoverComponent({
+ propsData: {
+ target,
+ projectPath,
+ mergeRequestIID: iid,
+ mergeRequestTitle: mrTitle,
+ },
+ apolloProvider,
+ });
+
+ renderedPopover.$mount();
+ }, 200); // 200ms delay so not every mouseover triggers Popover + API Call
+};
+
+export default elements => {
+ const mrLinks = elements || [...document.querySelectorAll('.gfm-merge_request')];
+ if (mrLinks.length > 0) {
+ Vue.use(VueApollo);
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+ });
+ const listenerAddedAttr = 'data-mr-listener-added';
+
+ mrLinks.forEach(el => {
+ const { projectPath, mrTitle, iid } = el.dataset;
+
+ if (!el.getAttribute(listenerAddedAttr) && projectPath && mrTitle && iid) {
+ el.addEventListener(
+ 'mouseenter',
+ handleMRPopoverMount({ apolloProvider, projectPath, mrTitle, iid }),
+ );
+ el.setAttribute(listenerAddedAttr, true);
+ }
+ });
+ }
+};
diff --git a/app/assets/javascripts/mr_popover/queries/merge_request.graphql b/app/assets/javascripts/mr_popover/queries/merge_request.graphql
new file mode 100644
index 00000000000..0bb9bc03bc7
--- /dev/null
+++ b/app/assets/javascripts/mr_popover/queries/merge_request.graphql
@@ -0,0 +1,14 @@
+query mergeRequest($projectPath: ID!, $mergeRequestIID: ID!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $mergeRequestIID) {
+ createdAt
+ state
+ headPipeline {
+ detailedStatus {
+ icon
+ group
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/namespace_select.js b/app/assets/javascripts/namespace_select.js
index ee1a5274ff7..03d349ac714 100644
--- a/app/assets/javascripts/namespace_select.js
+++ b/app/assets/javascripts/namespace_select.js
@@ -4,6 +4,7 @@ import $ from 'jquery';
import Api from './api';
import { mergeUrlParams } from './lib/utils/url_utility';
import { parseBoolean } from '~/lib/utils/common_utils';
+import { __ } from './locale';
export default class NamespaceSelect {
constructor(opts) {
@@ -29,7 +30,7 @@ export default class NamespaceSelect {
return Api.namespaces(term, function(namespaces) {
if (isFilter) {
const anyNamespace = {
- text: 'Any namespace',
+ text: __('Any namespace'),
id: null,
};
namespaces.unshift(anyNamespace);
diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js
new file mode 100644
index 00000000000..b817d38960c
--- /dev/null
+++ b/app/assets/javascripts/namespaces/leave_by_url.js
@@ -0,0 +1,22 @@
+import Flash from '~/flash';
+import { __, sprintf } from '~/locale';
+import { getParameterByName } from '~/lib/utils/common_utils';
+
+const PARAMETER_NAME = 'leave';
+const LEAVE_LINK_SELECTOR = '.js-leave-link';
+
+export default function leaveByUrl(namespaceType) {
+ if (!namespaceType) throw new Error('namespaceType not provided');
+
+ const param = getParameterByName(PARAMETER_NAME);
+ if (!param) return;
+
+ const leaveLink = document.querySelector(LEAVE_LINK_SELECTOR);
+ if (leaveLink) {
+ leaveLink.click();
+ } else {
+ Flash(
+ sprintf(__('You do not have permission to leave this %{namespaceType}.'), { namespaceType }),
+ );
+ }
+}
diff --git a/app/assets/javascripts/notebook/cells/code.vue b/app/assets/javascripts/notebook/cells/code.vue
index bd6736152f5..eefc801ed7a 100644
--- a/app/assets/javascripts/notebook/cells/code.vue
+++ b/app/assets/javascripts/notebook/cells/code.vue
@@ -1,11 +1,12 @@
<script>
-import CodeCell from './code/index.vue';
+import CodeOutput from './code/index.vue';
import OutputCell from './output/index.vue';
export default {
+ name: 'CodeCell',
components: {
- 'code-cell': CodeCell,
- 'output-cell': OutputCell,
+ CodeOutput,
+ OutputCell,
},
props: {
cell: {
@@ -29,8 +30,8 @@ export default {
hasOutput() {
return this.cell.outputs.length;
},
- output() {
- return this.cell.outputs[0];
+ outputs() {
+ return this.cell.outputs;
},
},
};
@@ -38,7 +39,7 @@ export default {
<template>
<div class="cell">
- <code-cell
+ <code-output
:raw-code="rawInputCode"
:count="cell.execution_count"
:code-css-class="codeCssClass"
@@ -47,7 +48,7 @@ export default {
<output-cell
v-if="hasOutput"
:count="cell.execution_count"
- :output="output"
+ :outputs="outputs"
:code-css-class="codeCssClass"
/>
</div>
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 8bf2431c4c6..98b6cdd0944 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -3,8 +3,9 @@ import Prism from '../../lib/highlight';
import Prompt from '../prompt.vue';
export default {
+ name: 'CodeOutput',
components: {
- prompt: Prompt,
+ Prompt,
},
props: {
count: {
diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue
index c6fc786fa76..8dc2d73af9b 100644
--- a/app/assets/javascripts/notebook/cells/output/html.vue
+++ b/app/assets/javascripts/notebook/cells/output/html.vue
@@ -4,13 +4,21 @@ import Prompt from '../prompt.vue';
export default {
components: {
- prompt: Prompt,
+ Prompt,
},
props: {
+ count: {
+ type: Number,
+ required: true,
+ },
rawCode: {
type: String,
required: true,
},
+ index: {
+ type: Number,
+ required: true,
+ },
},
computed: {
sanitizedOutput() {
@@ -21,13 +29,16 @@ export default {
},
});
},
+ showOutput() {
+ return this.index === 0;
+ },
},
};
</script>
<template>
<div class="output">
- <prompt />
+ <prompt type="Out" :count="count" :show-output="showOutput" />
<div v-html="sanitizedOutput"></div>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/image.vue b/app/assets/javascripts/notebook/cells/output/image.vue
index fe8c81398fb..f1130275525 100644
--- a/app/assets/javascripts/notebook/cells/output/image.vue
+++ b/app/assets/javascripts/notebook/cells/output/image.vue
@@ -6,6 +6,10 @@ export default {
prompt: Prompt,
},
props: {
+ count: {
+ type: Number,
+ required: true,
+ },
outputType: {
type: String,
required: true,
@@ -14,10 +18,24 @@ export default {
type: String,
required: true,
},
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ imgSrc() {
+ return `data:${this.outputType};base64,${this.rawCode}`;
+ },
+ showOutput() {
+ return this.index === 0;
+ },
},
};
</script>
<template>
- <div class="output"><prompt /> <img :src="'data:' + outputType + ';base64,' + rawCode" /></div>
+ <div class="output">
+ <prompt type="out" :count="count" :show-output="showOutput" /> <img :src="imgSrc" />
+ </div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index bd0bcc0d819..b59ddd0d57a 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -1,14 +1,9 @@
<script>
-import CodeCell from '../code/index.vue';
-import Html from './html.vue';
-import Image from './image.vue';
+import CodeOutput from '../code/index.vue';
+import HtmlOutput from './html.vue';
+import ImageOutput from './image.vue';
export default {
- components: {
- 'code-cell': CodeCell,
- 'html-output': Html,
- 'image-output': Image,
- },
props: {
codeCssClass: {
type: String,
@@ -20,50 +15,27 @@ export default {
required: false,
default: 0,
},
- output: {
- type: Object,
+ outputs: {
+ type: Array,
required: true,
- default: () => ({}),
},
},
- computed: {
- componentName() {
- if (this.output.text) {
- return 'code-cell';
- } else if (this.output.data['image/png']) {
- return 'image-output';
- } else if (this.output.data['text/html']) {
- return 'html-output';
- } else if (this.output.data['image/svg+xml']) {
- return 'html-output';
- }
-
- return 'code-cell';
- },
- rawCode() {
- if (this.output.text) {
- return this.output.text.join('');
- }
-
- return this.dataForType(this.outputType);
- },
- outputType() {
- if (this.output.text) {
- return '';
- } else if (this.output.data['image/png']) {
+ methods: {
+ outputType(output) {
+ if (output.text) {
+ return 'text/plain';
+ } else if (output.data['image/png']) {
return 'image/png';
- } else if (this.output.data['text/html']) {
+ } else if (output.data['text/html']) {
return 'text/html';
- } else if (this.output.data['image/svg+xml']) {
+ } else if (output.data['image/svg+xml']) {
return 'image/svg+xml';
}
return 'text/plain';
},
- },
- methods: {
- dataForType(type) {
- let data = this.output.data[type];
+ dataForType(output, type) {
+ let data = output.data[type];
if (typeof data === 'object') {
data = data.join('');
@@ -71,17 +43,42 @@ export default {
return data;
},
+ getComponent(output) {
+ if (output.text) {
+ return CodeOutput;
+ } else if (output.data['image/png']) {
+ return ImageOutput;
+ } else if (output.data['text/html']) {
+ return HtmlOutput;
+ } else if (output.data['image/svg+xml']) {
+ return HtmlOutput;
+ }
+
+ return CodeOutput;
+ },
+ rawCode(output) {
+ if (output.text) {
+ return output.text.join('');
+ }
+
+ return this.dataForType(output, this.outputType(output));
+ },
},
};
</script>
<template>
- <component
- :is="componentName"
- :output-type="outputType"
- :count="count"
- :raw-code="rawCode"
- :code-css-class="codeCssClass"
- type="output"
- />
+ <div>
+ <component
+ :is="getComponent(output)"
+ v-for="(output, index) in outputs"
+ :key="index"
+ type="output"
+ :output-type="outputType(output)"
+ :count="count"
+ :index="index"
+ :raw-code="rawCode(output)"
+ :code-css-class="codeCssClass"
+ />
+ </div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/prompt.vue b/app/assets/javascripts/notebook/cells/prompt.vue
index 3f1f239a806..1eeb61844a4 100644
--- a/app/assets/javascripts/notebook/cells/prompt.vue
+++ b/app/assets/javascripts/notebook/cells/prompt.vue
@@ -11,18 +11,26 @@ export default {
required: false,
default: 0,
},
+ showOutput: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
hasKeys() {
return this.type !== '' && this.count;
},
+ showTypeText() {
+ return this.type && this.count && this.showOutput;
+ },
},
};
</script>
<template>
<div class="prompt">
- <span v-if="hasKeys"> {{ type }} [{{ count }}]: </span>
+ <span v-if="showTypeText"> {{ type }} [{{ count }}]: </span>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/index.vue b/app/assets/javascripts/notebook/index.vue
index 6a54d0b3823..e7056c03e4a 100644
--- a/app/assets/javascripts/notebook/index.vue
+++ b/app/assets/javascripts/notebook/index.vue
@@ -3,8 +3,8 @@ import { MarkdownCell, CodeCell } from './cells';
export default {
components: {
- 'code-cell': CodeCell,
- 'markdown-cell': MarkdownCell,
+ CodeCell,
+ MarkdownCell,
},
props: {
notebook: {
diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js
index dfb53c986fc..d03f4508fb8 100644
--- a/app/assets/javascripts/notes.js
+++ b/app/assets/javascripts/notes.js
@@ -11,8 +11,8 @@ import $ from 'jquery';
import _ from 'underscore';
import Cookies from 'js-cookie';
import Autosize from 'autosize';
-import 'vendor/jquery.caret'; // required by jquery.atwho
-import 'vendor/jquery.atwho';
+import 'jquery.caret'; // required by at.js
+import 'at.js';
import AjaxCache from '~/lib/utils/ajax_cache';
import Vue from 'vue';
import syntaxHighlight from '~/syntax_highlight';
@@ -35,6 +35,7 @@ import {
} from './lib/utils/common_utils';
import imageDiffHelper from './image_diff/helpers/index';
import { localTimeAgo } from './lib/utils/datetime_utility';
+import { sprintf, s__, __ } from './locale';
window.autosize = Autosize;
@@ -138,8 +139,6 @@ export default class Notes {
this.$wrapperEl.on('click', '.js-note-delete', this.removeNote);
// delete note attachment
this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment);
- // reset main target form when clicking discard
- this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm);
// update the file name when an attachment is selected
this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment);
// reply to diff/discussion notes
@@ -191,7 +190,6 @@ export default class Notes {
this.$wrapperEl.off('keyup input', '.js-note-text');
this.$wrapperEl.off('click', '.js-note-target-reopen');
this.$wrapperEl.off('click', '.js-note-target-close');
- this.$wrapperEl.off('click', '.js-note-discard');
this.$wrapperEl.off('keydown', '.js-note-text');
this.$wrapperEl.off('click', '.js-comment-resolve-button');
this.$wrapperEl.off('click', '.system-note-commit-list-toggler');
@@ -256,7 +254,7 @@ export default class Notes {
discussionNoteForm = $textarea.closest('.js-discussion-note-form');
if (discussionNoteForm.length) {
if ($textarea.val() !== '') {
- if (!window.confirm('Are you sure you want to cancel creating this comment?')) {
+ if (!window.confirm(__('Are you sure you want to cancel creating this comment?'))) {
return;
}
}
@@ -268,7 +266,7 @@ export default class Notes {
originalText = $textarea.closest('form').data('originalNote');
newText = $textarea.val();
if (originalText !== newText) {
- if (!window.confirm('Are you sure you want to cancel editing this comment?')) {
+ if (!window.confirm(__('Are you sure you want to cancel editing this comment?'))) {
return;
}
}
@@ -509,7 +507,7 @@ export default class Notes {
var contentContainerClass =
'.' +
$notes
- .closest('.notes_content')
+ .closest('.notes-content')
.attr('class')
.split(' ')
.join('.');
@@ -639,7 +637,7 @@ export default class Notes {
this.glForm = new GLForm(form, enableGFM);
textarea = form.find('.js-note-text');
key = [
- 'Note',
+ s__('NoteForm|Note'),
form.find('#note_noteable_type').val(),
form.find('#note_noteable_id').val(),
form.find('#note_commit_id').val(),
@@ -673,7 +671,9 @@ export default class Notes {
formParentTimeline = $form.closest('.discussion-notes').find('.notes');
}
return this.addFlash(
- 'Your comment could not be submitted! Please check your network connection and try again.',
+ __(
+ 'Your comment could not be submitted! Please check your network connection and try again.',
+ ),
'alert',
formParentTimeline.get(0),
);
@@ -682,7 +682,7 @@ export default class Notes {
updateNoteError($parentTimeline) {
// eslint-disable-next-line no-new
new Flash(
- 'Your comment could not be updated! Please check your network connection and try again.',
+ __('Your comment could not be updated! Please check your network connection and try again.'),
);
}
@@ -986,11 +986,9 @@ export default class Notes {
form.find('#note_position').val(dataHolder.attr('data-position'));
form
- .find('.js-note-discard')
+ .find('.js-close-discussion-note-form')
.show()
- .removeClass('js-note-discard')
- .addClass('js-close-discussion-note-form')
- .text(form.find('.js-close-discussion-note-form').data('cancelText'));
+ .removeClass('hide');
form.find('.js-note-target-close').remove();
form.find('.js-note-new-discussion').remove();
this.setupNoteForm(form);
@@ -1074,14 +1072,14 @@ export default class Notes {
addForm = false;
let lineTypeSelector = '';
rowCssToAdd =
- '<tr class="notes_holder js-temp-notes-holder"><td class="notes_content" colspan="3"><div class="content"></div></td></tr>';
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes-content" colspan="3"><div class="content"></div></td></tr>';
// In parallel view, look inside the correct left/right pane
if (this.isParallelView()) {
lineTypeSelector = `.${lineType}`;
rowCssToAdd =
- '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes_content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes_content parallel new"><div class="content"></div></td></tr>';
+ '<tr class="notes_holder js-temp-notes-holder"><td class="notes_line old"></td><td class="notes-content parallel old"><div class="content"></div></td><td class="notes_line new"></td><td class="notes-content parallel new"><div class="content"></div></td></tr>';
}
- const notesContentSelector = `.notes_content${lineTypeSelector} .content`;
+ const notesContentSelector = `.notes-content${lineTypeSelector} .content`;
let notesContent = targetRow.find(notesContentSelector);
if (hasNotes && showReplyInput) {
@@ -1194,12 +1192,11 @@ export default class Notes {
}
updateTargetButtons(e) {
- var closebtn, closetext, discardbtn, form, reopenbtn, reopentext, textarea;
+ var closebtn, closetext, form, reopenbtn, reopentext, textarea;
textarea = $(e.target);
form = textarea.parents('form');
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.attr('data-alternative-text');
@@ -1216,9 +1213,6 @@ export default class Notes {
if (closebtn.is(':not(.btn-comment-and-close)')) {
closebtn.addClass('btn-comment-and-close');
}
- if (discardbtn.is(':hidden')) {
- return discardbtn.show();
- }
} else {
reopentext = reopenbtn.data('originalText');
closetext = closebtn.data('originalText');
@@ -1234,9 +1228,6 @@ export default class Notes {
if (closebtn.is('.btn-comment-and-close')) {
closebtn.removeClass('btn-comment-and-close');
}
- if (discardbtn.is(':visible')) {
- return discardbtn.hide();
- }
}
}
@@ -1251,15 +1242,13 @@ export default class Notes {
var postUrl = $originalContentEl.data('postUrl');
var targetId = $originalContentEl.data('targetId');
var targetType = $originalContentEl.data('targetType');
- var markdownVersion = $originalContentEl.data('markdownVersion');
this.glForm = new GLForm($editForm.find('form'), this.enableGFM);
$editForm
.find('form')
.attr('action', `${postUrl}?html=true`)
- .attr('data-remote', 'true')
- .attr('data-markdown-version', markdownVersion);
+ .attr('data-remote', 'true');
$editForm.find('.js-form-target-id').val(targetId);
$editForm.find('.js-form-target-type').val(targetType);
$editForm
@@ -1272,12 +1261,19 @@ export default class Notes {
putConflictEditWarningInPlace(noteEntity, $note) {
if ($note.find('.js-conflict-edit-warning').length === 0) {
+ const open_link = `<a href="#note_${
+ noteEntity.id
+ }" target="_blank" rel="noopener noreferrer">`;
const $alert = $(`<div class="js-conflict-edit-warning alert alert-danger">
- This comment has changed since you started editing, please review the
- <a href="#note_${noteEntity.id}" target="_blank" rel="noopener noreferrer">
- updated comment
- </a>
- to ensure information is not lost
+ ${sprintf(
+ s__(
+ 'Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost',
+ ),
+ {
+ open_link,
+ close_link: '</a>',
+ },
+ )}
</div>`);
$alert.insertAfter($note.find('.note-text'));
}
@@ -1505,13 +1501,15 @@ export default class Notes {
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
- tempFormContent = 'Applying multiple commands';
+ tempFormContent = __('Applying multiple commands');
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
- tempFormContent = `Applying command to ${commandDescription}`;
+ tempFormContent = sprintf(__('Applying command to %{commandDescription}'), {
+ commandDescription,
+ });
}
} else {
- tempFormContent = 'Applying command';
+ tempFormContent = __('Applying command');
}
return tempFormContent;
@@ -1831,7 +1829,9 @@ export default class Notes {
$editingNote
.find('.note-headline-meta a')
.html(
- '<i class="fa fa-spinner fa-spin" aria-label="Comment is being updated" aria-hidden="true"></i>',
+ `<i class="fa fa-spinner fa-spin" aria-label="${__(
+ 'Comment is being updated',
+ )}" aria-hidden="true"></i>`,
);
// Make request to update comment on server
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index ce56beb1e6b..688c06878ac 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -11,6 +11,7 @@ import {
capitalizeFirstCharacter,
convertToCamelCase,
splitCamelCase,
+ slugifyWithUnderscore,
} from '../../lib/utils/text_utility';
import * as constants from '../constants';
import eventHub from '../event_hub';
@@ -39,11 +40,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
},
data() {
return {
@@ -120,8 +116,11 @@ export default {
author() {
return this.getUserData;
},
- canUpdateIssue() {
- return this.getNoteableData.current_user.can_update;
+ canToggleIssueState() {
+ return (
+ this.getNoteableData.current_user.can_update &&
+ this.getNoteableData.state !== constants.MERGED
+ );
},
endpoint() {
return this.getNoteableData.create_note_path;
@@ -131,6 +130,9 @@ export default {
? 'merge request'
: 'issue';
},
+ trackingLabel() {
+ return slugifyWithUnderscore(`${this.commentButtonTitle} button`);
+ },
},
watch: {
note(newNote) {
@@ -342,7 +344,6 @@ Please check your network connection and try again.`;
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- :markdown-version="markdownVersion"
:add-spacing-classes="false"
>
<textarea
@@ -350,6 +351,7 @@ Please check your network connection and try again.`;
ref="textarea"
slot="textarea"
v-model="note"
+ dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text
@@ -357,9 +359,9 @@ js-gfm-input js-autosize markdown-area js-vue-textarea qa-comment-input"
data-supports-quick-actions="true"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
- @keydown.up="editCurrentUserLastNote();"
- @keydown.meta.enter="handleSave();"
- @keydown.ctrl.enter="handleSave();"
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleSave()"
+ @keydown.ctrl.enter="handleSave()"
>
</textarea>
</markdown-field>
@@ -370,10 +372,12 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
>
<button
:disabled="isSubmitButtonDisabled"
- class="btn btn-create comment-btn js-comment-button js-comment-submit-button
+ class="btn btn-success js-comment-button js-comment-submit-button
qa-comment-button"
type="submit"
- @click.prevent="handleSave();"
+ :data-track-label="trackingLabel"
+ data-track-event="click_button"
+ @click.prevent="handleSave()"
>
{{ __(commentButtonTitle) }}
</button>
@@ -381,7 +385,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
:disabled="isSubmitButtonDisabled"
name="button"
type="button"
- class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
+ class="btn btn-success note-type-toggle js-note-new-discussion dropdown-toggle qa-note-dropdown"
data-display="static"
data-toggle="dropdown"
aria-label="Open comment type dropdown"
@@ -394,7 +398,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<button
type="button"
class="btn btn-transparent"
- @click.prevent="setNoteType('comment');"
+ @click.prevent="setNoteType('comment')"
>
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
@@ -408,7 +412,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
<button
type="button"
class="btn btn-transparent qa-discussion-option"
- @click.prevent="setNoteType('discussion');"
+ @click.prevent="setNoteType('discussion')"
>
<i aria-hidden="true" class="fa fa-check icon"> </i>
<div class="description">
@@ -421,7 +425,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</div>
<loading-button
- v-if="canUpdateIssue"
+ v-if="canToggleIssueState"
:loading="isToggleStateButtonLoading"
:container-class="[
actionButtonClassNames,
@@ -429,17 +433,8 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
]"
:disabled="isToggleStateButtonLoading || isSubmitting"
:label="issueActionButtonTitle"
- @click="handleSave(true);"
+ @click="handleSave(true)"
/>
-
- <button
- v-if="note.length"
- type="button"
- class="btn btn-cancel js-note-discard"
- @click="discard"
- >
- Discard draft
- </button>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue
index af821df0fd2..b95835ed10a 100644
--- a/app/assets/javascripts/notes/components/diff_with_note.vue
+++ b/app/assets/javascripts/notes/components/diff_with_note.vue
@@ -5,8 +5,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { GlSkeletonLoading } from '@gitlab/ui';
import { getDiffMode } from '~/diffs/store/utils';
-
-const FIRST_CHAR_REGEX = /^(\+|-| )/;
+import { diffViewerModes } from '~/ide/constants';
export default {
components: {
@@ -33,6 +32,12 @@ export default {
diffMode() {
return getDiffMode(this.discussion.diff_file);
},
+ diffViewerMode() {
+ return this.discussion.diff_file.viewer.name;
+ },
+ isTextFile() {
+ return this.diffViewerMode === diffViewerModes.text;
+ },
hasTruncatedDiffLines() {
return (
this.discussion.truncated_diff_lines && this.discussion.truncated_diff_lines.length !== 0
@@ -54,28 +59,21 @@ export default {
this.error = true;
});
},
- trimChar(line) {
- return line.replace(FIRST_CHAR_REGEX, '');
- },
},
userColorSchemeClass: window.gon.user_color_scheme,
};
</script>
<template>
- <div :class="{ 'text-file': discussion.diff_file.text }" class="diff-file file-holder">
+ <div :class="{ 'text-file': isTextFile }" class="diff-file file-holder">
<diff-file-header
:discussion-path="discussion.discussion_path"
:diff-file="discussion.diff_file"
:can-current-user-fork="false"
- :expanded="!discussion.diff_file.collapsed"
+ :expanded="!discussion.diff_file.viewer.collapsed"
/>
- <div
- v-if="discussion.diff_file.text"
- :class="$options.userColorSchemeClass"
- class="diff-content code"
- >
- <table>
+ <div v-if="isTextFile" class="diff-content">
+ <table class="code js-syntax-highlight" :class="$options.userColorSchemeClass">
<template v-if="hasTruncatedDiffLines">
<tr
v-for="line in discussion.truncated_diff_lines"
@@ -83,9 +81,9 @@ export default {
:key="line.line_code"
class="line_holder"
>
- <td class="diff-line-num old_line">{{ line.old_line }}</td>
- <td class="diff-line-num new_line">{{ line.new_line }}</td>
- <td :class="line.type" class="line_content" v-html="trimChar(line.rich_text)"></td>
+ <td :class="line.type" class="diff-line-num old_line">{{ line.old_line }}</td>
+ <td :class="line.type" class="diff-line-num new_line">{{ line.new_line }}</td>
+ <td :class="line.type" class="line_content" v-html="line.rich_text"></td>
</tr>
</template>
<tr v-if="!hasTruncatedDiffLines" class="line_holder line-holder-placeholder">
@@ -107,13 +105,14 @@ export default {
</td>
</tr>
<tr class="notes_holder">
- <td class="notes_content" colspan="3"><slot></slot></td>
+ <td class="notes-content" colspan="3"><slot></slot></td>
</tr>
</table>
</div>
<div v-else>
<diff-viewer
:diff-mode="diffMode"
+ :diff-viewer-mode="diffViewerMode"
:new-path="discussion.diff_file.new_path"
:new-sha="discussion.diff_file.diff_refs.head_sha"
:old-path="discussion.diff_file.old_path"
diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue
new file mode 100644
index 00000000000..22cca756ef6
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_actions.vue
@@ -0,0 +1,58 @@
+<script>
+import ReplyPlaceholder from './discussion_reply_placeholder.vue';
+import ResolveDiscussionButton from './discussion_resolve_button.vue';
+import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue';
+import JumpToNextDiscussionButton from './discussion_jump_to_next_button.vue';
+
+export default {
+ name: 'DiscussionActions',
+ components: {
+ ReplyPlaceholder,
+ ResolveDiscussionButton,
+ ResolveWithIssueButton,
+ JumpToNextDiscussionButton,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ isResolving: {
+ type: Boolean,
+ required: true,
+ },
+ resolveButtonTitle: {
+ type: String,
+ required: true,
+ },
+ resolveWithIssuePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ shouldShowJumpToNextDiscussion: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-with-resolve-btn">
+ <reply-placeholder class="qa-discussion-reply" @onClick="$emit('showReplyForm')" />
+ <resolve-discussion-button
+ v-if="discussion.resolvable"
+ :is-resolving="isResolving"
+ :button-title="resolveButtonTitle"
+ @onClick="$emit('resolve')"
+ />
+ <div v-if="discussion.resolvable" class="btn-group discussion-actions ml-sm-2" role="group">
+ <resolve-with-issue-button v-if="resolveWithIssuePath" :url="resolveWithIssuePath" />
+ <jump-to-next-discussion-button
+ v-if="shouldShowJumpToNextDiscussion"
+ @onClick="$emit('jumpToNextDiscussion')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_filter.vue b/app/assets/javascripts/notes/components/discussion_filter.vue
index 86c114a761a..47951591e82 100644
--- a/app/assets/javascripts/notes/components/discussion_filter.vue
+++ b/app/assets/javascripts/notes/components/discussion_filter.vue
@@ -1,8 +1,15 @@
<script>
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
+import { getLocationHash } from '../../lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
-import { DISCUSSION_FILTERS_DEFAULT_VALUE, HISTORY_ONLY_FILTER_VALUE } from '../constants';
+import {
+ DISCUSSION_FILTERS_DEFAULT_VALUE,
+ HISTORY_ONLY_FILTER_VALUE,
+ DISCUSSION_TAB_LABEL,
+ DISCUSSION_FILTER_TYPES,
+} from '../constants';
+import notesEventHub from '../event_hub';
export default {
components: {
@@ -15,7 +22,7 @@ export default {
},
selectedValue: {
type: Number,
- default: null,
+ default: DISCUSSION_FILTERS_DEFAULT_VALUE,
required: false,
},
},
@@ -23,6 +30,7 @@ export default {
return {
currentValue: this.selectedValue,
defaultValue: DISCUSSION_FILTERS_DEFAULT_VALUE,
+ displayFilters: true,
};
},
computed: {
@@ -32,31 +40,73 @@ export default {
return this.filters.find(filter => filter.value === this.currentValue);
},
},
+ created() {
+ if (window.mrTabs) {
+ const { eventHub, currentTab } = window.mrTabs;
+
+ eventHub.$on('MergeRequestTabChange', this.toggleFilters);
+ this.toggleFilters(currentTab);
+ }
+
+ notesEventHub.$on('dropdownSelect', this.selectFilter);
+ window.addEventListener('hashchange', this.handleLocationHash);
+ this.handleLocationHash();
+ },
mounted() {
this.toggleCommentsForm();
},
+ destroyed() {
+ notesEventHub.$off('dropdownSelect', this.selectFilter);
+ window.removeEventListener('hashchange', this.handleLocationHash);
+ },
methods: {
- ...mapActions(['filterDiscussion', 'setCommentsDisabled']),
+ ...mapActions(['filterDiscussion', 'setCommentsDisabled', 'setTargetNoteHash']),
selectFilter(value) {
const filter = parseInt(value, 10);
// close dropdown
- $(this.$refs.dropdownToggle).dropdown('toggle');
+ this.toggleDropdown();
if (filter === this.currentValue) return;
this.currentValue = filter;
this.filterDiscussion({ path: this.getNotesDataByProp('discussionsPath'), filter });
this.toggleCommentsForm();
},
+ toggleDropdown() {
+ $(this.$refs.dropdownToggle).dropdown('toggle');
+ },
toggleCommentsForm() {
this.setCommentsDisabled(this.currentValue === HISTORY_ONLY_FILTER_VALUE);
},
+ toggleFilters(tab) {
+ this.displayFilters = tab === DISCUSSION_TAB_LABEL;
+ },
+ handleLocationHash() {
+ const hash = getLocationHash();
+
+ if (/^note_/.test(hash) && this.currentValue !== DISCUSSION_FILTERS_DEFAULT_VALUE) {
+ this.selectFilter(this.defaultValue);
+ this.toggleDropdown(); // close dropdown
+ this.setTargetNoteHash(hash);
+ }
+ },
+ filterType(value) {
+ if (value === 0) {
+ return DISCUSSION_FILTER_TYPES.ALL;
+ } else if (value === 1) {
+ return DISCUSSION_FILTER_TYPES.COMMENTS;
+ }
+ return DISCUSSION_FILTER_TYPES.HISTORY;
+ },
},
};
</script>
<template>
- <div class="discussion-filter-container d-inline-block align-bottom">
+ <div
+ v-if="displayFilters"
+ class="discussion-filter-container js-discussion-filter-container d-inline-block align-bottom"
+ >
<button
id="discussion-filter-dropdown"
ref="dropdownToggle"
@@ -67,17 +117,22 @@ export default {
{{ currentFilter.title }} <icon name="chevron-down" />
</button>
<div
+ ref="dropdownMenu"
class="dropdown-menu dropdown-menu-selectable dropdown-menu-right"
aria-labelledby="discussion-filter-dropdown"
>
<div class="dropdown-content">
<ul>
- <li v-for="filter in filters" :key="filter.value">
+ <li
+ v-for="filter in filters"
+ :key="filter.value"
+ :data-filter-type="filterType(filter.value)"
+ >
<button
:class="{ 'is-active': filter.value === currentValue }"
class="qa-filter-options"
type="button"
- @click="selectFilter(filter.value);"
+ @click="selectFilter(filter.value)"
>
{{ filter.title }}
</button>
diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue
new file mode 100644
index 00000000000..889731df180
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { __, sprintf } from '~/locale';
+
+import notesEventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ Icon,
+ },
+ computed: {
+ timelineContent() {
+ return sprintf(
+ __(
+ "You're only seeing %{startTag}other activity%{endTag} in the feed. To add a comment, switch to one of the following options.",
+ ),
+ {
+ startTag: `<b>`,
+ endTag: `</b>`,
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ selectFilter(value) {
+ notesEventHub.$emit('dropdownSelect', value);
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="timeline-entry note note-wrapper discussion-filter-note js-discussion-filter-note">
+ <div class="timeline-icon d-none d-lg-flex">
+ <icon name="comment" />
+ </div>
+ <div class="timeline-content">
+ <div v-html="timelineContent"></div>
+ <div class="discussion-filter-actions mt-2">
+ <gl-button variant="default" @click="selectFilter(0)">
+ {{ __('Show all activity') }}
+ </gl-button>
+ <gl-button variant="default" @click="selectFilter(1)">
+ {{ __('Show comments only') }}
+ </gl-button>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue
new file mode 100644
index 00000000000..07a5bda6bcb
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_jump_to_next_button.vue
@@ -0,0 +1,28 @@
+<script>
+import icon from '~/vue_shared/components/icon.vue';
+import { GlTooltipDirective } from '@gitlab/ui';
+
+export default {
+ name: 'JumpToNextDiscussionButton',
+ components: {
+ icon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+};
+</script>
+
+<template>
+ <div class="btn-group" role="group">
+ <button
+ ref="button"
+ v-gl-tooltip
+ class="btn btn-default discussion-next-btn"
+ :title="s__('MergeRequests|Jump to next unresolved discussion')"
+ @click="$emit('onClick')"
+ >
+ <icon name="comment-next" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue
new file mode 100644
index 00000000000..5b6163a6214
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_notes.vue
@@ -0,0 +1,155 @@
+<script>
+import { mapGetters } from 'vuex';
+import { SYSTEM_NOTE } from '../constants';
+import { __ } from '~/locale';
+import NoteableNote from './noteable_note.vue';
+import PlaceholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
+import PlaceholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
+import SystemNote from '~/vue_shared/components/notes/system_note.vue';
+import ToggleRepliesWidget from './toggle_replies_widget.vue';
+import NoteEditedText from './note_edited_text.vue';
+
+export default {
+ name: 'DiscussionNotes',
+ components: {
+ ToggleRepliesWidget,
+ NoteEditedText,
+ },
+ props: {
+ discussion: {
+ type: Object,
+ required: true,
+ },
+ isExpanded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ diffLine: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
+ shouldGroupReplies: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ ...mapGetters(['userCanReply']),
+ hasReplies() {
+ return !!this.replies.length;
+ },
+ replies() {
+ return this.discussion.notes.slice(1);
+ },
+ firstNote() {
+ return this.discussion.notes.slice(0, 1)[0];
+ },
+ resolvedText() {
+ return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
+ },
+ commit() {
+ if (!this.discussion.for_commit) {
+ return null;
+ }
+
+ return {
+ id: this.discussion.commit_id,
+ url: this.discussion.discussion_path,
+ };
+ },
+ },
+ methods: {
+ componentName(note) {
+ if (note.isPlaceholderNote) {
+ if (note.placeholderType === SYSTEM_NOTE) {
+ return PlaceholderSystemNote;
+ }
+
+ return PlaceholderNote;
+ }
+
+ if (note.system) {
+ return SystemNote;
+ }
+
+ return NoteableNote;
+ },
+ componentData(note) {
+ return note.isPlaceholderNote ? note.notes[0] : note;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="discussion-notes">
+ <ul class="notes">
+ <template v-if="shouldGroupReplies">
+ <component
+ :is="componentName(firstNote)"
+ :note="componentData(firstNote)"
+ :line="line"
+ :commit="commit"
+ :help-page-path="helpPagePath"
+ :show-reply-button="userCanReply"
+ @handle-delete-note="$emit('deleteNote')"
+ @start-replying="$emit('startReplying')"
+ >
+ <note-edited-text
+ v-if="discussion.resolved"
+ slot="discussion-resolved-text"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline discussion-resolved-text"
+ />
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ </component>
+ <toggle-replies-widget
+ v-if="hasReplies"
+ :collapsed="!isExpanded"
+ :replies="replies"
+ @toggle="$emit('toggleDiscussion')"
+ />
+ <template v-if="isExpanded">
+ <component
+ :is="componentName(note)"
+ v-for="note in replies"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="line"
+ @handle-delete-note="$emit('deleteNote')"
+ />
+ </template>
+ </template>
+ <template v-else>
+ <component
+ :is="componentName(note)"
+ v-for="(note, index) in discussion.notes"
+ :key="note.id"
+ :note="componentData(note)"
+ :help-page-path="helpPagePath"
+ :line="diffLine"
+ @handle-delete-note="$emit('deleteNote')"
+ >
+ <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
+ </component>
+ </template>
+ </ul>
+ <slot :show-replies="isExpanded || !hasReplies" name="footer"></slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
new file mode 100644
index 00000000000..ea590905e3c
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_reply_placeholder.vue
@@ -0,0 +1,17 @@
+<script>
+export default {
+ name: 'ReplyPlaceholder',
+};
+</script>
+
+<template>
+ <button
+ ref="button"
+ type="button"
+ class="js-vue-discussion-reply btn btn-text-field"
+ :title="s__('MergeRequests|Add a reply')"
+ @click="$emit('onClick')"
+ >
+ {{ s__('MergeRequests|Reply...') }}
+ </button>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue
new file mode 100644
index 00000000000..2b29d710236
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue
@@ -0,0 +1,28 @@
+<script>
+export default {
+ name: 'ResolveDiscussionButton',
+ props: {
+ isResolving: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ buttonTitle: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <button ref="button" type="button" class="btn btn-default ml-sm-2" @click="$emit('onClick')">
+ <i
+ v-if="isResolving"
+ ref="isResolvingIcon"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin"
+ ></i>
+ {{ buttonTitle }}
+ </button>
+</template>
diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
new file mode 100644
index 00000000000..e413398696a
--- /dev/null
+++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue
@@ -0,0 +1,34 @@
+<script>
+import Icon from '~/vue_shared/components/icon.vue';
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'ResolveWithIssueButton',
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ url: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="btn-group" role="group">
+ <gl-button
+ v-gl-tooltip
+ :href="url"
+ :title="s__('MergeRequests|Resolve this discussion in a new issue')"
+ class="new-issue-for-discussion discussion-create-issue-btn"
+ >
+ <icon name="issue-new" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue
index d99694b06e9..5a4ff15d198 100644
--- a/app/assets/javascripts/notes/components/note_actions.vue
+++ b/app/assets/javascripts/notes/components/note_actions.vue
@@ -2,16 +2,20 @@
import { mapGetters } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
+import resolvedStatusMixin from 'ee_else_ce/batch_comments/mixins/resolved_status';
+import ReplyButton from './note_actions/reply_button.vue';
export default {
name: 'NoteActions',
components: {
Icon,
+ ReplyButton,
GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
+ mixins: [resolvedStatusMixin],
props: {
authorId: {
type: Number,
@@ -36,6 +40,10 @@ export default {
required: false,
default: null,
},
+ showReply: {
+ type: Boolean,
+ required: true,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -92,15 +100,6 @@ export default {
currentUserId() {
return this.getUserDataByProp('id');
},
- resolveButtonTitle() {
- let title = 'Mark as resolved';
-
- if (this.resolvedBy) {
- title = `Resolved by ${this.resolvedBy.name}`;
- }
-
- return title;
- },
},
methods: {
onEdit() {
@@ -112,6 +111,11 @@ export default {
onResolve() {
this.$emit('handleResolve');
},
+ closeTooltip() {
+ this.$nextTick(() => {
+ this.$root.$emit('bv::hide::tooltip');
+ });
+ },
},
};
</script>
@@ -121,6 +125,7 @@ export default {
<span v-if="accessLevel" class="note-role user-access-role">{{ accessLevel }}</span>
<div v-if="canResolve" class="note-actions-item">
<button
+ ref="resolveButton"
v-gl-tooltip
:class="{ 'is-disabled': !resolvable, 'is-active': isResolved }"
:title="resolveButtonTitle"
@@ -137,25 +142,26 @@ export default {
</div>
<div v-if="canAwardEmoji" class="note-actions-item">
<a
- v-gl-tooltip.bottom
+ v-gl-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
- data-position="right"
href="#"
title="Add reaction"
>
- <gl-loading-icon inline />
- <icon
- css-classes="link-highlight award-control-icon-neutral"
- name="emoji_slightly_smiling_face"
- />
- <icon css-classes="link-highlight award-control-icon-positive" name="emoji_smiley" />
- <icon css-classes="link-highlight award-control-icon-super-positive" name="emoji_smiley" />
+ <icon css-classes="link-highlight award-control-icon-neutral" name="slight-smile" />
+ <icon css-classes="link-highlight award-control-icon-positive" name="smiley" />
+ <icon css-classes="link-highlight award-control-icon-super-positive" name="smiley" />
</a>
</div>
+ <reply-button
+ v-if="showReply"
+ ref="replyButton"
+ class="js-reply-button"
+ @startReplying="$emit('startReplying')"
+ />
<div v-if="canEdit" class="note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
@@ -166,7 +172,7 @@ export default {
</div>
<div v-if="showDeleteAction" class="note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="Delete comment"
class="note-action-button js-note-delete btn btn-transparent"
@@ -177,11 +183,12 @@ export default {
</div>
<div v-else-if="shouldShowActionsDropdown" class="dropdown more-actions note-actions-item">
<button
- v-gl-tooltip.bottom
+ v-gl-tooltip
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
+ @click="closeTooltip"
>
<icon css-classes="icon" name="ellipsis_v" />
</button>
diff --git a/app/assets/javascripts/notes/components/note_actions/reply_button.vue b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
new file mode 100644
index 00000000000..be8e42af9ea
--- /dev/null
+++ b/app/assets/javascripts/notes/components/note_actions/reply_button.vue
@@ -0,0 +1,30 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ name: 'ReplyButton',
+ components: {
+ Icon,
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+};
+</script>
+
+<template>
+ <div class="note-actions-item">
+ <gl-button
+ ref="button"
+ v-gl-tooltip
+ class="note-action-button"
+ variant="transparent"
+ :title="__('Reply to comment')"
+ @click="$emit('startReplying')"
+ >
+ <icon name="comment" css-classes="link-highlight" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue
index 3d60eb02db8..941b6d5cab3 100644
--- a/app/assets/javascripts/notes/components/note_awards_list.vue
+++ b/app/assets/javascripts/notes/components/note_awards_list.vue
@@ -1,6 +1,6 @@
<script>
import { mapActions, mapGetters } from 'vuex';
-import { GlTooltipDirective } from '@gitlab/ui';
+import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import { glEmojiTag } from '../../emoji';
@@ -10,7 +10,7 @@ export default {
Icon,
},
directives: {
- GlTooltip: GlTooltipDirective,
+ tooltip,
},
props: {
awards: {
@@ -167,35 +167,35 @@ export default {
<button
v-for="(awardList, awardName, index) in groupedAwards"
:key="index"
- v-gl-tooltip.bottom="{ boundary: 'viewport' }"
+ v-tooltip
:class="getAwardClassBindings(awardList)"
:title="awardTitle(awardList)"
+ data-boundary="viewport"
class="btn award-control"
type="button"
- @click="handleAward(awardName);"
+ @click="handleAward(awardName)"
>
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">{{ awardList.length }}</span>
</button>
<div v-if="canAwardEmoji" class="award-menu-holder">
<button
- v-gl-tooltip
+ v-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
aria-label="Add reaction"
data-boundary="viewport"
- data-placement="bottom"
type="button"
>
<span class="award-control-icon award-control-icon-neutral">
- <icon name="emoji_slightly_smiling_face" />
+ <icon name="slight-smile" />
</span>
<span class="award-control-icon award-control-icon-positive">
- <icon name="emoji_smiley" />
+ <icon name="smiley" />
</span>
<span class="award-control-icon award-control-icon-super-positive">
- <icon name="emoji_smiley" />
+ <icon name="smiley" />
</span>
<i
aria-hidden="true"
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index bcf5d334da4..88454c3fb4c 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions } from 'vuex';
import $ from 'jquery';
+import getDiscussion from 'ee_else_ce/notes/mixins/get_discussion';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
@@ -16,7 +17,7 @@ export default {
noteForm,
Suggestions,
},
- mixins: [autosave],
+ mixins: [autosave, getDiscussion],
props: {
note: {
type: Object,
@@ -76,16 +77,18 @@ export default {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
- handleFormUpdate(note, parentElement, callback) {
- this.$emit('handleFormUpdate', note, parentElement, callback);
+ handleFormUpdate(note, parentElement, callback, resolveDiscussion) {
+ this.$emit('handleFormUpdate', note, parentElement, callback, resolveDiscussion);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
- applySuggestion({ suggestionId, flashContainer, callback }) {
+ applySuggestion({ suggestionId, flashContainer, callback = () => {} }) {
const { discussion_id: discussionId, id: noteId } = this.note;
- this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then(
+ callback,
+ );
},
},
};
@@ -111,7 +114,8 @@ export default {
:line="line"
:note="note"
:help-page-path="helpPagePath"
- :markdown-version="note.cached_markdown_version"
+ :discussion="discussion"
+ :resolve-discussion="note.resolve_discussion"
@handleFormUpdate="handleFormUpdate"
@cancelForm="formCancelHandler"
/>
@@ -120,6 +124,7 @@ export default {
v-model="note.note"
:data-update-url="note.path"
class="hidden js-task-list-field"
+ dir="auto"
></textarea>
<note-edited-text
v-if="note.last_edited_at"
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 9b7f3d3588d..acbb91ce7be 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -6,6 +6,9 @@ import issueWarning from '../../vue_shared/components/issue/issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
+import { __ } from '~/locale';
+import { getDraft, updateDraft } from '~/lib/utils/autosave';
+import noteFormMixin from 'ee_else_ce/notes/mixins/note_form';
export default {
name: 'NoteForm',
@@ -13,7 +16,7 @@ export default {
issueWarning,
markdownField,
},
- mixins: [issuableStateMixin, resolvable],
+ mixins: [issuableStateMixin, resolvable, noteFormMixin],
props: {
noteBody: {
type: String,
@@ -25,15 +28,10 @@ export default {
required: false,
default: '',
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
saveButtonTitle: {
type: String,
required: false,
- default: 'Save comment',
+ default: __('Save comment'),
},
discussion: {
type: Object,
@@ -64,15 +62,31 @@ export default {
required: false,
default: null,
},
+ diffFile: {
+ type: Object,
+ required: false,
+ default: null,
+ },
helpPagePath: {
type: String,
required: false,
default: '',
},
+ autosaveKey: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
data() {
+ let updatedNoteBody = this.noteBody;
+
+ if (!updatedNoteBody && this.autosaveKey) {
+ updatedNoteBody = getDraft(this.autosaveKey) || '';
+ }
+
return {
- updatedNoteBody: this.noteBody,
+ updatedNoteBody,
conflictWhileEditing: false,
isSubmitting: false,
isResolving: this.resolveDiscussion,
@@ -94,9 +108,42 @@ export default {
}
return '#';
},
+ diffParams() {
+ if (this.diffFile) {
+ return {
+ filePath: this.diffFile.file_path,
+ refs: this.diffFile.diff_refs,
+ };
+ } else if (this.note && this.note.position) {
+ return {
+ filePath: this.note.position.new_path,
+ refs: this.note.position,
+ };
+ } else if (this.discussion && this.discussion.diff_file) {
+ return {
+ filePath: this.discussion.diff_file.file_path,
+ refs: this.discussion.diff_file.diff_refs,
+ };
+ }
+
+ return null;
+ },
markdownPreviewPath() {
const notable = this.getNoteableDataByProp('preview_note_path');
- return mergeUrlParams({ preview_suggestions: true }, notable);
+
+ const previewSuggestions = this.line && this.diffParams;
+ const params = previewSuggestions
+ ? {
+ preview_suggestions: previewSuggestions,
+ line: this.line.new_line,
+ file_path: this.diffParams.filePath,
+ base_sha: this.diffParams.refs.base_sha,
+ start_sha: this.diffParams.refs.start_sha,
+ head_sha: this.diffParams.refs.head_sha,
+ }
+ : {};
+
+ return mergeUrlParams(params, notable);
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
@@ -149,18 +196,6 @@ export default {
return shouldResolve || shouldToggleState;
},
- handleUpdate(shouldResolve) {
- const beforeSubmitDiscussionState = this.discussionResolved;
- this.isSubmitting = true;
-
- this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
- this.isSubmitting = false;
-
- if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
- this.resolveHandler(beforeSubmitDiscussionState);
- }
- });
- },
editMyLastNote() {
if (this.updatedNoteBody === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
@@ -176,6 +211,12 @@ export default {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelForm', shouldConfirm, this.noteBody !== this.updatedNoteBody);
},
+ onInput() {
+ if (this.autosaveKey) {
+ const { autosaveKey, updatedNoteBody: text } = this;
+ updateDraft(autosaveKey, text);
+ }
+ },
},
};
</script>
@@ -198,7 +239,6 @@ export default {
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :markdown-version="markdownVersion"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
@@ -214,37 +254,85 @@ export default {
:data-supports-quick-actions="!isEditing"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form js-vue-textarea qa-reply-input"
+ dir="auto"
aria-label="Description"
placeholder="Write a comment or drag your files here…"
- @keydown.meta.enter="handleUpdate();"
- @keydown.ctrl.enter="handleUpdate();"
- @keydown.up="editMyLastNote();"
- @keydown.esc="cancelHandler(true);"
+ @keydown.meta.enter="handleKeySubmit()"
+ @keydown.ctrl.enter="handleKeySubmit()"
+ @keydown.exact.up="editMyLastNote()"
+ @keydown.exact.esc="cancelHandler(true)"
+ @input="onInput"
></textarea>
</markdown-field>
<div class="note-form-actions clearfix">
- <button
- :disabled="isDisabled"
- type="button"
- class="js-vue-issue-save btn btn-success js-comment-button"
- @click="handleUpdate();"
- >
- {{ saveButtonTitle }}
- </button>
- <button
- v-if="discussion.resolvable"
- class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
- @click.prevent="handleUpdate(true);"
- >
- {{ resolveButtonTitle }}
- </button>
- <button
- class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
- type="button"
- @click="cancelHandler();"
- >
- Cancel
- </button>
+ <template v-if="showBatchCommentsActions">
+ <p v-if="showResolveDiscussionToggle">
+ <label>
+ <template v-if="discussionResolved">
+ <input
+ v-model="isUnresolving"
+ type="checkbox"
+ class="qa-unresolve-review-discussion"
+ />
+ {{ __('Unresolve discussion') }}
+ </template>
+ <template v-else>
+ <input v-model="isResolving" type="checkbox" class="qa-resolve-review-discussion" />
+ {{ __('Resolve discussion') }}
+ </template>
+ </label>
+ </p>
+ <div>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="btn btn-success qa-start-review"
+ @click="handleAddToReview"
+ >
+ <template v-if="hasDrafts">{{ __('Add to review') }}</template>
+ <template v-else>{{ __('Start a review') }}</template>
+ </button>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="btn qa-comment-now"
+ @click="handleUpdate()"
+ >
+ {{ __('Add comment now') }}
+ </button>
+ <button
+ class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
+ type="button"
+ @click="cancelHandler()"
+ >
+ {{ __('Cancel') }}
+ </button>
+ </div>
+ </template>
+ <template v-else>
+ <button
+ :disabled="isDisabled"
+ type="button"
+ class="js-vue-issue-save btn btn-success js-comment-button qa-reply-comment-button"
+ @click="handleUpdate()"
+ >
+ {{ saveButtonTitle }}
+ </button>
+ <button
+ v-if="discussion.resolvable"
+ class="btn btn-nr btn-default append-right-10 js-comment-resolve-button"
+ @click.prevent="handleUpdate(true)"
+ >
+ {{ resolveButtonTitle }}
+ </button>
+ <button
+ class="btn btn-cancel note-edit-cancel js-close-discussion-note-form"
+ type="button"
+ @click="cancelHandler()"
+ >
+ Cancel
+ </button>
+ </template>
</div>
</form>
</div>
diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue
index 7b39901024d..5c59c0c32dd 100644
--- a/app/assets/javascripts/notes/components/note_header.vue
+++ b/app/assets/javascripts/notes/components/note_header.vue
@@ -69,7 +69,7 @@ export default {
type="button"
@click="handleToggle"
>
- <i :class="toggleChevronClass" class="fa" aria-hidden="true"> </i>
+ <i :class="toggleChevronClass" class="fa" aria-hidden="true"></i>
{{ __('Toggle discussion') }}
</button>
</div>
@@ -81,35 +81,31 @@ export default {
:data-user-id="author.id"
:data-username="author.username"
>
+ <slot name="note-header-info"></slot>
<span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light"> @{{ author.username }} </span>
+ <span class="note-headline-light">@{{ author.username }}</span>
</a>
- <span v-else> {{ __('A deleted user') }} </span>
- <span class="note-headline-light">
- <span class="note-headline-meta">
- <span class="system-note-message"> <slot></slot> </span>
- <template v-if="createdAt">
- <span class="system-note-separator">
- <template v-if="actionText">
- {{ actionText }}
- </template>
- </span>
- <a
- :href="noteTimestampLink"
- class="note-timestamp system-note-separator"
- @click="updateTargetNoteHash"
- >
- <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
- </a>
- </template>
- <i
- class="fa fa-spinner fa-spin editing-spinner"
- aria-label="Comment is being updated"
- aria-hidden="true"
+ <span v-else>{{ __('A deleted user') }}</span>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="createdAt">
+ <span class="system-note-separator">
+ <template v-if="actionText">{{ actionText }}</template>
+ </span>
+ <a
+ :href="noteTimestampLink"
+ class="note-timestamp system-note-separator"
+ @click="updateTargetNoteHash"
>
- </i>
- </span>
+ <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
+ </a>
+ </template>
+ <i
+ class="fa fa-spinner fa-spin editing-spinner"
+ aria-label="Comment is being updated"
+ aria-hidden="true"
+ ></i>
</span>
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue
index 07c938a0021..2c549e7abdd 100644
--- a/app/assets/javascripts/notes/components/noteable_discussion.vue
+++ b/app/assets/javascripts/notes/components/noteable_discussion.vue
@@ -4,46 +4,42 @@ import { mapActions, mapGetters } from 'vuex';
import { GlTooltipDirective } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
-import systemNote from '~/vue_shared/components/notes/system_note.vue';
+import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave';
import icon from '~/vue_shared/components/icon.vue';
+import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import Flash from '../../flash';
-import { SYSTEM_NOTE } from '../constants';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
-import noteableNote from './noteable_note.vue';
import noteHeader from './note_header.vue';
-import toggleRepliesWidget from './toggle_replies_widget.vue';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import noteEditedText from './note_edited_text.vue';
import noteForm from './note_form.vue';
import diffWithNote from './diff_with_note.vue';
-import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
-import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
-import autosave from '../mixins/autosave';
import noteable from '../mixins/noteable';
import resolvable from '../mixins/resolvable';
import discussionNavigation from '../mixins/discussion_navigation';
+import eventHub from '../event_hub';
+import DiscussionNotes from './discussion_notes.vue';
+import DiscussionActions from './discussion_actions.vue';
export default {
name: 'NoteableDiscussion',
components: {
icon,
- noteableNote,
userAvatarLink,
noteHeader,
noteSignedOutWidget,
noteEditedText,
noteForm,
- toggleRepliesWidget,
- placeholderNote,
- placeholderSystemNote,
- systemNote,
+ DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'),
TimelineEntryItem,
+ DiscussionNotes,
+ DiscussionActions,
},
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [autosave, noteable, resolvable, discussionNavigation],
+ mixins: [noteable, resolvable, discussionNavigation, diffLineNoteFormMixin],
props: {
discussion: {
type: Object,
@@ -76,41 +72,34 @@ export default {
},
},
data() {
- const { diff_discussion: isDiffDiscussion, resolved } = this.discussion;
-
return {
isReplying: false,
isResolving: false,
resolveAsThread: true,
- isRepliesCollapsed: Boolean(!isDiffDiscussion && resolved),
};
},
computed: {
...mapGetters([
+ 'convertedDisscussionIds',
'getNoteableData',
+ 'userCanReply',
'nextUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
]),
author() {
- return this.initialDiscussion.author;
+ return this.firstNote.author;
},
- canReply() {
- return this.getNoteableData.current_user.can_create_note;
+ autosaveKey() {
+ return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id);
},
newNotePath() {
return this.getNoteableData.create_note_path;
},
- hasReplies() {
- return this.discussion.notes.length > 1;
- },
- initialDiscussion() {
+ firstNote() {
return this.discussion.notes.slice(0, 1)[0];
},
- replies() {
- return this.discussion.notes.slice(1);
- },
lastUpdatedBy() {
const { notes } = this.discussion;
@@ -163,11 +152,11 @@ export default {
return '';
},
- shouldShowDiscussions() {
- const { expanded, resolved } = this.discussion;
- const isResolvedNonDiffDiscussion = !this.discussion.diff_discussion && resolved;
-
- return expanded || this.alwaysExpanded || isResolvedNonDiffDiscussion;
+ isExpanded() {
+ return this.discussion.expanded || this.alwaysExpanded;
+ },
+ shouldHideDiscussionBody() {
+ return this.shouldRenderDiffs && !this.isExpanded;
},
actionText() {
const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`;
@@ -178,52 +167,54 @@ export default {
commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`;
}
- let text = s__('MergeRequests|started a discussion');
+ const {
+ for_commit: isForCommit,
+ diff_discussion: isDiffDiscussion,
+ active: isActive,
+ } = this.discussion;
- if (this.discussion.for_commit) {
+ let text = s__('MergeRequests|started a discussion');
+ if (isForCommit) {
text = s__(
'MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}',
);
- } else if (this.discussion.diff_discussion) {
- if (this.discussion.active) {
- text = s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}');
- } else {
- text = s__(
- 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}',
- );
- }
+ } else if (isDiffDiscussion && commitId) {
+ text = isActive
+ ? s__('MergeRequests|started a discussion on commit %{linkStart}%{commitId}%{linkEnd}')
+ : s__(
+ 'MergeRequests|started a discussion on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}',
+ );
+ } else if (isDiffDiscussion) {
+ text = isActive
+ ? s__('MergeRequests|started a discussion on %{linkStart}the diff%{linkEnd}')
+ : s__(
+ 'MergeRequests|started a discussion on %{linkStart}an old version of the diff%{linkEnd}',
+ );
}
- return sprintf(
- text,
- {
- commitId,
- linkStart,
- linkEnd,
- },
- false,
- );
+ return sprintf(text, { commitId, linkStart, linkEnd }, false);
},
diffLine() {
+ if (this.line) {
+ return this.line;
+ }
+
if (this.discussion.diff_discussion && this.discussion.truncated_diff_lines) {
return this.discussion.truncated_diff_lines.slice(-1)[0];
}
- return this.line;
+ return null;
},
- },
- watch: {
- isReplying() {
- if (this.isReplying) {
- this.$nextTick(() => {
- // Pass an extra key to separate reply and note edit forms
- this.initAutoSave({ ...this.initialDiscussion, ...this.discussion }, ['Reply']);
- });
- } else {
- this.disposeAutoSave();
- }
+ resolveWithIssuePath() {
+ return !this.discussionResolved ? this.discussion.resolve_with_issue_path : '';
},
},
+ created() {
+ eventHub.$on('startReplying', this.onStartReplying);
+ },
+ beforeDestroy() {
+ eventHub.$off('startReplying', this.onStartReplying);
+ },
methods: {
...mapActions([
'saveNote',
@@ -231,32 +222,12 @@ export default {
'removePlaceholderNotes',
'toggleResolveNote',
'expandDiscussion',
+ 'removeConvertedDiscussion',
]),
truncateSha,
- componentName(note) {
- if (note.isPlaceholderNote) {
- if (note.placeholderType === SYSTEM_NOTE) {
- return placeholderSystemNote;
- }
-
- return placeholderNote;
- }
-
- if (note.system) {
- return systemNote;
- }
-
- return noteableNote;
- },
- componentData(note) {
- return note.isPlaceholderNote ? note.notes[0] : note;
- },
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},
- toggleReplies() {
- this.isRepliesCollapsed = !this.isRepliesCollapsed;
- },
showReplyForm() {
this.isReplying = true;
},
@@ -270,8 +241,12 @@ export default {
}
}
+ if (this.convertedDisscussionIds.includes(this.discussion.id)) {
+ this.removeConvertedDiscussion(this.discussion.id);
+ }
+
this.isReplying = false;
- this.resetAutoSave();
+ clearDraft(this.autosaveKey);
},
saveReply(noteText, form, callback) {
const postData = {
@@ -280,6 +255,10 @@ export default {
note: { note: noteText },
};
+ if (this.convertedDisscussionIds.includes(this.discussion.id)) {
+ postData.return_discussion = true;
+ }
+
if (this.discussion.for_commit) {
postData.note_project_id = this.discussion.project_id;
}
@@ -293,7 +272,7 @@ export default {
this.isReplying = false;
this.saveNote(replyData)
.then(() => {
- this.resetAutoSave();
+ clearDraft(this.autosaveKey);
callback();
})
.catch(err => {
@@ -319,6 +298,11 @@ Please check your network connection and try again.`;
deleteNoteHandler(note) {
this.$emit('noteDeleted', this.discussion, note);
},
+ onStartReplying(discussionId) {
+ if (this.discussion.id === discussionId) {
+ this.showReplyForm();
+ }
+ },
},
};
</script>
@@ -337,147 +321,89 @@ Please check your network connection and try again.`;
:img-size="40"
/>
</div>
- <note-header
- :author="author"
- :created-at="initialDiscussion.created_at"
- :note-id="initialDiscussion.id"
- :include-toggle="true"
- :expanded="discussion.expanded"
- @toggleHandler="toggleDiscussionHandler"
- >
- <span v-html="actionText"></span>
- </note-header>
- <note-edited-text
- v-if="discussion.resolved"
- :edited-at="discussion.resolved_at"
- :edited-by="discussion.resolved_by"
- :action-text="resolvedText"
- class-name="discussion-headline-light js-discussion-headline"
- />
- <note-edited-text
- v-else-if="lastUpdatedAt"
- :edited-at="lastUpdatedAt"
- :edited-by="lastUpdatedBy"
- action-text="Last updated"
- class-name="discussion-headline-light js-discussion-headline"
- />
+ <div class="timeline-content">
+ <note-header
+ :author="author"
+ :created-at="firstNote.created_at"
+ :note-id="firstNote.id"
+ :include-toggle="true"
+ :expanded="discussion.expanded"
+ @toggleHandler="toggleDiscussionHandler"
+ >
+ <span v-html="actionText"></span>
+ </note-header>
+ <note-edited-text
+ v-if="discussion.resolved"
+ :edited-at="discussion.resolved_at"
+ :edited-by="discussion.resolved_by"
+ :action-text="resolvedText"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ <note-edited-text
+ v-else-if="lastUpdatedAt"
+ :edited-at="lastUpdatedAt"
+ :edited-by="lastUpdatedBy"
+ action-text="Last updated"
+ class-name="discussion-headline-light js-discussion-headline"
+ />
+ </div>
</div>
- <div v-if="shouldShowDiscussions" class="discussion-body">
+ <div v-if="!shouldHideDiscussionBody" class="discussion-body">
<component
:is="wrapperComponent"
v-bind="wrapperComponentProps"
class="card discussion-wrapper"
>
- <div class="discussion-notes">
- <ul class="notes">
- <template v-if="shouldGroupReplies">
- <component
- :is="componentName(initialDiscussion)"
- :note="componentData(initialDiscussion)"
- :line="line"
- :help-page-path="helpPagePath"
- @handleDeleteNote="deleteNoteHandler"
- >
- <slot slot="avatar-badge" name="avatar-badge"></slot>
- </component>
- <toggle-replies-widget
- v-if="hasReplies"
- :collapsed="isRepliesCollapsed"
- :replies="replies"
- @toggle="toggleReplies"
+ <discussion-notes
+ :discussion="discussion"
+ :diff-line="diffLine"
+ :help-page-path="helpPagePath"
+ :is-expanded="isExpanded"
+ :line="line"
+ :should-group-replies="shouldGroupReplies"
+ @startReplying="showReplyForm"
+ @toggleDiscussion="toggleDiscussionHandler"
+ @deleteNote="deleteNoteHandler"
+ >
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
+ <template #footer="{ showReplies }">
+ <draft-note
+ v-if="showDraft(discussion.reply_id)"
+ :key="`draft_${discussion.id}`"
+ :draft="draftForDiscussion(discussion.reply_id)"
+ />
+ <div
+ v-else-if="showReplies"
+ :class="{ 'is-replying': isReplying }"
+ class="discussion-reply-holder"
+ >
+ <discussion-actions
+ v-if="!isReplying && userCanReply"
+ :discussion="discussion"
+ :is-resolving="isResolving"
+ :resolve-button-title="resolveButtonTitle"
+ :resolve-with-issue-path="resolveWithIssuePath"
+ :should-show-jump-to-next-discussion="shouldShowJumpToNextDiscussion"
+ @showReplyForm="showReplyForm"
+ @resolve="resolveHandler"
+ @jumpToNextDiscussion="jumpToNextDiscussion"
/>
- <template v-if="!isRepliesCollapsed">
- <component
- :is="componentName(note)"
- v-for="note in replies"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
- :line="line"
- @handleDeleteNote="deleteNoteHandler"
- />
- </template>
- </template>
- <template v-else>
- <component
- :is="componentName(note)"
- v-for="(note, index) in discussion.notes"
- :key="note.id"
- :note="componentData(note)"
- :help-page-path="helpPagePath"
+ <note-form
+ v-if="isReplying"
+ ref="noteForm"
+ :discussion="discussion"
+ :is-editing="false"
:line="diffLine"
- @handleDeleteNote="deleteNoteHandler"
- >
- <slot v-if="index === 0" slot="avatar-badge" name="avatar-badge"></slot>
- </component>
- </template>
- </ul>
- <div
- v-if="!isRepliesCollapsed || !hasReplies"
- :class="{ 'is-replying': isReplying }"
- class="discussion-reply-holder"
- >
- <template v-if="!isReplying && canReply">
- <div class="discussion-with-resolve-btn">
- <button
- type="button"
- class="js-vue-discussion-reply btn btn-text-field qa-discussion-reply"
- title="Add a reply"
- @click="showReplyForm"
- >
- Reply...
- </button>
- <div v-if="discussion.resolvable">
- <button
- type="button"
- class="btn btn-default ml-sm-2"
- @click="resolveHandler();"
- >
- <i v-if="isResolving" aria-hidden="true" class="fa fa-spinner fa-spin"></i>
- {{ resolveButtonTitle }}
- </button>
- </div>
- <div
- v-if="discussion.resolvable"
- class="btn-group discussion-actions ml-sm-2"
- role="group"
- >
- <div v-if="!discussionResolved" class="btn-group" role="group">
- <a
- v-gl-tooltip
- :href="discussion.resolve_with_issue_path"
- :title="s__('MergeRequests|Resolve this discussion in a new issue')"
- class="new-issue-for-discussion btn btn-default discussion-create-issue-btn"
- >
- <icon name="issue-new" />
- </a>
- </div>
- <div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group">
- <button
- v-gl-tooltip
- class="btn btn-default discussion-next-btn"
- title="Jump to next unresolved discussion"
- @click="jumpToNextDiscussion"
- >
- <icon name="comment-next" />
- </button>
- </div>
- </div>
- </div>
- </template>
- <note-form
- v-if="isReplying"
- ref="noteForm"
- :discussion="discussion"
- :is-editing="false"
- :line="diffLine"
- save-button-title="Comment"
- @handleFormUpdate="saveReply"
- @cancelForm="cancelReplyForm"
- />
- <note-signed-out-widget v-if="!canReply" />
- </div>
- </div>
+ save-button-title="Comment"
+ :autosave-key="autosaveKey"
+ @handleFormUpdateAddToReview="addReplyToReview"
+ @handleFormUpdate="saveReply"
+ @cancelForm="cancelReplyForm"
+ />
+ <note-signed-out-widget v-if="!userCanReply" />
+ </div>
+ </template>
+ </discussion-notes>
</component>
</div>
</div>
diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue
index 57e9c40bd61..47d74c2f892 100644
--- a/app/assets/javascripts/notes/components/noteable_note.vue
+++ b/app/assets/javascripts/notes/components/noteable_note.vue
@@ -2,7 +2,10 @@
import $ from 'jquery';
import { mapGetters, mapActions } from 'vuex';
import { escape } from 'underscore';
+import { truncateSha } from '~/lib/utils/text_utility';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
+import draftMixin from 'ee_else_ce/notes/mixins/draft';
+import { s__, sprintf } from '../../locale';
import Flash from '../../flash';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import noteHeader from './note_header.vue';
@@ -21,7 +24,7 @@ export default {
noteBody,
TimelineEntryItem,
},
- mixins: [noteable, resolvable],
+ mixins: [noteable, resolvable, draftMixin],
props: {
note: {
type: Object,
@@ -37,6 +40,16 @@ export default {
required: false,
default: '',
},
+ commit: {
+ type: Object,
+ required: false,
+ default: () => null,
+ },
+ showReplyButton: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -47,7 +60,7 @@ export default {
};
},
computed: {
- ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData']),
+ ...mapGetters(['targetNoteHash', 'getNoteableData', 'getUserData', 'commentsDisabled']),
author() {
return this.note.author;
},
@@ -61,9 +74,6 @@ export default {
'is-editable': this.note.current_user.can_edit,
};
},
- canResolve() {
- return this.note.resolvable && !!this.getUserData.id;
- },
canReportAsAbuse() {
return !!this.note.report_abuse_path && this.author.id !== this.getUserData.id;
},
@@ -73,6 +83,27 @@ export default {
isTarget() {
return this.targetNoteHash === this.noteAnchorId;
},
+ discussionId() {
+ if (this.discussion) {
+ return this.discussion.id;
+ }
+ return '';
+ },
+ actionText() {
+ if (!this.commit) {
+ return '';
+ }
+
+ // We need to do this to ensure we have the correct sentence order
+ // when translating this as the sentence order may change from one
+ // language to the next. See:
+ // https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/24427#note_133713771
+ const { id, url } = this.commit;
+ const commitLink = `<a class="commit-sha monospace" href="${escape(url)}">${truncateSha(
+ id,
+ )}</a>`;
+ return sprintf(s__('MergeRequests|commented on commit %{commitLink}'), { commitLink }, false);
+ },
},
created() {
@@ -123,12 +154,16 @@ export default {
this.$refs.noteBody.resetAutoSave();
this.$emit('updateSuccess');
},
- formUpdateHandler(noteText, parentElement, callback) {
+ formUpdateHandler(noteText, parentElement, callback, resolveDiscussion) {
this.$emit('handleUpdateNote', {
note: this.note,
noteText,
+ resolveDiscussion,
callback: () => this.updateSuccess(),
});
+
+ if (this.isDraft) return;
+
const data = {
endpoint: this.note.path,
note: {
@@ -186,7 +221,7 @@ export default {
:class="classNameBindings"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
- class="note note-wrapper"
+ class="note note-wrapper qa-noteable-note-item"
>
<div v-once class="timeline-icon">
<user-avatar-link
@@ -195,48 +230,54 @@ export default {
:img-alt="author.name"
:img-size="40"
>
- <slot slot="avatar-badge" name="avatar-badge"> </slot>
+ <slot slot="avatar-badge" name="avatar-badge"></slot>
</user-avatar-link>
</div>
<div class="timeline-content">
<div class="note-header">
- <note-header
- v-once
- :author="author"
- :created-at="note.created_at"
- :note-id="note.id"
- action-text="commented"
- />
+ <note-header v-once :author="author" :created-at="note.created_at" :note-id="note.id">
+ <slot slot="note-header-info" name="note-header-info"></slot>
+ <span v-if="commit" v-html="actionText"></span>
+ <span v-else class="d-none d-sm-inline">&middot;</span>
+ </note-header>
<note-actions
:author-id="author.id"
:note-id="note.id"
:note-url="note.noteable_note_url"
:access-level="note.human_access"
+ :show-reply="showReplyButton"
:can-edit="note.current_user.can_edit"
:can-award-emoji="note.current_user.can_award_emoji"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
- :can-resolve="note.current_user.can_resolve"
+ :can-resolve="canResolve"
:report-abuse-path="note.report_abuse_path"
- :resolvable="note.resolvable"
- :is-resolved="note.resolved"
+ :resolvable="note.resolvable || note.isDraft"
+ :is-resolved="note.resolved || note.resolve_discussion"
:is-resolving="isResolving"
:resolved-by="note.resolved_by"
+ :is-draft="note.isDraft"
+ :resolve-discussion="note.isDraft && note.resolve_discussion"
+ :discussion-id="discussionId"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
@handleResolve="resolveHandler"
+ @startReplying="$emit('startReplying')"
+ />
+ </div>
+ <div class="timeline-discussion-body">
+ <slot name="discussion-resolved-text"></slot>
+ <note-body
+ ref="noteBody"
+ :note="note"
+ :line="line"
+ :can-edit="note.current_user.can_edit"
+ :is-editing="isEditing"
+ :help-page-path="helpPagePath"
+ @handleFormUpdate="formUpdateHandler"
+ @cancelForm="formCancelHandler"
/>
</div>
- <note-body
- ref="noteBody"
- :note="note"
- :line="line"
- :can-edit="note.current_user.can_edit"
- :is-editing="isEditing"
- :help-page-path="helpPagePath"
- @handleFormUpdate="formUpdateHandler"
- @cancelForm="formCancelHandler"
- />
</div>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index f3fcfdfda05..0f1976db37d 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -6,6 +6,7 @@ import * as constants from '../constants';
import eventHub from '../event_hub';
import noteableNote from './noteable_note.vue';
import noteableDiscussion from './noteable_discussion.vue';
+import discussionFilterNote from './discussion_filter_note.vue';
import systemNote from '../../vue_shared/components/notes/system_note.vue';
import commentForm from './comment_form.vue';
import placeholderNote from '../../vue_shared/components/notes/placeholder_note.vue';
@@ -24,6 +25,7 @@ export default {
placeholderNote,
placeholderSystemNote,
skeletonLoadingContainer,
+ discussionFilterNote,
},
props: {
noteableData: {
@@ -44,11 +46,6 @@ export default {
required: false,
default: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
helpPagePath: {
type: String,
required: false,
@@ -65,9 +62,12 @@ export default {
...mapGetters([
'isNotesFetched',
'discussions',
+ 'convertedDisscussionIds',
'getNotesDataByProp',
'isLoading',
'commentsDisabled',
+ 'getNoteableData',
+ 'userCanReply',
]),
noteableType() {
return this.noteableData.noteableType;
@@ -83,6 +83,9 @@ export default {
return this.discussions;
},
+ canReply() {
+ return this.userCanReply && !this.commentsDisabled;
+ },
},
watch: {
shouldShow() {
@@ -90,8 +93,15 @@ export default {
this.fetchNotes();
}
},
+ allDiscussions() {
+ if (this.discussonsCount) {
+ this.discussonsCount.textContent = this.allDiscussions.length;
+ }
+ },
},
created() {
+ this.discussonsCount = document.querySelector('.js-discussions-count');
+
this.setNotesData(this.notesData);
this.setNoteableData(this.noteableData);
this.setUserData(this.userData);
@@ -133,6 +143,7 @@ export default {
'setNotesFetchedState',
'expandDiscussion',
'startTaskList',
+ 'convertToDiscussion',
]),
fetchNotes() {
if (this.isFetching) return null;
@@ -180,6 +191,11 @@ export default {
}
}
},
+ startReplying(discussionId) {
+ return this.convertToDiscussion(discussionId)
+ .then(() => this.$nextTick())
+ .then(() => eventHub.$emit('startReplying', discussionId));
+ },
},
systemNote: constants.SYSTEM_NOTE,
};
@@ -198,13 +214,21 @@ export default {
/>
<placeholder-note v-else :key="discussion.id" :note="discussion.notes[0]" />
</template>
- <template v-else-if="discussion.individual_note">
+ <template
+ v-else-if="discussion.individual_note && !convertedDisscussionIds.includes(discussion.id)"
+ >
<system-note
v-if="discussion.notes[0].system"
:key="discussion.id"
:note="discussion.notes[0]"
/>
- <noteable-note v-else :key="discussion.id" :note="discussion.notes[0]" />
+ <noteable-note
+ v-else
+ :key="discussion.id"
+ :note="discussion.notes[0]"
+ :show-reply-button="canReply"
+ @startReplying="startReplying(discussion.id)"
+ />
</template>
<noteable-discussion
v-else
@@ -214,12 +238,9 @@ export default {
:help-page-path="helpPagePath"
/>
</template>
+ <discussion-filter-note v-show="commentsDisabled" />
</ul>
- <comment-form
- v-if="!commentsDisabled"
- :noteable-type="noteableType"
- :markdown-version="markdownVersion"
- />
+ <comment-form v-if="!commentsDisabled" :noteable-type="noteableType" />
</div>
</template>
diff --git a/app/assets/javascripts/notes/components/toggle_replies_widget.vue b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
index 72a8ff28466..f1b0b12bdce 100644
--- a/app/assets/javascripts/notes/components/toggle_replies_widget.vue
+++ b/app/assets/javascripts/notes/components/toggle_replies_widget.vue
@@ -57,7 +57,7 @@ export default {
tooltip-placement="bottom"
/>
</div>
- <button class="btn btn-link js-replies-text" type="button" @click="toggle">
+ <button class="btn btn-link js-replies-text qa-expand-replies" type="button" @click="toggle">
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
</button>
{{ __('Last reply by') }}
@@ -66,7 +66,11 @@ export default {
</a>
<time-ago-tooltip :time="lastReply.created_at" tooltip-placement="bottom" />
</template>
- <span v-else class="collapse-replies-btn js-collapse-replies" @click="toggle">
+ <span
+ v-else
+ class="collapse-replies-btn js-collapse-replies qa-collapse-replies"
+ @click="toggle"
+ >
<icon name="chevron-down" /> {{ s__('Notes|Collapse replies') }}
</span>
</li>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
index 3147dc64c27..bdfb6b8f105 100644
--- a/app/assets/javascripts/notes/constants.js
+++ b/app/assets/javascripts/notes/constants.js
@@ -7,6 +7,7 @@ export const COMMENT = 'comment';
export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
+export const MERGED = 'merged';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue';
@@ -17,9 +18,16 @@ export const RESOLVE_NOTE_METHOD_NAME = 'post';
export const DESCRIPTION_TYPE = 'changed the description';
export const HISTORY_ONLY_FILTER_VALUE = 2;
export const DISCUSSION_FILTERS_DEFAULT_VALUE = 0;
+export const DISCUSSION_TAB_LABEL = 'show';
export const NOTEABLE_TYPE_MAPPING = {
Issue: ISSUE_NOTEABLE_TYPE,
MergeRequest: MERGE_REQUEST_NOTEABLE_TYPE,
Epic: EPIC_NOTEABLE_TYPE,
};
+
+export const DISCUSSION_FILTER_TYPES = {
+ ALL: 'all',
+ COMMENTS: 'comments',
+ HISTORY: 'history',
+};
diff --git a/app/assets/javascripts/notes/discussion_filters.js b/app/assets/javascripts/notes/discussion_filters.js
index 5c5f38a3fb0..cdf9a46c5aa 100644
--- a/app/assets/javascripts/notes/discussion_filters.js
+++ b/app/assets/javascripts/notes/discussion_filters.js
@@ -6,12 +6,16 @@ export default store => {
if (discussionFilterEl) {
const { defaultFilter, notesFilters } = discussionFilterEl.dataset;
- const selectedValue = defaultFilter ? parseInt(defaultFilter, 10) : null;
const filterValues = notesFilters ? JSON.parse(notesFilters) : {};
const filters = Object.keys(filterValues).map(entry => ({
title: entry,
value: filterValues[entry],
}));
+ const props = { filters };
+
+ if (defaultFilter) {
+ props.selectedValue = parseInt(defaultFilter, 10);
+ }
return new Vue({
el: discussionFilterEl,
@@ -21,12 +25,7 @@ export default store => {
},
store,
render(createElement) {
- return createElement('discussion-filter', {
- props: {
- filters,
- selectedValue,
- },
- });
+ return createElement('discussion-filter', { props });
},
});
}
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
index 2f715c85fa6..57dd1c5cab2 100644
--- a/app/assets/javascripts/notes/index.js
+++ b/app/assets/javascripts/notes/index.js
@@ -1,4 +1,6 @@
import Vue from 'vue';
+import { isEE } from '~/lib/utils/common_utils';
+import initNoteStats from 'ee_else_ce/event_tracking/notes';
import notesApp from './components/notes_app.vue';
import initDiscussionFilters from './discussion_filters';
import createStore from './stores';
@@ -6,9 +8,8 @@ import createStore from './stores';
document.addEventListener('DOMContentLoaded', () => {
const store = createStore();
- initDiscussionFilters(store);
-
- return new Vue({
+ // eslint-disable-next-line no-new
+ new Vue({
el: '#js-vue-notes',
components: {
notesApp,
@@ -18,7 +19,6 @@ document.addEventListener('DOMContentLoaded', () => {
const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
- const markdownVersion = parseInt(notesDataset.markdownVersion, 10);
let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
@@ -37,19 +37,24 @@ document.addEventListener('DOMContentLoaded', () => {
return {
noteableData,
currentUserData,
- markdownVersion,
notesData: JSON.parse(notesDataset.notesData),
};
},
+ mounted() {
+ if (isEE) {
+ initNoteStats();
+ }
+ },
render(createElement) {
return createElement('notes-app', {
props: {
noteableData: this.noteableData,
notesData: this.notesData,
userData: this.currentUserData,
- markdownVersion: this.markdownVersion,
},
});
},
});
+
+ initDiscussionFilters(store);
});
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
index 4f45f912479..b161773f5f1 100644
--- a/app/assets/javascripts/notes/mixins/autosave.js
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -1,12 +1,13 @@
import $ from 'jquery';
import Autosave from '../../autosave';
import { capitalizeFirstCharacter } from '../../lib/utils/text_utility';
+import { s__ } from '~/locale';
export default {
methods: {
initAutoSave(noteable, extraKeys = []) {
let keys = [
- 'Note',
+ s__('Autosave|Note'),
capitalizeFirstCharacter(noteable.noteable_type || noteable.noteableType),
noteable.id,
];
diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
new file mode 100644
index 00000000000..188556e8921
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js
@@ -0,0 +1,10 @@
+export default {
+ computed: {
+ draftForDiscussion: () => () => ({}),
+ },
+ methods: {
+ showDraft: () => false,
+ addReplyToReview: () => {},
+ addToReview: () => {},
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/draft.js b/app/assets/javascripts/notes/mixins/draft.js
new file mode 100644
index 00000000000..1370f3978df
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/draft.js
@@ -0,0 +1,8 @@
+export default {
+ computed: {
+ isDraft: () => false,
+ canResolve() {
+ return this.note.current_user.can_resolve;
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/get_discussion.js b/app/assets/javascripts/notes/mixins/get_discussion.js
new file mode 100644
index 00000000000..b5d820fe083
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/get_discussion.js
@@ -0,0 +1,7 @@
+export default {
+ computed: {
+ discussion() {
+ return {};
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/note_form.js b/app/assets/javascripts/notes/mixins/note_form.js
new file mode 100644
index 00000000000..b74879f2256
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/note_form.js
@@ -0,0 +1,24 @@
+export default {
+ data() {
+ return {
+ showBatchCommentsActions: false,
+ };
+ },
+ methods: {
+ handleKeySubmit() {
+ this.handleUpdate();
+ },
+ handleUpdate(shouldResolve) {
+ const beforeSubmitDiscussionState = this.discussionResolved;
+ this.isSubmitting = true;
+
+ this.$emit('handleFormUpdate', this.updatedNoteBody, this.$refs.editNoteForm, () => {
+ this.isSubmitting = false;
+
+ if (this.shouldToggleResolved(shouldResolve, beforeSubmitDiscussionState)) {
+ this.resolveHandler(beforeSubmitDiscussionState);
+ }
+ });
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js
index 8edf3d088bb..2329727bca2 100644
--- a/app/assets/javascripts/notes/mixins/resolvable.js
+++ b/app/assets/javascripts/notes/mixins/resolvable.js
@@ -31,6 +31,10 @@ export default {
},
methods: {
resolveHandler(resolvedState = false) {
+ if (this.note && this.note.isDraft) {
+ return this.$emit('toggleResolveStatus');
+ }
+
this.isResolving = true;
const isResolved = this.discussionResolved || resolvedState;
const discussion = this.resolveAsThread;
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 65f85314fa0..63658d49a05 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -83,12 +83,44 @@ export const updateNote = ({ commit, dispatch }, { endpoint, note }) =>
dispatch('startTaskList');
});
-export const replyToDiscussion = ({ commit }, { endpoint, data }) =>
+export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) => {
+ const { notesById } = getters;
+
+ notes.forEach(note => {
+ if (notesById[note.id]) {
+ commit(types.UPDATE_NOTE, note);
+ } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
+ const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id);
+
+ if (discussion) {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
+ } else if (note.type === constants.DIFF_NOTE) {
+ dispatch('fetchDiscussions', { path: state.notesData.discussionsPath });
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ } else {
+ commit(types.ADD_NEW_NOTE, note);
+ }
+ });
+};
+
+export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoint, data }) =>
service
.replyToDiscussion(endpoint, data)
.then(res => res.json())
.then(res => {
- commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+ if (res.discussion) {
+ commit(types.UPDATE_DISCUSSION, res.discussion);
+
+ updateOrCreateNotes({ commit, state, getters, dispatch }, res.discussion.notes);
+
+ dispatch('updateMergeRequestWidget');
+ dispatch('startTaskList');
+ dispatch('updateResolvableDiscussonsCounts');
+ } else {
+ commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
+ }
return res;
});
@@ -110,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES);
+export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }) => {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ const isResolved = getters.isDiscussionResolved(discussionId);
+
+ if (!discussion) {
+ return Promise.reject();
+ } else if (isResolved) {
+ return Promise.resolve();
+ }
+
+ return dispatch('toggleResolveNote', {
+ endpoint: discussion.resolve_path,
+ isResolved,
+ discussion: true,
+ });
+};
+
export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) =>
service
.toggleResolveNote(endpoint, isResolved)
@@ -219,11 +268,20 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
- if (hasQuickActions && errors && Object.keys(errors).length) {
- eTagPoll.makeRequest();
+ if (errors && Object.keys(errors).length) {
+ /*
+ The following reply means that quick actions have been successfully applied:
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash('Commands applied', 'notice', noteData.flashContainer);
+ {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
+ */
+ if (hasQuickActions) {
+ eTagPoll.makeRequest();
+
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash(__('Commands applied'), 'notice', noteData.flashContainer);
+ } else {
+ throw new Error(__('Failed to save comment!'));
+ }
}
if (commandsChanges) {
@@ -237,7 +295,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
})
.catch(() => {
Flash(
- 'Something went wrong while adding your award. Please try again.',
+ __('Something went wrong while adding your award. Please try again.'),
'alert',
noteData.flashContainer,
);
@@ -262,25 +320,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const pollSuccessCallBack = (resp, commit, state, getters, dispatch) => {
if (resp.notes && resp.notes.length) {
- const { notesById } = getters;
-
- resp.notes.forEach(note => {
- if (notesById[note.id]) {
- commit(types.UPDATE_NOTE, note);
- } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) {
- const discussion = utils.findNoteObjectById(state.discussions, note.discussion_id);
-
- if (discussion) {
- commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
- } else if (note.type === constants.DIFF_NOTE) {
- dispatch('fetchDiscussions', { path: state.notesData.discussionsPath });
- } else {
- commit(types.ADD_NEW_NOTE, note);
- }
- } else {
- commit(types.ADD_NEW_NOTE, note);
- }
- });
+ updateOrCreateNotes({ commit, state, getters, dispatch }, resp.notes);
dispatch('startTaskList');
}
@@ -297,7 +337,7 @@ export const poll = ({ commit, state, getters, dispatch }) => {
data: state,
successCallback: resp =>
resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)),
- errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
+ errorCallback: () => Flash(__('Something went wrong while fetching latest comments.')),
});
if (!Visibility.hidden()) {
@@ -333,7 +373,7 @@ export const fetchData = ({ commit, state, getters }) => {
.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
- .catch(() => Flash('Something went wrong while fetching latest comments.'));
+ .catch(() => Flash(__('Something went wrong while fetching latest comments.')));
};
export const toggleAward = ({ commit, getters }, { awardName, noteId }) => {
@@ -406,24 +446,27 @@ export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
- { commit },
- { discussionId, noteId, suggestionId, flashContainer, callback },
-) => {
+ { commit, dispatch },
+ { discussionId, noteId, suggestionId, flashContainer },
+) =>
service
.applySuggestion(suggestionId)
- .then(() => {
- commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
- callback();
- })
- .catch(() => {
- Flash(
- __('Something went wrong while applying the suggestion. Please try again.'),
- 'alert',
- flashContainer,
+ .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
+ .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
+ .catch(err => {
+ const defaultMessage = __(
+ 'Something went wrong while applying the suggestion. Please try again.',
);
- callback();
+ const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
+
+ Flash(__(flashMessage), 'alert', flashContainer);
});
-};
+
+export const convertToDiscussion = ({ commit }, noteId) =>
+ commit(types.CONVERT_TO_DISCUSSION, noteId);
+
+export const removeConvertedDiscussion = ({ commit }, noteId) =>
+ commit(types.REMOVE_CONVERTED_DISCUSSION, noteId);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
index 0ffc0cb2593..2d150e64ef7 100644
--- a/app/assets/javascripts/notes/stores/getters.js
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -4,6 +4,8 @@ import { collapseSystemNotes } from './collapse_utils';
export const discussions = state => collapseSystemNotes(state.discussions);
+export const convertedDisscussionIds = state => state.convertedDisscussionIds;
+
export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
@@ -18,6 +20,8 @@ export const getNoteableData = state => state.noteableData;
export const getNoteableDataByProp = state => prop => state.noteableData[prop];
+export const userCanReply = state => !!state.noteableData.current_user.can_create_note;
+
export const openState = state => state.noteableData.state;
export const getUserData = state => state.userData || {};
@@ -189,6 +193,9 @@ export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => {
return getters.unresolvedDiscussionsIdsByDate[0];
};
+export const getDiscussion = state => discussionId =>
+ state.discussions.find(discussion => discussion.id === discussionId);
+
export const commentsDisabled = state => state.commentsDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 887e6d22b06..6168aeae35d 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -5,6 +5,7 @@ import mutations from '../mutations';
export default () => ({
state: {
discussions: [],
+ convertedDisscussionIds: [],
targetNoteHash: null,
lastFetchedAt: null,
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
index df943c155f4..796370920bb 100644
--- a/app/assets/javascripts/notes/stores/mutation_types.js
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -17,6 +17,8 @@ export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE';
export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE';
export const DISABLE_COMMENTS = 'DISABLE_COMMENTS';
export const APPLY_SUGGESTION = 'APPLY_SUGGESTION';
+export const CONVERT_TO_DISCUSSION = 'CONVERT_TO_DISCUSSION';
+export const REMOVE_CONVERTED_DISCUSSION = 'REMOVE_CONVERTED_DISCUSSION';
// DISCUSSION
export const COLLAPSE_DISCUSSION = 'COLLAPSE_DISCUSSION';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
index 8992454be2e..fa44ef2d057 100644
--- a/app/assets/javascripts/notes/stores/mutations.js
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -105,7 +105,10 @@ export default {
if (discussion.diff_file) {
diffData.file_hash = discussion.diff_file.file_hash;
- diffData.truncated_diff_lines = discussion.truncated_diff_lines || [];
+
+ diffData.truncated_diff_lines = utils.prepareDiffLines(
+ discussion.truncated_diff_lines || [],
+ );
}
// To support legacy notes, should be very rare case.
@@ -190,6 +193,10 @@ export default {
const noteObj = utils.findNoteObjectById(state.discussions, note.discussion_id);
if (noteObj.individual_note) {
+ if (note.type === constants.DISCUSSION_NOTE) {
+ noteObj.individual_note = false;
+ }
+
noteObj.notes.splice(0, 1, note);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
@@ -243,7 +250,7 @@ export default {
[types.SET_DISCUSSION_DIFF_LINES](state, { discussionId, diffLines }) {
const discussion = utils.findNoteObjectById(state.discussions, discussionId);
- discussion.truncated_diff_lines = diffLines;
+ discussion.truncated_diff_lines = utils.prepareDiffLines(diffLines);
},
[types.DISABLE_COMMENTS](state, value) {
@@ -261,4 +268,16 @@ export default {
).length;
state.hasUnresolvedDiscussions = state.unresolvedDiscussionsCount > 1;
},
+
+ [types.CONVERT_TO_DISCUSSION](state, discussionId) {
+ const convertedDisscussionIds = [...state.convertedDisscussionIds, discussionId];
+ Object.assign(state, { convertedDisscussionIds });
+ },
+
+ [types.REMOVE_CONVERTED_DISCUSSION](state, discussionId) {
+ const convertedDisscussionIds = [...state.convertedDisscussionIds];
+
+ convertedDisscussionIds.splice(convertedDisscussionIds.indexOf(discussionId), 1);
+ Object.assign(state, { convertedDisscussionIds });
+ },
};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index dd57539e4d8..ed4cef4a917 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -1,11 +1,14 @@
import AjaxCache from '~/lib/utils/ajax_cache';
+import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
+import { sprintf, __ } from '~/locale';
-const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+// factory function because global flag makes RegExp stateful
+const createQuickActionsRegex = () => /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
export const getQuickActionText = note => {
- let text = 'Applying command';
+ let text = __('Applying command');
const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
const executedCommands = quickActions.filter(command => {
@@ -15,16 +18,19 @@ export const getQuickActionText = note => {
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
- text = 'Applying multiple commands';
+ text = __('Applying multiple commands');
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
- text = `Applying command to ${commandDescription}`;
+ text = sprintf(__('Applying command to %{commandDescription}', { commandDescription }));
}
}
return text;
};
-export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+export const hasQuickActions = note => createQuickActionsRegex().test(note);
-export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
+export const stripQuickActions = note => note.replace(createQuickActionsRegex(), '').trim();
+
+export const prepareDiffLines = diffLines =>
+ diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) }));
diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js
index e7fa05faa8a..08545dcea46 100644
--- a/app/assets/javascripts/notifications_dropdown.js
+++ b/app/assets/javascripts/notifications_dropdown.js
@@ -1,9 +1,11 @@
import $ from 'jquery';
import Flash from './flash';
+import { __ } from '~/locale';
export default function notificationsDropdown() {
$(document).on('click', '.update-notification', function updateNotificationCallback(e) {
e.preventDefault();
+
if ($(this).is('.is-active') && $(this).data('notificationLevel') === 'custom') {
return;
}
@@ -26,7 +28,7 @@ export default function notificationsDropdown() {
.closest('.js-notification-dropdown')
.replaceWith(data.html);
} else {
- Flash('Failed to save new settings', 'alert');
+ Flash(__('Failed to save new settings'), 'alert');
}
});
}
diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
new file mode 100644
index 00000000000..0a87d193b72
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ },
+ props: {
+ externalDashboardPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ externalDashboardHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="settings expanded">
+ <div class="settings-header">
+ <h4 class="js-section-header">
+ {{ s__('ExternalMetrics|External Dashboard') }}
+ </h4>
+ <p class="js-section-sub-header">
+ {{
+ s__(
+ 'ExternalMetrics|Add a button to the metrics dashboard linking directly to your existing external dashboards.',
+ )
+ }}
+ <gl-link :href="externalDashboardHelpPagePath">{{ __('Learn more') }}</gl-link>
+ </p>
+ </div>
+ <div class="settings-content">
+ <form>
+ <gl-form-group
+ :label="s__('ExternalMetrics|Full dashboard URL')"
+ :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')"
+ >
+ <gl-form-input
+ :value="externalDashboardPath"
+ placeholder="https://my-org.gitlab.io/my-dashboards"
+ />
+ </gl-form-group>
+ <gl-button variant="success">
+ {{ __('Save Changes') }}
+ </gl-button>
+ </form>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
new file mode 100644
index 00000000000..1171f3ece9f
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import ExternalDashboardForm from './components/external_dashboard.vue';
+
+export default () => {
+ /**
+ * This check can be removed when we remove
+ * the :grafana_dashboard_link feature flag
+ */
+ if (!gon.features.grafanaDashboardLink) {
+ return null;
+ }
+
+ const el = document.querySelector('.js-operation-settings');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(ExternalDashboardForm, {
+ props: {
+ ...el.dataset,
+ expanded: false,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/admin/application_settings/show/index.js b/app/assets/javascripts/pages/admin/application_settings/show/index.js
new file mode 100644
index 00000000000..5ec9688a6e4
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/application_settings/show/index.js
@@ -0,0 +1,3 @@
+import initUserInternalRegexPlaceholder from '../../application_settings/account_and_limits';
+
+document.addEventListener('DOMContentLoaded', initUserInternalRegexPlaceholder());
diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
index d5ded3f9a79..6e00e31b828 100644
--- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
+++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js
@@ -22,7 +22,7 @@ export default () => {
_.debounce(function onMessageInput() {
const message = $(this).val();
if (message === '') {
- $('.js-broadcast-message-preview').text('Your message here');
+ $('.js-broadcast-message-preview').text(__('Your message here'));
} else {
axios
.post(previewPath, {
diff --git a/app/assets/javascripts/pages/groups/clusters/update/index.js b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
index 8001d2dd1da..8001d2dd1da 100644
--- a/app/assets/javascripts/pages/groups/clusters/update/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/destroy/index.js
diff --git a/app/assets/javascripts/pages/projects/clusters/update/index.js b/app/assets/javascripts/pages/admin/clusters/edit/index.js
index 8001d2dd1da..8001d2dd1da 100644
--- a/app/assets/javascripts/pages/projects/clusters/update/index.js
+++ b/app/assets/javascripts/pages/admin/clusters/edit/index.js
diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js
new file mode 100644
index 00000000000..d0c9ae66c6a
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/index.js
@@ -0,0 +1,21 @@
+import PersistentUserCallout from '~/persistent_user_callout';
+import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+
+function initGcpSignupCallout() {
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+}
+
+document.addEventListener('DOMContentLoaded', () => {
+ const { page } = document.body.dataset;
+ const newClusterViews = [
+ 'admin:clusters:new',
+ 'admin:clusters:create_gcp',
+ 'admin:clusters:create_user',
+ ];
+
+ if (newClusterViews.indexOf(page) > -1) {
+ initGcpSignupCallout();
+ initGkeDropdowns();
+ }
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/index/index.js b/app/assets/javascripts/pages/admin/clusters/index/index.js
new file mode 100644
index 00000000000..30d519d0e37
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/index/index.js
@@ -0,0 +1,6 @@
+import PersistentUserCallout from '~/persistent_user_callout';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+});
diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/clusters/show/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js
index d3d125a1859..ad7276132b9 100644
--- a/app/assets/javascripts/pages/admin/groups/edit/index.js
+++ b/app/assets/javascripts/pages/admin/groups/edit/index.js
@@ -1,3 +1,3 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
-document.addEventListener('DOMContentLoaded', groupAvatar);
+document.addEventListener('DOMContentLoaded', initAvatarPicker);
diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js
index 21f1ce222ac..6de740ee9ce 100644
--- a/app/assets/javascripts/pages/admin/groups/new/index.js
+++ b/app/assets/javascripts/pages/admin/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '../../../../behaviors/bind_in_out';
import Group from '../../../../group';
-import groupAvatar from '../../../../group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/admin/index.js b/app/assets/javascripts/pages/admin/index.js
index 3aa793e47b9..8a32556f06c 100644
--- a/app/assets/javascripts/pages/admin/index.js
+++ b/app/assets/javascripts/pages/admin/index.js
@@ -1,7 +1,3 @@
import initAdmin from './admin';
-import initUserInternalRegexPlaceholder from './application_settings/account_and_limits';
-document.addEventListener('DOMContentLoaded', () => {
- initAdmin();
- initUserInternalRegexPlaceholder();
-});
+document.addEventListener('DOMContentLoaded', initAdmin());
diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
index 260484726f3..ff758fcb4fe 100644
--- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js
+++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js
@@ -1,10 +1,11 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index.js
index 8f98be79640..01001d4f3ff 100644
--- a/app/assets/javascripts/pages/dashboard/projects/index.js
+++ b/app/assets/javascripts/pages/dashboard/projects/index.js
@@ -1,7 +1,5 @@
import ProjectsList from '~/projects_list';
-import Star from '../../../star';
document.addEventListener('DOMContentLoaded', () => {
new ProjectsList(); // eslint-disable-line no-new
- new Star('.project-row'); // eslint-disable-line no-new
});
diff --git a/app/assets/javascripts/pages/explore/projects/index.js b/app/assets/javascripts/pages/explore/projects/index.js
index 0c585e162cb..01001d4f3ff 100644
--- a/app/assets/javascripts/pages/explore/projects/index.js
+++ b/app/assets/javascripts/pages/explore/projects/index.js
@@ -1,3 +1,5 @@
import ProjectsList from '~/projects_list';
-document.addEventListener('DOMContentLoaded', () => new ProjectsList());
+document.addEventListener('DOMContentLoaded', () => {
+ new ProjectsList(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/groups/clusters/edit/index.js b/app/assets/javascripts/pages/groups/clusters/edit/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/clusters/edit/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/groups/clusters/index/index.js b/app/assets/javascripts/pages/groups/clusters/index/index.js
index 845a5f7042c..30d519d0e37 100644
--- a/app/assets/javascripts/pages/groups/clusters/index/index.js
+++ b/app/assets/javascripts/pages/groups/clusters/index/index.js
@@ -1,5 +1,6 @@
-import initDismissableCallout from '~/dismissable_callout';
+import PersistentUserCallout from '~/persistent_user_callout';
document.addEventListener('DOMContentLoaded', () => {
- initDismissableCallout('.gcp-signup-offer');
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
});
diff --git a/app/assets/javascripts/pages/groups/details/index.js b/app/assets/javascripts/pages/groups/details/index.js
new file mode 100644
index 00000000000..3bcaa0f0232
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/details/index.js
@@ -0,0 +1,5 @@
+import initGroupDetails from '../shared/group_details';
+
+document.addEventListener('DOMContentLoaded', () => {
+ initGroupDetails('details');
+});
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 01ef445c901..d036ff07d89 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -1,4 +1,4 @@
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
import TransferDropdown from '~/groups/transfer_dropdown';
import initConfirmDangerModal from '~/confirm_danger_modal';
import initSettingsPanels from '~/settings_panels';
@@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select';
import projectSelect from '~/project_select';
document.addEventListener('DOMContentLoaded', () => {
- groupAvatar();
+ initAvatarPicker();
new TransferDropdown(); // eslint-disable-line no-new
initConfirmDangerModal();
initSettingsPanels();
diff --git a/app/assets/javascripts/pages/groups/group_members/index/index.js b/app/assets/javascripts/pages/groups/group_members/index/index.js
index c22a164cd4e..e4f4c3b574e 100644
--- a/app/assets/javascripts/pages/groups/group_members/index/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index/index.js
@@ -1,7 +1,7 @@
/* eslint-disable no-new */
import memberExpirationDate from '~/member_expiration_date';
-import Members from '~/members';
+import Members from 'ee_else_ce/members';
import UsersSelect from '~/users_select';
document.addEventListener('DOMContentLoaded', () => {
diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js
index bf80d8b8193..451be6497de 100644
--- a/app/assets/javascripts/pages/groups/index.js
+++ b/app/assets/javascripts/pages/groups/index.js
@@ -1,6 +1,11 @@
-import initDismissableCallout from '~/dismissable_callout';
+import PersistentUserCallout from '~/persistent_user_callout';
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+function initGcpSignupCallout() {
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+}
+
document.addEventListener('DOMContentLoaded', () => {
const { page } = document.body.dataset;
const newClusterViews = [
@@ -10,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => {
];
if (newClusterViews.indexOf(page) > -1) {
- initDismissableCallout('.gcp-signup-offer');
+ initGcpSignupCallout();
initGkeDropdowns();
}
});
diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js
index 736c6a62610..35d4b034654 100644
--- a/app/assets/javascripts/pages/groups/issues/index.js
+++ b/app/assets/javascripts/pages/groups/issues/index.js
@@ -1,9 +1,11 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
+ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/edit/index.js
+++ b/app/assets/javascripts/pages/groups/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/groups/labels/new/index.js
+++ b/app/assets/javascripts/pages/groups/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js
index 339ce67438a..12a26fd88fa 100644
--- a/app/assets/javascripts/pages/groups/merge_requests/index.js
+++ b/app/assets/javascripts/pages/groups/merge_requests/index.js
@@ -1,10 +1,11 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js
index b2f275dc5ea..57b53eb9e5d 100644
--- a/app/assets/javascripts/pages/groups/new/index.js
+++ b/app/assets/javascripts/pages/groups/new/index.js
@@ -1,9 +1,9 @@
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
-import groupAvatar from '~/group_avatar';
+import initAvatarPicker from '~/avatar_picker';
document.addEventListener('DOMContentLoaded', () => {
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
- groupAvatar();
+ initAvatarPicker();
});
diff --git a/app/assets/javascripts/pages/groups/shared/group_details.js b/app/assets/javascripts/pages/groups/shared/group_details.js
new file mode 100644
index 00000000000..01ef3f1db2b
--- /dev/null
+++ b/app/assets/javascripts/pages/groups/shared/group_details.js
@@ -0,0 +1,31 @@
+/* eslint-disable no-new */
+
+import { getPagePath } from '~/lib/utils/common_utils';
+import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
+import NewGroupChild from '~/groups/new_group_child';
+import notificationsDropdown from '~/notifications_dropdown';
+import NotificationsForm from '~/notifications_form';
+import ProjectsList from '~/projects_list';
+import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
+import GroupTabs from './group_tabs';
+
+export default function initGroupDetails(actionName = 'show') {
+ const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
+ const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
+ const paths = window.location.pathname.split('/');
+ const subpath = paths[paths.length - 1];
+ let action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
+ if (actionName && action === actionName) {
+ action = 'show'; // 'show' resets GroupTabs to default action through base class
+ }
+
+ new GroupTabs({ parentEl: '.groups-listing', action });
+ new ShortcutsNavigation();
+ new NotificationsForm();
+ notificationsDropdown();
+ new ProjectsList();
+
+ if (newGroupChildWrapper) {
+ new NewGroupChild(newGroupChildWrapper);
+ }
+}
diff --git a/app/assets/javascripts/pages/groups/show/group_tabs.js b/app/assets/javascripts/pages/groups/shared/group_tabs.js
index c6fe61d2bd9..c6fe61d2bd9 100644
--- a/app/assets/javascripts/pages/groups/show/group_tabs.js
+++ b/app/assets/javascripts/pages/groups/shared/group_tabs.js
diff --git a/app/assets/javascripts/pages/groups/show/index.js b/app/assets/javascripts/pages/groups/show/index.js
index 3a45fd70d02..82ee5ead83d 100644
--- a/app/assets/javascripts/pages/groups/show/index.js
+++ b/app/assets/javascripts/pages/groups/show/index.js
@@ -1,28 +1,7 @@
-/* eslint-disable no-new */
-
-import { getPagePath } from '~/lib/utils/common_utils';
-import { ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED } from '~/groups/constants';
-import NewGroupChild from '~/groups/new_group_child';
-import notificationsDropdown from '~/notifications_dropdown';
-import NotificationsForm from '~/notifications_form';
-import ProjectsList from '~/projects_list';
-import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
-import GroupTabs from './group_tabs';
+import leaveByUrl from '~/namespaces/leave_by_url';
+import initGroupDetails from '../shared/group_details';
document.addEventListener('DOMContentLoaded', () => {
- const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
- const loadableActions = [ACTIVE_TAB_SHARED, ACTIVE_TAB_ARCHIVED];
- const paths = window.location.pathname.split('/');
- const subpath = paths[paths.length - 1];
- const action = loadableActions.includes(subpath) ? subpath : getPagePath(1);
-
- new GroupTabs({ parentEl: '.groups-listing', action });
- new ShortcutsNavigation();
- new NotificationsForm();
- notificationsDropdown();
- new ProjectsList();
-
- if (newGroupChildWrapper) {
- new NewGroupChild(newGroupChildWrapper);
- }
+ leaveByUrl('group');
+ initGroupDetails();
});
diff --git a/app/assets/javascripts/pages/import/gitea/status/index.js b/app/assets/javascripts/pages/import/gitea/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/gitea/status/index.js
@@ -0,0 +1,7 @@
+import mountImportProjectsTable from '~/import_projects';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+
+ mountImportProjectsTable(mountElement);
+});
diff --git a/app/assets/javascripts/pages/import/github/status/index.js b/app/assets/javascripts/pages/import/github/status/index.js
new file mode 100644
index 00000000000..dcd84f0faf9
--- /dev/null
+++ b/app/assets/javascripts/pages/import/github/status/index.js
@@ -0,0 +1,7 @@
+import mountImportProjectsTable from '~/import_projects';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mountElement = document.getElementById('import-projects-mount-element');
+
+ mountImportProjectsTable(mountElement);
+});
diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
index a79ef07f1c5..c563514d36b 100644
--- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
+++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue
@@ -33,8 +33,7 @@ export default {
text() {
return sprintf(
s__(`Milestones|Promoting %{milestoneTitle} will make it available for all projects inside %{groupName}.
- Existing project milestones with the same title will be merged.
- This action cannot be reversed.`),
+ Existing project milestones with the same title will be merged.`),
{ milestoneTitle: this.milestoneTitle, groupName: this.groupName },
);
},
@@ -72,6 +71,9 @@ export default {
<template slot="title">
{{ title }}
</template>
- {{ text }}
+ <div>
+ <p>{{ text }}</p>
+ <p>{{ s__('Milestones|This action cannot be reversed.') }}</p>
+ </div>
</gl-modal>
</template>
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 1cd3ee1dfdb..d3dcd21f456 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -2,6 +2,8 @@ import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
const input = document.querySelector('.js-add-ssh-key-validation-input');
+ if (!input) return;
+
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js
index c7ce4675573..13cb0d6f74b 100644
--- a/app/assets/javascripts/pages/profiles/show/index.js
+++ b/app/assets/javascripts/pages/profiles/show/index.js
@@ -1,7 +1,9 @@
import $ from 'jquery';
import createFlash from '~/flash';
-import GfmAutoComplete from '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
+import emojiRegex from 'emoji-regex';
import EmojiMenu from './emoji_menu';
+import { __ } from '~/locale';
const defaultStatusEmoji = 'speech_balloon';
@@ -42,6 +44,17 @@ document.addEventListener('DOMContentLoaded', () => {
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(statusMessageField), { emojis: true });
+ const userNameInput = document.getElementById('user_name');
+ userNameInput.addEventListener('input', () => {
+ const EMOJI_REGEX = emojiRegex();
+ if (EMOJI_REGEX.test(userNameInput.value)) {
+ // set field to invalid so it gets detected by GlFieldErrors
+ userNameInput.setCustomValidity(__('Invalid field'));
+ } else {
+ userNameInput.setCustomValidity('');
+ }
+ });
+
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
const emojiMenu = new EmojiMenu(
@@ -69,5 +82,5 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
})
- .catch(() => createFlash('Failed to load emoji list.'));
+ .catch(() => createFlash(__('Failed to load emoji list.')));
});
diff --git a/app/assets/javascripts/pages/projects/clusters/edit/index.js b/app/assets/javascripts/pages/projects/clusters/edit/index.js
new file mode 100644
index 00000000000..8001d2dd1da
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/clusters/edit/index.js
@@ -0,0 +1,5 @@
+import ClustersBundle from '~/clusters/clusters_bundle';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ClustersBundle(); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/clusters/index/index.js b/app/assets/javascripts/pages/projects/clusters/index/index.js
index 845a5f7042c..30d519d0e37 100644
--- a/app/assets/javascripts/pages/projects/clusters/index/index.js
+++ b/app/assets/javascripts/pages/projects/clusters/index/index.js
@@ -1,5 +1,6 @@
-import initDismissableCallout from '~/dismissable_callout';
+import PersistentUserCallout from '~/persistent_user_callout';
document.addEventListener('DOMContentLoaded', () => {
- initDismissableCallout('.gcp-signup-offer');
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
});
diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js
index 899d5925956..92ed6a652d7 100644
--- a/app/assets/javascripts/pages/projects/edit/index.js
+++ b/app/assets/javascripts/pages/projects/edit/index.js
@@ -3,17 +3,24 @@ import initSettingsPanels from '~/settings_panels';
import setupProjectEdit from '~/project_edit';
import initConfirmDangerModal from '~/confirm_danger_modal';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
-import fileUpload from '~/lib/utils/file_upload';
+import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
+import initAvatarPicker from '~/avatar_picker';
import initProjectLoadingSpinner from '../shared/save_project_loader';
import initProjectPermissionsSettings from '../shared/permissions';
document.addEventListener('DOMContentLoaded', () => {
- initProjectLoadingSpinner();
- setupProjectEdit();
- // Initialize expandable settings panels
- initSettingsPanels();
- fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input');
- initProjectPermissionsSettings();
+ initAvatarPicker();
initConfirmDangerModal();
+ initSettingsPanels();
mountBadgeSettings(PROJECT_BADGE);
+
+ initProjectLoadingSpinner();
+ initProjectPermissionsSettings();
+ setupProjectEdit();
+
+ dirtySubmitFactory(
+ document.querySelectorAll(
+ '.js-general-settings-form, .js-mr-settings-form, .js-mr-approvals-form',
+ ),
+ );
});
diff --git a/app/assets/javascripts/pages/projects/environments/metrics/index.js b/app/assets/javascripts/pages/projects/environments/metrics/index.js
index 0b644780ad4..0d69a689316 100644
--- a/app/assets/javascripts/pages/projects/environments/metrics/index.js
+++ b/app/assets/javascripts/pages/projects/environments/metrics/index.js
@@ -1,3 +1,3 @@
-import monitoringBundle from '~/monitoring/monitoring_bundle';
+import monitoringBundle from 'ee_else_ce/monitoring/monitoring_bundle';
document.addEventListener('DOMContentLoaded', monitoringBundle);
diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js
new file mode 100644
index 00000000000..5a8fe137e9a
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/error_tracking/index.js
@@ -0,0 +1,5 @@
+import ErrorTracking from '~/error_tracking';
+
+document.addEventListener('DOMContentLoaded', () => {
+ ErrorTracking();
+});
diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js
index 3ccad513c05..314519ee442 100644
--- a/app/assets/javascripts/pages/projects/graphs/charts/index.js
+++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js
@@ -2,65 +2,86 @@ import $ from 'jquery';
import Chart from 'chart.js';
import _ from 'underscore';
+import { barChartOptions, pieChartOptions } from '~/lib/utils/chart_utils';
+
document.addEventListener('DOMContentLoaded', () => {
const projectChartData = JSON.parse(document.getElementById('projectChartData').innerHTML);
- const responsiveChart = (selector, data) => {
- const options = {
- scaleOverlay: true,
- responsive: true,
- pointHitDetectionRadius: 2,
- maintainAspectRatio: false,
- };
+ const barChart = (selector, data) => {
// get selector by context
const ctx = selector.get(0).getContext('2d');
// pointing parent container to make chart.js inherit its width
const container = $(selector).parent();
- const generateChart = () => {
- selector.attr('width', $(container).width());
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8;
- }
- return new Chart(ctx).Bar(data, options);
- };
- // enabling auto-resizing
- $(window).resize(generateChart);
- return generateChart();
+ selector.attr('width', $(container).width());
+
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ const shouldAdjustFontSize = window.innerWidth < 768;
+ return new Chart(ctx, {
+ type: 'bar',
+ data,
+ options: barChartOptions(shouldAdjustFontSize),
+ });
+ };
+
+ const pieChart = (context, data) => {
+ const options = pieChartOptions();
+
+ return new Chart(context, {
+ type: 'pie',
+ data,
+ options,
+ });
};
const chartData = data => ({
labels: Object.keys(data),
datasets: [
{
- fillColor: 'rgba(220,220,220,0.5)',
- strokeColor: 'rgba(220,220,220,1)',
- barStrokeWidth: 1,
- barValueSpacing: 1,
- barDatasetSpacing: 1,
+ backgroundColor: 'rgba(220,220,220,0.5)',
+ borderColor: 'rgba(220,220,220,1)',
+ borderWidth: 1,
data: _.values(data),
},
],
});
+ const reorderWeekDays = (weekDays, firstDayOfWeek = 0) => {
+ if (firstDayOfWeek === 0) {
+ return weekDays;
+ }
+
+ return Object.keys(weekDays).reduce((acc, dayName, idx, arr) => {
+ const reorderedDayName = arr[(idx + firstDayOfWeek) % arr.length];
+
+ return {
+ ...acc,
+ [reorderedDayName]: weekDays[reorderedDayName],
+ };
+ }, {});
+ };
+
const hourData = chartData(projectChartData.hour);
- responsiveChart($('#hour-chart'), hourData);
+ barChart($('#hour-chart'), hourData);
- const dayData = chartData(projectChartData.weekDays);
- responsiveChart($('#weekday-chart'), dayData);
+ const weekDays = reorderWeekDays(projectChartData.weekDays, gon.first_day_of_week);
+ const dayData = chartData(weekDays);
+ barChart($('#weekday-chart'), dayData);
const monthData = chartData(projectChartData.month);
- responsiveChart($('#month-chart'), monthData);
+ barChart($('#month-chart'), monthData);
- const data = projectChartData.languages;
+ const data = {
+ datasets: [
+ {
+ data: projectChartData.languages.map(x => x.value),
+ backgroundColor: projectChartData.languages.map(x => x.color),
+ hoverBackgroundColor: projectChartData.languages.map(x => x.highlight),
+ },
+ ],
+ labels: projectChartData.languages.map(x => x.label),
+ };
const ctx = $('#languages-chart')
.get(0)
.getContext('2d');
- const options = {
- scaleOverlay: true,
- responsive: true,
- maintainAspectRatio: false,
- };
-
- new Chart(ctx).Pie(data, options);
+ pieChart(ctx, data);
});
diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js
index 5659e13981a..d4bd02c14e9 100644
--- a/app/assets/javascripts/pages/projects/index.js
+++ b/app/assets/javascripts/pages/projects/index.js
@@ -1,5 +1,5 @@
-import initDismissableCallout from '~/dismissable_callout';
import initGkeDropdowns from '~/projects/gke_cluster_dropdowns';
+import PersistentUserCallout from '../../persistent_user_callout';
import Project from './project';
import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation';
@@ -12,7 +12,9 @@ document.addEventListener('DOMContentLoaded', () => {
];
if (newClusterViews.indexOf(page) > -1) {
- initDismissableCallout('.gcp-signup-offer');
+ const callout = document.querySelector('.gcp-signup-offer');
+ PersistentUserCallout.factory(callout);
+
initGkeDropdowns();
}
diff --git a/app/assets/javascripts/pages/projects/issues/edit/index.js b/app/assets/javascripts/pages/projects/issues/edit/index.js
index ffc84dc106b..aecc6484b26 100644
--- a/app/assets/javascripts/pages/projects/issues/edit/index.js
+++ b/app/assets/javascripts/pages/projects/issues/edit/index.js
@@ -1,3 +1,3 @@
-import initForm from '../form';
+import initForm from 'ee_else_ce/pages/projects/issues/form';
document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js
index f99023ad8e7..941c4552579 100644
--- a/app/assets/javascripts/pages/projects/issues/form.js
+++ b/app/assets/javascripts/pages/projects/issues/form.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import GLForm from '~/gl_form';
-import IssuableForm from '~/issuable_form';
+import IssuableForm from 'ee_else_ce/issuable_form';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js
index a56c0bb6be8..c34aff02111 100644
--- a/app/assets/javascripts/pages/projects/issues/index/index.js
+++ b/app/assets/javascripts/pages/projects/issues/index/index.js
@@ -4,11 +4,13 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
-import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
+import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
+ IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
+
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
diff --git a/app/assets/javascripts/pages/projects/issues/new/index.js b/app/assets/javascripts/pages/projects/issues/new/index.js
index ffc84dc106b..aecc6484b26 100644
--- a/app/assets/javascripts/pages/projects/issues/new/index.js
+++ b/app/assets/javascripts/pages/projects/issues/new/index.js
@@ -1,3 +1,3 @@
-import initForm from '../form';
+import initForm from 'ee_else_ce/pages/projects/issues/form';
document.addEventListener('DOMContentLoaded', initForm);
diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js
index 8987c8e3f47..0447d1f79fb 100644
--- a/app/assets/javascripts/pages/projects/issues/show.js
+++ b/app/assets/javascripts/pages/projects/issues/show.js
@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import initIssueableApp from '~/issue_show';
+import initRelatedMergeRequestsApp from '~/related_merge_requests';
export default function() {
initIssueableApp();
+ initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/edit/index.js
+++ b/app/assets/javascripts/pages/projects/labels/edit/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js
index fa81ad914ba..83d6ac9fd14 100644
--- a/app/assets/javascripts/pages/projects/labels/new/index.js
+++ b/app/assets/javascripts/pages/projects/labels/new/index.js
@@ -1,3 +1,3 @@
-import Labels from '~/labels';
+import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());
diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
index ec39db12e74..0bcca22e40f 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js
@@ -2,12 +2,13 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
+import addExtraTokensForMergeRequests from 'ee_else_ce/filtered_search/add_extra_tokens_for_merge_requests';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
- IssuableFilteredSearchTokenKeys.addExtraTokensForMergeRequests();
+ addExtraTokensForMergeRequests(IssuableFilteredSearchTokenKeys);
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
index e3971618da5..8f0dc8554e2 100644
--- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
+++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js
@@ -4,7 +4,7 @@ import $ from 'jquery';
import Diff from '~/diff';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import GLForm from '~/gl_form';
-import IssuableForm from '~/issuable_form';
+import IssuableForm from 'ee_else_ce/issuable_form';
import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
index db2a4041ec0..bd4309e47ad 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue
@@ -70,7 +70,7 @@ export default {
:checked="isEditable"
class="label-bold"
type="radio"
- @click="toggleCustomInput(true);"
+ @click="toggleCustomInput(true)"
/>
<label for="custom"> {{ s__('PipelineSheduleIntervalPattern|Custom') }} </label>
@@ -88,7 +88,7 @@ export default {
:value="cronIntervalPresets.everyDay"
class="label-bold"
type="radio"
- @click="toggleCustomInput(false);"
+ @click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-day"> {{ __('Every day (at 4:00am)') }} </label>
@@ -102,7 +102,7 @@ export default {
:value="cronIntervalPresets.everyWeek"
class="label-bold"
type="radio"
- @click="toggleCustomInput(false);"
+ @click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-week">
@@ -118,7 +118,7 @@ export default {
:value="cronIntervalPresets.everyMonth"
class="label-bold"
type="radio"
- @click="toggleCustomInput(false);"
+ @click="toggleCustomInput(false)"
/>
<label class="label-bold" for="every-month">
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index 95b57d5e048..a20a0526f12 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -1,15 +1,42 @@
-/* eslint-disable class-methods-use-this */
+const defaultTimezone = { name: 'UTC', offset: 0 };
+const defaults = {
+ $inputEl: null,
+ $dropdownEl: null,
+ onSelectTimezone: null,
+ displayFormat: item => item.name,
+};
-import $ from 'jquery';
+export const formatUtcOffset = offset => {
+ const parsed = parseInt(offset, 10);
+ if (Number.isNaN(parsed) || parsed === 0) {
+ return `0`;
+ }
+ const prefix = offset > 0 ? '+' : '-';
+ return `${prefix} ${Math.abs(offset / 3600)}`;
+};
+
+export const formatTimezone = item => `[UTC ${formatUtcOffset(item.offset)}] ${item.name}`;
-const defaultTimezone = 'UTC';
+export const findTimezoneByIdentifier = (tzList = [], identifier = null) => {
+ if (tzList && tzList.length && identifier && identifier.length) {
+ return tzList.find(tz => tz.identifier === identifier) || null;
+ }
+ return null;
+};
export default class TimezoneDropdown {
- constructor() {
- this.$dropdown = $('.js-timezone-dropdown');
+ constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) {
+ this.$dropdown = $dropdownEl;
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
- this.$input = $('#schedule_cron_timezone');
+ this.$input = $inputEl;
this.timezoneData = this.$dropdown.data('data');
+
+ this.onSelectTimezone = onSelectTimezone;
+ this.displayFormat = displayFormat || defaults.displayFormat;
+
+ this.initialTimezone =
+ findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone;
+
this.initDefaultTimezone();
this.initDropdown();
}
@@ -19,50 +46,32 @@ export default class TimezoneDropdown {
data: this.timezoneData,
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
+ toggleLabel: this.displayFormat,
search: {
fields: ['name'],
},
clicked: cfg => this.updateInputValue(cfg),
- text: item => this.formatTimezone(item),
+ text: item => formatTimezone(item),
});
- this.setDropdownToggle();
- }
-
- formatUtcOffset(offset) {
- let prefix = '';
-
- if (offset > 0) {
- prefix = '+';
- } else if (offset < 0) {
- prefix = '-';
- }
-
- return `${prefix} ${Math.abs(offset / 3600)}`;
- }
-
- formatTimezone(item) {
- return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`;
+ this.setDropdownToggle(this.displayFormat(this.initialTimezone));
}
initDefaultTimezone() {
- const initialValue = this.$input.val();
-
- if (!initialValue) {
- this.$input.val(defaultTimezone);
+ if (!this.$input.val()) {
+ this.$input.val(defaultTimezone.name);
}
}
- setDropdownToggle() {
- const initialValue = this.$input.val();
-
- this.$dropdownToggle.text(initialValue);
+ setDropdownToggle(dropdownText) {
+ this.$dropdownToggle.text(dropdownText);
}
updateInputValue({ selectedObj, e }) {
e.preventDefault();
this.$input.val(selectedObj.identifier);
- gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ if (this.onSelectTimezone) {
+ this.onSelectTimezone({ selectedObj, e });
+ }
}
}
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
index 4d494efef6c..dc6df27f1c7 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/init_form.js
@@ -41,7 +41,13 @@ export default () => {
const formElement = document.getElementById('new-pipeline-schedule-form');
- gl.timezoneDropdown = new TimezoneDropdown();
+ gl.timezoneDropdown = new TimezoneDropdown({
+ $dropdownEl: $('.js-timezone-dropdown'),
+ $inputEl: $('#schedule_cron_timezone'),
+ onSelectTimezone: () => {
+ gl.pipelineScheduleFieldErrors.updateFormValidityState();
+ },
+ });
gl.targetBranchDropdown = new TargetBranchDropdown();
gl.pipelineScheduleFieldErrors = new GlFieldErrors(formElement);
diff --git a/app/assets/javascripts/pages/projects/pipelines/charts/index.js b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
index 48353f3b4ef..9fa580d2ba9 100644
--- a/app/assets/javascripts/pages/projects/pipelines/charts/index.js
+++ b/app/assets/javascripts/pages/projects/pipelines/charts/index.js
@@ -1,29 +1,31 @@
import $ from 'jquery';
import Chart from 'chart.js';
-const options = {
- scaleOverlay: true,
- responsive: true,
- maintainAspectRatio: false,
-};
+import { barChartOptions, lineChartOptions } from '~/lib/utils/chart_utils';
+
+const SUCCESS_LINE_COLOR = '#1aaa55';
+
+const TOTAL_LINE_COLOR = '#707070';
-const buildChart = chartScope => {
+const buildChart = (chartScope, shouldAdjustFontSize) => {
const data = {
labels: chartScope.labels,
datasets: [
{
- fillColor: '#707070',
- strokeColor: '#707070',
- pointColor: '#707070',
- pointStrokeColor: '#EEE',
- data: chartScope.totalValues,
+ backgroundColor: SUCCESS_LINE_COLOR,
+ borderColor: SUCCESS_LINE_COLOR,
+ pointBackgroundColor: SUCCESS_LINE_COLOR,
+ pointBorderColor: '#fff',
+ data: chartScope.successValues,
+ fill: 'origin',
},
{
- fillColor: '#1aaa55',
- strokeColor: '#1aaa55',
- pointColor: '#1aaa55',
- pointStrokeColor: '#fff',
- data: chartScope.successValues,
+ backgroundColor: TOTAL_LINE_COLOR,
+ borderColor: TOTAL_LINE_COLOR,
+ pointBackgroundColor: TOTAL_LINE_COLOR,
+ pointBorderColor: '#EEE',
+ data: chartScope.totalValues,
+ fill: '-1',
},
],
};
@@ -31,36 +33,51 @@ const buildChart = chartScope => {
.get(0)
.getContext('2d');
- new Chart(ctx).Line(data, options);
+ return new Chart(ctx, {
+ type: 'line',
+ data,
+ options: lineChartOptions({
+ width: ctx.canvas.width,
+ numberOfPoints: chartScope.totalValues.length,
+ shouldAdjustFontSize,
+ }),
+ });
};
-document.addEventListener('DOMContentLoaded', () => {
- const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
- const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
+const buildBarChart = (chartTimesData, shouldAdjustFontSize) => {
const data = {
labels: chartTimesData.labels,
datasets: [
{
- fillColor: 'rgba(220,220,220,0.5)',
- strokeColor: 'rgba(220,220,220,1)',
- barStrokeWidth: 1,
+ backgroundColor: 'rgba(220,220,220,0.5)',
+ borderColor: 'rgba(220,220,220,1)',
+ borderWidth: 1,
barValueSpacing: 1,
barDatasetSpacing: 1,
data: chartTimesData.values,
},
],
};
-
- if (window.innerWidth < 768) {
- // Scale fonts if window width lower than 768px (iPad portrait)
- options.scaleFontSize = 8;
- }
-
- new Chart(
+ return new Chart(
$('#build_timesChart')
.get(0)
.getContext('2d'),
- ).Bar(data, options);
+ {
+ type: 'bar',
+ data,
+ options: barChartOptions(shouldAdjustFontSize),
+ },
+ );
+};
+
+document.addEventListener('DOMContentLoaded', () => {
+ const chartTimesData = JSON.parse(document.getElementById('pipelinesTimesChartsData').innerHTML);
+ const chartsData = JSON.parse(document.getElementById('pipelinesChartsData').innerHTML);
+
+ // Scale fonts if window width lower than 768px (iPad portrait)
+ const shouldAdjustFontSize = window.innerWidth < 768;
+
+ buildBarChart(chartTimesData, shouldAdjustFontSize);
- chartsData.forEach(scope => buildChart(scope));
+ chartsData.forEach(scope => buildChart(scope, shouldAdjustFontSize));
});
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index adbe744290a..f39765818e7 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,7 +1,7 @@
+import Members from 'ee_else_ce/members';
import memberExpirationDate from '../../../member_expiration_date';
import UsersSelect from '../../../users_select';
import groupsSelect from '../../../groups_select';
-import Members from '../../../members';
document.addEventListener('DOMContentLoaded', () => {
memberExpirationDate('.js-access-expiration-date-groups');
diff --git a/app/assets/javascripts/pages/projects/releases/index/index.js b/app/assets/javascripts/pages/projects/releases/index/index.js
new file mode 100644
index 00000000000..c183fbb9610
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/releases/index/index.js
@@ -0,0 +1,3 @@
+import initReleases from '~/releases';
+
+document.addEventListener('DOMContentLoaded', initReleases);
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
new file mode 100644
index 00000000000..5270a7924ec
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -0,0 +1,7 @@
+import mountErrorTrackingForm from '~/error_tracking_settings';
+import mountOperationSettings from '~/operation_settings';
+
+document.addEventListener('DOMContentLoaded', () => {
+ mountErrorTrackingForm();
+ mountOperationSettings();
+});
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 08c7719dcf2..19d9903c988 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -325,8 +325,8 @@ export default {
<project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled"
:help-path="pagesHelpPath"
- label="Pages"
- help-text="Static website for the project."
+ label="Pages access control"
+ help-text="Access control for the project's static website"
>
<project-feature-setting
v-model="pagesAccessLevel"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/constants.js b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
index bc5c29d12b5..ac0dca31c37 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/constants.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const visibilityOptions = {
PRIVATE: 0,
INTERNAL: 10,
@@ -5,9 +7,11 @@ export const visibilityOptions = {
};
export const visibilityLevelDescriptions = {
- [visibilityOptions.PRIVATE]:
+ [visibilityOptions.PRIVATE]: __(
'The project is accessible only by members of the project. Access must be granted explicitly to each user.',
- [visibilityOptions.INTERNAL]: 'The project can be accessed by any user who is logged in.',
- [visibilityOptions.PUBLIC]:
+ ),
+ [visibilityOptions.INTERNAL]: __('The project can be accessed by any user who is logged in.'),
+ [visibilityOptions.PUBLIC]: __(
'The project can be accessed by anyone, regardless of authentication.',
+ ),
};
diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js
index 7302c1ab202..6aa41d0825b 100644
--- a/app/assets/javascripts/pages/projects/show/index.js
+++ b/app/assets/javascripts/pages/projects/show/index.js
@@ -9,6 +9,7 @@ import Activities from '~/activities';
import { ajaxGet } from '~/lib/utils/common_utils';
import GpgBadges from '~/gpg_badges';
import initReadMore from '~/read_more';
+import leaveByUrl from '~/namespaces/leave_by_url';
import Star from '../../../star';
import notificationsDropdown from '../../../notifications_dropdown';
@@ -44,4 +45,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
GpgBadges.fetch();
+ leaveByUrl('project');
+
+ if (document.getElementById('js-tree-list')) {
+ import('~/repository')
+ .then(m => m.default())
+ .catch(e => {
+ throw e;
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/projects/tags/releases/index.js b/app/assets/javascripts/pages/projects/tags/releases/index.js
new file mode 100644
index 00000000000..d6afc71fb03
--- /dev/null
+++ b/app/assets/javascripts/pages/projects/tags/releases/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import ZenMode from '~/zen_mode';
+import GLForm from '~/gl_form';
+
+document.addEventListener('DOMContentLoaded', () => {
+ new ZenMode(); // eslint-disable-line no-new
+ new GLForm($('.release-form')); // eslint-disable-line no-new
+});
diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js
index 400aed35e32..7b90a3a4f6e 100644
--- a/app/assets/javascripts/pages/projects/tree/show/index.js
+++ b/app/assets/javascripts/pages/projects/tree/show/index.js
@@ -40,4 +40,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
GpgBadges.fetch();
+
+ if (document.getElementById('js-tree-list')) {
+ import('~/repository')
+ .then(m => m.default())
+ .catch(e => {
+ throw e;
+ });
+ }
});
diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js
index 0c896c8599e..d5a8e712d6b 100644
--- a/app/assets/javascripts/pages/search/show/search.js
+++ b/app/assets/javascripts/pages/search/show/search.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import Flash from '~/flash';
import Api from '~/api';
+import { __ } from '~/locale';
export default class Search {
constructor() {
@@ -24,7 +25,7 @@ export default class Search {
data(term, callback) {
return Api.groups(term, {}, data => {
data.unshift({
- full_name: 'Any',
+ full_name: __('Any'),
});
data.splice(1, 0, 'divider');
return callback(data);
@@ -54,14 +55,14 @@ export default class Search {
this.getProjectsData(term)
.then(data => {
data.unshift({
- name_with_namespace: 'Any',
+ name_with_namespace: __('Any'),
});
data.splice(1, 0, 'divider');
return data;
})
.then(data => callback(data))
- .catch(() => new Flash('Error fetching projects'));
+ .catch(() => new Flash(__('Error fetching projects')));
},
id(obj) {
return obj.id;
diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js
index 07f32210d93..e1a3f42a71f 100644
--- a/app/assets/javascripts/pages/sessions/new/index.js
+++ b/app/assets/javascripts/pages/sessions/new/index.js
@@ -1,13 +1,20 @@
import $ from 'jquery';
import UsernameValidator from './username_validator';
+import NoEmojiValidator from '../../../emoji/no_emoji_validator';
import SigninTabsMemoizer from './signin_tabs_memoizer';
import OAuthRememberMe from './oauth_remember_me';
+import preserveUrlFragment from './preserve_url_fragment';
document.addEventListener('DOMContentLoaded', () => {
new UsernameValidator(); // eslint-disable-line no-new
new SigninTabsMemoizer(); // eslint-disable-line no-new
+ new NoEmojiValidator(); // eslint-disable-line no-new
new OAuthRememberMe({
container: $('.omniauth-container'),
}).bindEvents();
+
+ // Save the URL fragment from the current window location. This will be present if the user was
+ // redirected to sign-in after attempting to access a protected URL that included a fragment.
+ preserveUrlFragment(window.location.hash);
});
diff --git a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
index 761618109a4..191221a48cd 100644
--- a/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
+++ b/app/assets/javascripts/pages/sessions/new/oauth_remember_me.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
/**
* OAuth-based login buttons have a separate "remember me" checkbox.
@@ -24,9 +25,9 @@ export default class OAuthRememberMe {
const href = $(element).attr('href');
if (rememberMe) {
- $(element).attr('href', `${href}?remember_me=1`);
+ $(element).attr('href', mergeUrlParams({ remember_me: 1 }, href));
} else {
- $(element).attr('href', href.replace('?remember_me=1', ''));
+ $(element).attr('href', removeParams(['remember_me'], href));
}
});
}
diff --git a/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
new file mode 100644
index 00000000000..e617fecaa0f
--- /dev/null
+++ b/app/assets/javascripts/pages/sessions/new/preserve_url_fragment.js
@@ -0,0 +1,32 @@
+import { mergeUrlParams, setUrlFragment } from '~/lib/utils/url_utility';
+
+/**
+ * Ensure the given URL fragment is preserved by appending it to sign-in/sign-up form actions and
+ * OAuth/SAML login links.
+ *
+ * @param fragment {string} - url fragment to be preserved
+ */
+export default function preserveUrlFragment(fragment = '') {
+ if (fragment) {
+ const normalFragment = fragment.replace(/^#/, '');
+
+ // Append the fragment to all sign-in/sign-up form actions so it is preserved when the user is
+ // eventually redirected back to the originally requested URL.
+ const forms = document.querySelectorAll('#signin-container form');
+ Array.prototype.forEach.call(forms, form => {
+ const actionWithFragment = setUrlFragment(form.getAttribute('action'), `#${normalFragment}`);
+ form.setAttribute('action', actionWithFragment);
+ });
+
+ // Append a redirect_fragment query param to all oauth provider links. The redirect_fragment
+ // query param will be available in the omniauth callback upon successful authentication
+ const anchors = document.querySelectorAll('#signin-container a.oauth-login');
+ Array.prototype.forEach.call(anchors, anchor => {
+ const newHref = mergeUrlParams(
+ { redirect_fragment: normalFragment },
+ anchor.getAttribute('href'),
+ );
+ anchor.setAttribute('href', newHref);
+ });
+ }
+}
diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 8a84ac37dab..693125f8a38 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -6,10 +6,16 @@ import dateFormat from 'dateformat';
import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility';
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
-import { __ } from '~/locale';
+import { n__, s__, __ } from '~/locale';
const d3 = { select, scaleLinear, scaleThreshold };
+const firstDayOfWeekChoices = Object.freeze({
+ sunday: 0,
+ monday: 1,
+ saturday: 6,
+});
+
const LOADING_HTML = `
<div class="text-center">
<i class="fa fa-spinner fa-spin user-calendar-activities-loading"></i>
@@ -29,9 +35,9 @@ function formatTooltipText({ date, count }) {
const dateDayName = getDayName(dateObject);
const dateText = dateFormat(dateObject, 'mmm d, yyyy');
- let contribText = 'No contributions';
+ let contribText = __('No contributions');
if (count > 0) {
- contribText = `${count} contribution${count > 1 ? 's' : ''}`;
+ contribText = n__('%d contribution', '%d contributions', count);
}
return `${contribText}<br />${dateDayName} ${dateText}`;
}
@@ -49,7 +55,7 @@ export default class ActivityCalendar {
timestamps,
calendarActivitiesPath,
utcOffset = 0,
- firstDayOfWeek = 0,
+ firstDayOfWeek = firstDayOfWeekChoices.sunday,
monthsAgo = 12,
) {
this.calendarActivitiesPath = calendarActivitiesPath;
@@ -59,18 +65,18 @@ export default class ActivityCalendar {
this.daySize = 15;
this.daySizeWithSpace = this.daySize + this.daySpace * 2;
this.monthNames = [
- 'Jan',
- 'Feb',
- 'Mar',
- 'Apr',
- 'May',
- 'Jun',
- 'Jul',
- 'Aug',
- 'Sep',
- 'Oct',
- 'Nov',
- 'Dec',
+ __('Jan'),
+ __('Feb'),
+ __('Mar'),
+ __('Apr'),
+ __('May'),
+ __('Jun'),
+ __('Jul'),
+ __('Aug'),
+ __('Sep'),
+ __('Oct'),
+ __('Nov'),
+ __('Dec'),
];
this.months = [];
this.firstDayOfWeek = firstDayOfWeek;
@@ -159,7 +165,7 @@ export default class ActivityCalendar {
.append('g')
.attr('transform', (group, i) => {
_.each(group, (stamp, a) => {
- if (a === 0 && stamp.day === 0) {
+ if (a === 0 && stamp.day === this.firstDayOfWeek) {
const month = stamp.date.getMonth();
const x = this.daySizeWithSpace * i + 1 + this.daySizeWithSpace;
const lastMonth = _.last(this.months);
@@ -193,18 +199,31 @@ export default class ActivityCalendar {
renderDayTitles() {
const days = [
{
- text: 'M',
+ text: s__('DayTitle|M'),
y: 29 + this.dayYPos(1),
},
{
- text: 'W',
+ text: s__('DayTitle|W'),
y: 29 + this.dayYPos(3),
},
{
- text: 'F',
+ text: s__('DayTitle|F'),
y: 29 + this.dayYPos(5),
},
];
+
+ if (this.firstDayOfWeek === firstDayOfWeekChoices.monday) {
+ days.push({
+ text: s__('DayTitle|S'),
+ y: 29 + this.dayYPos(7),
+ });
+ } else if (this.firstDayOfWeek === firstDayOfWeekChoices.saturday) {
+ days.push({
+ text: s__('DayTitle|S'),
+ y: 29 + this.dayYPos(6),
+ });
+ }
+
this.svg
.append('g')
.selectAll('text')
@@ -234,11 +253,11 @@ export default class ActivityCalendar {
renderKey() {
const keyValues = [
- 'no contributions',
- '1-9 contributions',
- '10-19 contributions',
- '20-29 contributions',
- '30+ contributions',
+ __('no contributions'),
+ __('1-9 contributions'),
+ __('10-19 contributions'),
+ __('20-29 contributions'),
+ __('30+ contributions'),
];
const keyColors = [
'#ededed',
diff --git a/app/assets/javascripts/pages/users/user_overview_block.js b/app/assets/javascripts/pages/users/user_overview_block.js
index eec2b5ca8e5..a7c3c9d104d 100644
--- a/app/assets/javascripts/pages/users/user_overview_block.js
+++ b/app/assets/javascripts/pages/users/user_overview_block.js
@@ -15,7 +15,8 @@ export default class UserOverviewBlock {
}
loadData() {
- const loadingEl = document.querySelector(`${this.container} .loading`);
+ const containerEl = document.querySelector(this.container);
+ const loadingEl = containerEl.querySelector(`.loading`);
loadingEl.classList.remove('hide');
@@ -29,18 +30,21 @@ export default class UserOverviewBlock {
render(data) {
const { html, count } = data;
- const contentList = document.querySelector(`${this.container} .overview-content-list`);
+ const containerEl = document.querySelector(this.container);
+ const contentList = containerEl.querySelector('.overview-content-list');
contentList.innerHTML += html;
- const loadingEl = document.querySelector(`${this.container} .loading`);
+ const loadingEl = containerEl.querySelector('.loading');
if (count && count > 0) {
- document.querySelector(`${this.container} .js-view-all`).classList.remove('hide');
+ containerEl.querySelector('.js-view-all').classList.remove('hide');
} else {
- document
- .querySelector(`${this.container} .nothing-here-block`)
- .classList.add('text-left', 'p-0');
+ const nothingHereBlock = containerEl.querySelector('.nothing-here-block');
+
+ if (nothingHereBlock) {
+ nothingHereBlock.classList.add('p-5');
+ }
}
loadingEl.classList.add('hide');
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index 1c3fd58ca74..7f800d20835 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -91,6 +91,7 @@ export default class UserTabs {
this.actions = Object.keys(this.loaded);
this.bindEvents();
+ // TODO: refactor to make this configurable via constructor params with a default value of 'show'
if (this.action === 'show') {
this.action = this.defaultAction;
}
@@ -221,7 +222,7 @@ export default class UserTabs {
const monthsAgo = UserTabs.getVisibleCalendarPeriod($calendarWrap);
const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath');
const utcOffset = $calendarWrap.data('utcOffset');
- const calendarHint = __('Issues, merge requests, pushes and comments.');
+ const calendarHint = __('Issues, merge requests, pushes, and comments.');
$calendarWrap.html(CALENDAR_TEMPLATE);
@@ -234,7 +235,7 @@ export default class UserTabs {
data,
calendarActivitiesPath,
utcOffset,
- 0,
+ gon.first_day_of_week,
monthsAgo,
);
}
diff --git a/app/assets/javascripts/pdf/index.vue b/app/assets/javascripts/pdf/index.vue
index cdf1257b4e3..6d39abd4a1f 100644
--- a/app/assets/javascripts/pdf/index.vue
+++ b/app/assets/javascripts/pdf/index.vue
@@ -1,6 +1,6 @@
<script>
-import pdfjsLib from 'vendor/pdf';
-import workerSrc from 'vendor/pdf.worker.min';
+import pdfjsLib from 'pdfjs-dist/build/pdf';
+import workerSrc from 'pdfjs-dist/build/pdf.worker.min';
import page from './page/index.vue';
@@ -28,7 +28,7 @@ export default {
},
watch: { pdf: 'load' },
mounted() {
- pdfjsLib.PDFJS.workerSrc = workerSrc;
+ pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc;
if (this.hasPDF) this.load();
},
methods: {
diff --git a/app/assets/javascripts/performance_bar/components/detailed_metric.vue b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
index c729198c1d3..8f3ba9779fb 100644
--- a/app/assets/javascripts/performance_bar/components/detailed_metric.vue
+++ b/app/assets/javascripts/performance_bar/components/detailed_metric.vue
@@ -1,9 +1,11 @@
<script>
import GlModal from '~/vue_shared/components/gl_modal.vue';
+import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlModal,
+ Icon,
},
props: {
currentRequest: {
@@ -38,7 +40,11 @@ export default {
};
</script>
<template>
- <div v-if="currentRequest.details" :id="`peek-view-${metric}`" class="view">
+ <div
+ v-if="currentRequest.details"
+ :id="`peek-view-${metric}`"
+ class="view qa-performance-bar-detailed-metric"
+ >
<button
:data-target="`#modal-peek-${metric}-details`"
class="btn-blank btn-link bold"
@@ -57,9 +63,31 @@ export default {
<template v-if="detailsList.length">
<tr v-for="(item, index) in detailsList" :key="index">
<td>
- <strong>{{ item.duration }}ms</strong>
+ <span>{{ item.duration }}ms</span>
+ </td>
+ <td>
+ <div class="js-toggle-container">
+ <div
+ v-for="(key, keyIndex) in keys"
+ :key="key"
+ class="break-word"
+ :class="{ 'mb-3 bold': keyIndex == 0 }"
+ >
+ {{ item[key] }}
+ <button
+ v-if="keyIndex == 0 && item.backtrace"
+ class="text-expander js-toggle-button"
+ type="button"
+ :aria-label="__('Toggle backtrace')"
+ >
+ <icon :size="12" name="ellipsis_h" />
+ </button>
+ </div>
+ <pre v-if="item.backtrace" class="backtrace-row js-toggle-content mt-2">{{
+ item.backtrace
+ }}</pre>
+ </div>
</td>
- <td v-for="key in keys" :key="key" class="break-word">{{ item[key] }}</td>
</tr>
</template>
<template v-else>
diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
index 74faa35358d..48515cf785c 100644
--- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
+++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue
@@ -92,7 +92,7 @@ export default {
</script>
<template>
<div id="js-peek" :class="env">
- <div v-if="currentRequest" class="d-flex container-fluid container-limited">
+ <div v-if="currentRequest" class="d-flex container-fluid container-limited qa-performance-bar">
<div id="peek-view-host" class="view">
<span
v-if="hasHost"
@@ -134,6 +134,13 @@ export default {
>ms / <span title="Invoke Count">{{ currentRequest.details.gc.invokes }}</span> gc
</span>
</div>
+ <div
+ v-if="currentRequest.details && currentRequest.details.tracing"
+ id="peek-view-trace"
+ class="view"
+ >
+ <a :href="currentRequest.details.tracing.tracing_url"> trace </a>
+ </div>
<request-selector
v-if="currentRequest"
:current-request="currentRequest"
diff --git a/app/assets/javascripts/performance_bar/components/request_selector.vue b/app/assets/javascripts/performance_bar/components/request_selector.vue
index fdb5c0d6939..297507b85af 100644
--- a/app/assets/javascripts/performance_bar/components/request_selector.vue
+++ b/app/assets/javascripts/performance_bar/components/request_selector.vue
@@ -37,7 +37,12 @@ export default {
<template>
<div id="peek-request-selector">
<select v-model="currentRequestId">
- <option v-for="request in requests" :key="request.id" :value="request.id">
+ <option
+ v-for="request in requests"
+ :key="request.id"
+ :value="request.id"
+ class="qa-performance-bar-request"
+ >
{{ truncatedUrl(request.url) }}
</option>
</select>
diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js
new file mode 100644
index 00000000000..4a08e158f6b
--- /dev/null
+++ b/app/assets/javascripts/persistent_user_callout.js
@@ -0,0 +1,42 @@
+import axios from './lib/utils/axios_utils';
+import { __ } from './locale';
+import Flash from './flash';
+
+export default class PersistentUserCallout {
+ constructor(container) {
+ const { dismissEndpoint, featureId } = container.dataset;
+ this.container = container;
+ this.dismissEndpoint = dismissEndpoint;
+ this.featureId = featureId;
+
+ this.init();
+ }
+
+ init() {
+ const closeButton = this.container.querySelector('.js-close');
+ closeButton.addEventListener('click', event => this.dismiss(event));
+ }
+
+ dismiss(event) {
+ event.preventDefault();
+
+ axios
+ .post(this.dismissEndpoint, {
+ feature_name: this.featureId,
+ })
+ .then(() => {
+ this.container.remove();
+ })
+ .catch(() => {
+ Flash(__('An error occurred while dismissing the alert. Refresh the page and try again.'));
+ });
+ }
+
+ static factory(container) {
+ if (!container) {
+ return undefined;
+ }
+
+ return new PersistentUserCallout(container);
+ }
+}
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue
index 8ca539351a7..3c85bb61ce8 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlButton, GlLoadingIcon } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
@@ -20,6 +20,7 @@ export default {
components: {
Icon,
GlButton,
+ GlLoadingIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -41,6 +42,7 @@ export default {
data() {
return {
isDisabled: false,
+ isLoading: false,
};
},
computed: {
@@ -59,15 +61,19 @@ export default {
onClickAction() {
this.$root.$emit('bv::hide::tooltip', `js-ci-action-${this.link}`);
this.isDisabled = true;
+ this.isLoading = true;
axios
.post(`${this.link}.json`)
.then(() => {
this.isDisabled = false;
+ this.isLoading = false;
+
this.$emit('pipelineActionRequestComplete');
})
.catch(() => {
this.isDisabled = false;
+ this.isLoading = false;
createFlash(__('An error occurred while making the request.'));
});
@@ -82,10 +88,10 @@ export default {
:title="tooltipText"
:class="cssClass"
:disabled="isDisabled"
- class="js-ci-action btn btn-blank
-btn-transparent ci-action-icon-container ci-action-icon-wrapper"
+ class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper"
@click="onClickAction"
>
- <icon :name="actionIcon" />
+ <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" />
+ <icon v-else :name="actionIcon" />
</gl-button>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 59cebaba717..ba0dea626dc 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,59 +1,20 @@
<script>
-import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponent from './stage_column_component.vue';
+import GraphMixin from '../../mixins/graph_component_mixin';
export default {
components: {
StageColumnComponent,
GlLoadingIcon,
},
- props: {
- isLoading: {
- type: Boolean,
- required: true,
- },
- pipeline: {
- type: Object,
- required: true,
- },
- },
- computed: {
- graph() {
- return this.pipeline.details && this.pipeline.details.stages;
- },
- },
- methods: {
- capitalizeStageName(name) {
- const escapedName = _.escape(name);
- return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
- },
- isFirstColumn(index) {
- return index === 0;
- },
- stageConnectorClass(index, stage) {
- let className;
-
- // If it's the first stage column and only has one job
- if (index === 0 && stage.groups.length === 1) {
- className = 'no-margin';
- } else if (index > 0) {
- // If it is not the first column
- className = 'left-margin';
- }
-
- return className;
- },
- refreshPipelineGraph() {
- this.$emit('refreshPipelineGraph');
- },
- },
+ mixins: [GraphMixin],
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph pipeline-tab-content">
- <div class="text-center"><gl-loading-icon v-if="isLoading" :size="3" /></div>
+ <div v-if="isLoading" class="m-auto"><gl-loading-icon :size="3" /></div>
<ul v-if="!isLoading" class="stage-column-list">
<stage-column-component
@@ -63,6 +24,7 @@ export default {
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
+ :action="stage.status.action"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index 482898b80c4..ebd7a17040a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -69,7 +69,9 @@ export default {
>
<ci-icon :status="group.status" />
- <span class="ci-status-text"> {{ group.name }} </span>
+ <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ {{ group.name }}
+ </span>
<span class="dropdown-counter-badge"> {{ group.size }} </span>
</button>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index cf9db89e32b..0d5afe04e8e 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -57,6 +57,9 @@ export default {
},
},
computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
status() {
return this.job && this.job.status ? this.job.status : {};
},
@@ -104,18 +107,18 @@ export default {
<div class="ci-job-component">
<gl-link
v-if="status.has_details"
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:href="status.details_path"
:title="tooltipText"
:class="cssClassJobName"
- class="js-pipeline-graph-job-link"
+ class="js-pipeline-graph-job-link qa-job-link"
>
<job-name-component :name="job.name" :status="job.status" />
</gl-link>
<div
v-else
- v-gl-tooltip
+ v-gl-tooltip="{ boundary, placement: 'bottom' }"
:title="tooltipText"
:class="cssClassJobName"
class="js-job-component-tooltip non-details-job-component"
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
index 1bfab2a7fc0..02451839330 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
@@ -27,7 +27,8 @@ export default {
<template>
<span class="ci-job-name-component">
<ci-icon :status="status" />
-
- <span class="ci-status-text"> {{ name }} </span>
+ <span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
+ {{ name }}
+ </span>
</span>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 09a50d25020..d5c124dc0ca 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,13 +1,17 @@
<script>
import _ from 'underscore';
+import stageColumnMixin from 'ee_else_ce/pipelines/mixins/stage_column_mixin';
import JobItem from './job_item.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
+import ActionComponent from './action_component.vue';
export default {
components: {
JobItem,
JobGroupDropdown,
+ ActionComponent,
},
+ mixins: [stageColumnMixin],
props: {
title: {
type: String,
@@ -27,14 +31,21 @@ export default {
required: false,
default: '',
},
+ action: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ hasAction() {
+ return !_.isEmpty(this.action);
+ },
},
methods: {
groupId(group) {
return `ci-badge-${_.escape(group.name)}`;
},
- buildConnnectorClass(index) {
- return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
- },
pipelineActionRequestComplete() {
this.$emit('refreshPipelineGraph');
},
@@ -43,7 +54,18 @@ export default {
</script>
<template>
<li :class="stageConnectorClass" class="stage-column">
- <div class="stage-name">{{ title }}</div>
+ <div class="stage-name position-relative">
+ {{ title }}
+ <action-component
+ v-if="hasAction"
+ :action-icon="action.icon"
+ :tooltip-text="action.title"
+ :link="action.path"
+ class="js-stage-action stage-action position-absolute position-top-0 rounded"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+
<div class="builds-container">
<ul>
<li
diff --git a/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
new file mode 100644
index 00000000000..4cafd147511
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_stop_modal.vue
@@ -0,0 +1,97 @@
+<script>
+import _ from 'underscore';
+import GlModal from '~/vue_shared/components/gl_modal.vue';
+import { GlLink } from '@gitlab/ui';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { s__, sprintf } from '~/locale';
+
+/**
+ * Pipeline Stop Modal.
+ *
+ * Renders the modal used to confirm stopping a pipeline.
+ */
+export default {
+ components: {
+ GlModal,
+ GlLink,
+ ClipboardButton,
+ CiIcon,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ deep: true,
+ },
+ },
+ computed: {
+ modalTitle() {
+ return sprintf(
+ s__('Pipeline|Stop pipeline #%{pipelineId}?'),
+ {
+ pipelineId: `${this.pipeline.id}`,
+ },
+ false,
+ );
+ },
+ modalText() {
+ return sprintf(
+ s__(`Pipeline|You’re about to stop pipeline %{pipelineId}.`),
+ {
+ pipelineId: `<strong>#${this.pipeline.id}</strong>`,
+ },
+ false,
+ );
+ },
+ hasRef() {
+ return !_.isEmpty(this.pipeline.ref);
+ },
+ },
+ methods: {
+ emitSubmit(event) {
+ this.$emit('submit', event);
+ },
+ },
+};
+</script>
+<template>
+ <gl-modal
+ id="confirmation-modal"
+ :header-title-text="modalTitle"
+ :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
+ footer-primary-button-variant="danger"
+ @submit="emitSubmit($event)"
+ >
+ <p v-html="modalText"></p>
+
+ <p v-if="pipeline">
+ <ci-icon
+ v-if="pipeline.details"
+ :status="pipeline.details.status"
+ class="vertical-align-middle"
+ />
+
+ <span class="font-weight-bold">{{ __('Pipeline') }}</span>
+
+ <a :href="pipeline.path" class="js-pipeline-path link-commit qa-pipeline-path"
+ >#{{ pipeline.id }}</a
+ >
+ <template v-if="hasRef">
+ {{ __('from') }}
+ <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a>
+ </template>
+ </p>
+
+ <template v-if="pipeline.commit">
+ <p>
+ <span class="font-weight-bold">{{ __('Commit') }}</span>
+
+ <gl-link :href="pipeline.commit.commit_path" class="js-commit-sha commit-sha link-commit">
+ {{ pipeline.commit.short_id }}
+ </gl-link>
+ </p>
+ <p>{{ pipeline.commit.title }}</p>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue
new file mode 100644
index 00000000000..740b54cd8e0
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipeline_triggerer.vue
@@ -0,0 +1,35 @@
+<script>
+import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
+
+export default {
+ components: {
+ UserAvatarLink,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ user() {
+ return this.pipeline.user;
+ },
+ },
+};
+</script>
+<template>
+ <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-triggerer">
+ <user-avatar-link
+ v-if="user"
+ :link-href="user.path"
+ :img-src="user.avatar_url"
+ :img-size="26"
+ :tooltip-text="user.name"
+ class="prepend-left-default js-pipeline-url-user"
+ />
+ <span v-else class="prepend-left-default js-pipeline-url-api api">
+ {{ s__('Pipelines|API') }}
+ </span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue
index 30a5bbf92ce..c41ecab1294 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue
@@ -1,8 +1,20 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
+import _ from 'underscore';
+import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import popover from '~/vue_shared/directives/popover';
+const popoverTitle = sprintf(
+ _.escape(
+ __(
+ `This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}`,
+ ),
+ ),
+ { strongStart: '<b>', strongEnd: '</b>' },
+ false,
+);
+
export default {
components: {
UserAvatarLink,
@@ -32,14 +44,14 @@ export default {
trigger: 'focus',
placement: 'top',
title: `<div class="autodevops-title">
- This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>
+ ${popoverTitle}
</div>`,
content: `<a
class="autodevops-link"
href="${this.autoDevopsHelpPath}"
target="_blank"
rel="noopener noreferrer nofollow">
- Learn more about Auto DevOps
+ ${_.escape(__('Learn more about Auto DevOps'))}
</a>`,
};
},
@@ -47,27 +59,18 @@ export default {
};
</script>
<template>
- <div class="table-section section-15 d-none d-sm-none d-md-block pipeline-tags">
+ <div class="table-section section-10 d-none d-sm-none d-md-block pipeline-tags">
<gl-link :href="pipeline.path" class="js-pipeline-url-link">
<span class="pipeline-id">#{{ pipeline.id }}</span>
</gl-link>
- <span>by</span>
- <user-avatar-link
- v-if="user"
- :link-href="pipeline.user.path"
- :img-src="pipeline.user.avatar_url"
- :tooltip-text="pipeline.user.name"
- class="js-pipeline-url-user"
- />
- <span v-if="!user" class="js-pipeline-url-api api"> API </span>
<div class="label-container">
<span
v-if="pipeline.flags.latest"
v-gl-tooltip
+ :title="__('Latest pipeline for this branch')"
class="js-pipeline-url-latest badge badge-success"
- title="__('Latest pipeline for this branch')"
>
- latest
+ {{ __('latest') }}
</span>
<span
v-if="pipeline.flags.yaml_errors"
@@ -75,7 +78,7 @@ export default {
:title="pipeline.yaml_errors"
class="js-pipeline-url-yaml badge badge-danger"
>
- yaml invalid
+ {{ __('yaml invalid') }}
</span>
<span
v-if="pipeline.flags.failure_reason"
@@ -83,7 +86,7 @@ export default {
:title="pipeline.failure_reason"
class="js-pipeline-url-failure badge badge-danger"
>
- error
+ {{ __('error') }}
</span>
<gl-link
v-if="pipeline.flags.auto_devops"
@@ -95,15 +98,15 @@ export default {
Auto DevOps
</gl-link>
<span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning">
- stuck
+ {{ __('stuck') }}
</span>
<span
- v-if="pipeline.flags.merge_request"
+ v-if="pipeline.flags.detached_merge_request_pipeline"
v-gl-tooltip
- title="__('This pipeline is run in a merge request context')"
- class="js-pipeline-url-mergerequest badge badge-info"
+ :title="__('This pipeline is run on the source branch')"
+ class="js-pipeline-url-detached badge badge-info"
>
- merge request
+ {{ __('detached') }}
</span>
</div>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
index 2e9f2519fcb..244d332f38f 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_actions.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_actions.vue
@@ -59,28 +59,30 @@ export default {
</script>
<template>
<div class="btn-group">
- <gl-button
+ <button
v-gl-tooltip
+ type="button"
:disabled="isLoading"
class="dropdown-new btn btn-default js-pipeline-dropdown-manual-actions"
- title="Manual job"
+ :title="__('Manual job')"
data-toggle="dropdown"
- aria-label="Manual job"
+ :aria-label="__('Manual job')"
>
- <icon name="play" class="icon-play" /> <i class="fa fa-caret-down" aria-hidden="true"> </i>
+ <icon name="play" class="icon-play" />
+ <i class="fa fa-caret-down" aria-hidden="true"></i>
<gl-loading-icon v-if="isLoading" />
- </gl-button>
+ </button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="action in actions" :key="action.path">
<gl-button
:class="{ disabled: isActionDisabled(action) }"
:disabled="isActionDisabled(action)"
- class="js-pipeline-action-link no-btn btn"
- @click="onClickAction(action);"
+ class="js-pipeline-action-link no-btn btn d-flex align-items-center justify-content-between flex-wrap"
+ @click="onClickAction(action)"
>
{{ action.name }}
- <span v-if="action.scheduled_at" class="pull-right">
+ <span v-if="action.scheduled_at">
<icon name="clock" />
<gl-countdown :end-date-string="action.scheduled_at" />
</span>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index 908b10afee6..2ab0ad4d013 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -1,5 +1,5 @@
<script>
-import { GlLink, GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
@@ -9,7 +9,6 @@ export default {
components: {
Icon,
GlLink,
- GlButton,
},
props: {
artifacts: {
@@ -21,20 +20,22 @@ export default {
</script>
<template>
<div class="btn-group" role="group">
- <gl-button
+ <button
v-gl-tooltip
- class="dropdown-toggle build-artifacts js-pipeline-dropdown-download"
- title="Artifacts"
+ type="button"
+ class="dropdown-toggle build-artifacts btn btn-default js-pipeline-dropdown-download"
+ :title="__('Artifacts')"
data-toggle="dropdown"
- aria-label="Artifacts"
+ :aria-label="__('Artifacts')"
>
- <icon name="download" /> <i class="fa fa-caret-down" aria-hidden="true"> </i>
- </gl-button>
+ <icon name="download" />
+ <i class="fa fa-caret-down" aria-hidden="true"></i>
+ </button>
<ul class="dropdown-menu dropdown-menu-right">
<li v-for="(artifact, i) in artifacts" :key="i">
- <gl-link :href="artifact.path" rel="nofollow" download>
- Download {{ artifact.name }} artifacts
- </gl-link>
+ <gl-link :href="artifact.path" rel="nofollow" download
+ >Download {{ artifact.name }} artifacts</gl-link
+ >
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_table.vue
index 1c60ae6a152..03d332cd430 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table.vue
@@ -1,7 +1,7 @@
<script>
-import Modal from '~/vue_shared/components/gl_modal.vue';
-import { s__, sprintf } from '~/locale';
+import { GlTooltipDirective } from '@gitlab/ui';
import PipelinesTableRowComponent from './pipelines_table_row.vue';
+import PipelineStopModal from './pipeline_stop_modal.vue';
import eventHub from '../event_hub';
/**
@@ -12,7 +12,10 @@ import eventHub from '../event_hub';
export default {
components: {
PipelinesTableRowComponent,
- Modal,
+ PipelineStopModal,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
pipelines: {
@@ -36,30 +39,11 @@ export default {
data() {
return {
pipelineId: 0,
+ pipeline: {},
endpoint: '',
cancelingPipeline: null,
};
},
- computed: {
- modalTitle() {
- return sprintf(
- s__('Pipeline|Stop pipeline #%{pipelineId}?'),
- {
- pipelineId: `${this.pipelineId}`,
- },
- false,
- );
- },
- modalText() {
- return sprintf(
- s__('Pipeline|You’re about to stop pipeline %{pipelineId}.'),
- {
- pipelineId: `<strong>#${this.pipelineId}</strong>`,
- },
- false,
- );
- },
- },
created() {
eventHub.$on('openConfirmationModal', this.setModalData);
},
@@ -68,7 +52,8 @@ export default {
},
methods: {
setModalData(data) {
- this.pipelineId = data.pipelineId;
+ this.pipelineId = data.pipeline.id;
+ this.pipeline = data.pipeline;
this.endpoint = data.endpoint;
},
onSubmit() {
@@ -81,16 +66,19 @@ export default {
<template>
<div class="ci-table">
<div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status pipeline-status" role="rowheader">
+ <div class="table-section section-10 js-pipeline-status" role="rowheader">
{{ s__('Pipeline|Status') }}
</div>
- <div class="table-section section-15 js-pipeline-info pipeline-info" role="rowheader">
+ <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
{{ s__('Pipeline|Pipeline') }}
</div>
+ <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
+ {{ s__('Pipeline|Triggerer') }}
+ </div>
<div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
{{ s__('Pipeline|Commit') }}
</div>
- <div class="table-section section-20 js-pipeline-stages pipeline-stages" role="rowheader">
+ <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
{{ s__('Pipeline|Stages') }}
</div>
</div>
@@ -103,15 +91,6 @@ export default {
:view-type="viewType"
:canceling-pipeline="cancelingPipeline"
/>
-
- <modal
- id="confirmation-modal"
- :header-title-text="modalTitle"
- :footer-primary-button-text="s__('Pipeline|Stop pipeline')"
- footer-primary-button-variant="danger"
- @submit="onSubmit"
- >
- <span v-html="modalText"></span>
- </modal>
+ <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" />
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
index da42698c255..e32e2f785bd 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue
@@ -5,6 +5,7 @@ import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
import CiBadge from '../../vue_shared/components/ci_badge_link.vue';
import PipelineStage from './stage.vue';
import PipelineUrl from './pipeline_url.vue';
+import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelinesTimeago from './time_ago.vue';
import CommitComponent from '../../vue_shared/components/commit.vue';
import LoadingButton from '../../vue_shared/components/loading_button.vue';
@@ -23,6 +24,7 @@ export default {
CommitComponent,
PipelineStage,
PipelineUrl,
+ PipelineTriggerer,
CiBadge,
PipelinesTimeago,
LoadingButton,
@@ -243,7 +245,7 @@ export default {
methods: {
handleCancelClick() {
eventHub.$emit('openConfirmationModal', {
- pipelineId: this.pipeline.id,
+ pipeline: this.pipeline,
endpoint: this.pipeline.cancel_path,
});
},
@@ -264,23 +266,25 @@ export default {
</div>
<pipeline-url :pipeline="pipeline" :auto-devops-help-path="autoDevopsHelpPath" />
+ <pipeline-triggerer :pipeline="pipeline" />
- <div class="table-section section-20">
+ <div class="table-section section-wrap section-20">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div>
<div class="table-mobile-content">
<commit-component
:tag="commitTag"
:commit-ref="commitRef"
:commit-url="commitUrl"
+ :merge-request-ref="pipeline.merge_request"
:short-sha="commitShortSha"
:title="commitTitle"
:author="commitAuthor"
- :show-branch="!isChildView"
+ :show-ref-info="!isChildView"
/>
</div>
</div>
- <div class="table-section section-wrap section-20 stage-cell">
+ <div class="table-section section-wrap section-15 stage-cell">
<div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
<div class="table-mobile-content">
<template v-if="pipeline.details.stages.length > 0">
diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue
index 2d3f667e73e..7426936515a 100644
--- a/app/assets/javascripts/pipelines/components/stage.vue
+++ b/app/assets/javascripts/pipelines/components/stage.vue
@@ -172,8 +172,6 @@ export default {
<span :aria-label="stage.title" aria-hidden="true" class="no-pointer-events">
<icon :name="borderlessIcon" />
</span>
-
- <i class="fa fa-caret-down" aria-hidden="true"> </i>
</button>
<div
diff --git a/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
new file mode 100644
index 00000000000..66e9476dadf
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/graph_component_mixin.js
@@ -0,0 +1,44 @@
+import _ from 'underscore';
+
+export default {
+ props: {
+ isLoading: {
+ type: Boolean,
+ required: true,
+ },
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ graph() {
+ return this.pipeline.details && this.pipeline.details.stages;
+ },
+ },
+ methods: {
+ capitalizeStageName(name) {
+ const escapedName = _.escape(name);
+ return escapedName.charAt(0).toUpperCase() + escapedName.slice(1);
+ },
+ isFirstColumn(index) {
+ return index === 0;
+ },
+ stageConnectorClass(index, stage) {
+ let className;
+
+ // If it's the first stage column and only has one job
+ if (index === 0 && stage.groups.length === 1) {
+ className = 'no-margin';
+ } else if (index > 0) {
+ // If it is not the first column
+ className = 'left-margin';
+ }
+
+ return className;
+ },
+ refreshPipelineGraph() {
+ this.$emit('refreshPipelineGraph');
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
new file mode 100644
index 00000000000..dd79ade5bc9
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js
@@ -0,0 +1,16 @@
+import Flash from '~/flash';
+import { __ } from '~/locale';
+
+export default {
+ methods: {
+ clickTriggeredByPipeline() {},
+ clickTriggeredPipeline() {},
+ requestRefreshPipelineGraph() {
+ // When an action is clicked
+ // (wether in the dropdown or in the main nodes, we refresh the big graph)
+ this.mediator
+ .refreshPipeline()
+ .catch(() => Flash(__('An error occurred while making the request.')));
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js
index 32bfa47e5f2..3cc9d0a3a4e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines.js
@@ -27,11 +27,7 @@ export default {
},
computed: {
shouldRenderPagination() {
- return (
- !this.isLoading &&
- this.state.pipelines.length &&
- this.state.pageInfo.total > this.state.pageInfo.perPage
- );
+ return !this.isLoading;
},
},
beforeMount() {
@@ -94,8 +90,7 @@ export default {
this.isLoading = false;
this.successCallback(response);
- // restart polling
- this.poll.restart({ data: this.requestData });
+ this.poll.enable({ data: this.requestData, response });
})
.catch(() => {
this.isLoading = false;
diff --git a/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
new file mode 100644
index 00000000000..64283ed0e58
--- /dev/null
+++ b/app/assets/javascripts/pipelines/mixins/stage_column_mixin.js
@@ -0,0 +1,7 @@
+export default {
+ methods: {
+ buildConnnectorClass(index) {
+ return index === 0 && !this.isFirstColumn ? 'left-connector' : '';
+ },
+ },
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index dc9befe6349..b8976f77bac 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -2,8 +2,9 @@ import Vue from 'vue';
import Flash from '~/flash';
import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
+import pipelineGraph from 'ee_else_ce/pipelines/components/graph/graph_component.vue';
+import GraphEEMixin from 'ee_else_ce/pipelines/mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
-import pipelineGraph from './components/graph/graph_component.vue';
import pipelineHeader from './components/header_component.vue';
import eventHub from './event_hub';
@@ -22,28 +23,25 @@ export default () => {
components: {
pipelineGraph,
},
+ mixins: [GraphEEMixin],
data() {
return {
mediator,
};
},
- methods: {
- requestRefreshPipelineGraph() {
- // When an action is clicked
- // (wether in the dropdown or in the main nodes, we refresh the big graph)
- this.mediator
- .refreshPipeline()
- .catch(() => Flash(__('An error occurred while making the request.')));
- },
- },
render(createElement) {
return createElement('pipeline-graph', {
props: {
isLoading: this.mediator.state.isLoading,
pipeline: this.mediator.store.state.pipeline,
+ mediator: this.mediator,
},
on: {
refreshPipelineGraph: this.requestRefreshPipelineGraph,
+ onClickTriggeredBy: (parentPipeline, pipeline) =>
+ this.clickTriggeredByPipeline(parentPipeline, pipeline),
+ onClickTriggered: (parentPipeline, pipeline) =>
+ this.clickTriggeredPipeline(parentPipeline, pipeline),
},
});
},
diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
index bd1e1895660..d67d88c4dba 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js
@@ -19,6 +19,7 @@ export default class pipelinesMediator {
this.poll = new Poll({
resource: this.service,
method: 'getPipeline',
+ data: this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined,
successCallback: this.successCallback.bind(this),
errorCallback: this.errorCallback.bind(this),
});
@@ -56,6 +57,19 @@ export default class pipelinesMediator {
.getPipeline()
.then(response => this.successCallback(response))
.catch(() => this.errorCallback())
- .finally(() => this.poll.restart());
+ .finally(() =>
+ this.poll.restart(
+ this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined,
+ ),
+ );
+ }
+
+ /**
+ * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id`
+ */
+ getExpandedParameters() {
+ return {
+ expanded: this.store.state.expandedPipelines,
+ };
}
}
diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js
index a53a9cc8365..e44eb9cdfd1 100644
--- a/app/assets/javascripts/pipelines/services/pipeline_service.js
+++ b/app/assets/javascripts/pipelines/services/pipeline_service.js
@@ -5,8 +5,8 @@ export default class PipelineService {
this.pipeline = endpoint;
}
- getPipeline() {
- return axios.get(this.pipeline);
+ getPipeline(params) {
+ return axios.get(this.pipeline, { params });
}
// eslint-disable-next-line class-methods-use-this
diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js
index 052e34a8aef..259278b6410 100644
--- a/app/assets/javascripts/pipelines/stores/pipeline_store.js
+++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js
@@ -1,7 +1,6 @@
export default class PipelineStore {
constructor() {
this.state = {};
-
this.state.pipeline = {};
}
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index deacff5abe7..6e3800021b4 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -2,6 +2,9 @@ import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import flash from '../flash';
import { parseBoolean } from '~/lib/utils/common_utils';
+import TimezoneDropdown, {
+ formatTimezone,
+} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
export default class Profile {
constructor({ form } = {}) {
@@ -10,6 +13,14 @@ export default class Profile {
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
+
+ this.$inputEl = $('#user_timezone');
+
+ this.timezoneDropdown = new TimezoneDropdown({
+ $inputEl: this.$inputEl,
+ $dropdownEl: $('.js-timezone-dropdown'),
+ displayFormat: selectedItem => formatTimezone(selectedItem),
+ });
}
initAvatarGlCrop() {
diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js
index d3c604dcee1..5395e14cc79 100644
--- a/app/assets/javascripts/project_label_subscription.js
+++ b/app/assets/javascripts/project_label_subscription.js
@@ -38,9 +38,9 @@ export default class ProjectLabelSubscription {
let newAction;
if (oldStatus === 'unsubscribed') {
- [newStatus, newAction] = ['subscribed', 'Unsubscribe'];
+ [newStatus, newAction] = ['subscribed', __('Unsubscribe')];
} else {
- [newStatus, newAction] = ['unsubscribed', 'Subscribe'];
+ [newStatus, newAction] = ['unsubscribed', __('Subscribe')];
}
$btn.removeClass('disabled');
diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js
index a33835472bb..dbe354a547b 100644
--- a/app/assets/javascripts/project_select.js
+++ b/app/assets/javascripts/project_select.js
@@ -3,99 +3,104 @@
import $ from 'jquery';
import Api from './api';
import ProjectSelectComboButton from './project_select_combo_button';
+import { s__ } from './locale';
export default function projectSelect() {
- $('.ajax-project-select').each(function(i, select) {
- var placeholder;
- const simpleFilter = $(select).data('simpleFilter') || false;
- this.groupId = $(select).data('groupId');
- this.includeGroups = $(select).data('includeGroups');
- this.allProjects = $(select).data('allProjects') || false;
- this.orderBy = $(select).data('orderBy') || 'id';
- this.withIssuesEnabled = $(select).data('withIssuesEnabled');
- this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
- this.withShared =
- $(select).data('withShared') === undefined ? true : $(select).data('withShared');
- this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
- this.allowClear = $(select).data('allowClear') || false;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('.ajax-project-select').each(function(i, select) {
+ var placeholder;
+ const simpleFilter = $(select).data('simpleFilter') || false;
+ this.groupId = $(select).data('groupId');
+ this.includeGroups = $(select).data('includeGroups');
+ this.allProjects = $(select).data('allProjects') || false;
+ this.orderBy = $(select).data('orderBy') || 'id';
+ this.withIssuesEnabled = $(select).data('withIssuesEnabled');
+ this.withMergeRequestsEnabled = $(select).data('withMergeRequestsEnabled');
+ this.withShared =
+ $(select).data('withShared') === undefined ? true : $(select).data('withShared');
+ this.includeProjectsInSubgroups = $(select).data('includeProjectsInSubgroups') || false;
+ this.allowClear = $(select).data('allowClear') || false;
- placeholder = 'Search for project';
- if (this.includeGroups) {
- placeholder += ' or group';
- }
+ placeholder = s__('ProjectSelect|Search for project');
+ if (this.includeGroups) {
+ placeholder += s__('ProjectSelect| or group');
+ }
- $(select).select2({
- placeholder: placeholder,
- minimumInputLength: 0,
- query: (function(_this) {
- return function(query) {
- var finalCallback, projectsCallback;
- finalCallback = function(projects) {
- var data;
- data = {
- results: projects,
- };
- return query.callback(data);
- };
- if (_this.includeGroups) {
- projectsCallback = function(projects) {
- var groupsCallback;
- groupsCallback = function(groups) {
+ $(select).select2({
+ placeholder: placeholder,
+ minimumInputLength: 0,
+ query: (function(_this) {
+ return function(query) {
+ var finalCallback, projectsCallback;
+ finalCallback = function(projects) {
var data;
- data = groups.concat(projects);
- return finalCallback(data);
+ data = {
+ results: projects,
+ };
+ return query.callback(data);
};
- return Api.groups(query.term, {}, groupsCallback);
+ if (_this.includeGroups) {
+ projectsCallback = function(projects) {
+ var groupsCallback;
+ groupsCallback = function(groups) {
+ var data;
+ data = groups.concat(projects);
+ return finalCallback(data);
+ };
+ return Api.groups(query.term, {}, groupsCallback);
+ };
+ } else {
+ projectsCallback = finalCallback;
+ }
+ if (_this.groupId) {
+ return Api.groupProjects(
+ _this.groupId,
+ query.term,
+ {
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ with_shared: _this.withShared,
+ include_subgroups: _this.includeProjectsInSubgroups,
+ },
+ projectsCallback,
+ );
+ } else {
+ return Api.projects(
+ query.term,
+ {
+ order_by: _this.orderBy,
+ with_issues_enabled: _this.withIssuesEnabled,
+ with_merge_requests_enabled: _this.withMergeRequestsEnabled,
+ membership: !_this.allProjects,
+ },
+ projectsCallback,
+ );
+ }
};
- } else {
- projectsCallback = finalCallback;
- }
- if (_this.groupId) {
- return Api.groupProjects(
- _this.groupId,
- query.term,
- {
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- with_shared: _this.withShared,
- include_subgroups: _this.includeProjectsInSubgroups,
- },
- projectsCallback,
- );
- } else {
- return Api.projects(
- query.term,
- {
- order_by: _this.orderBy,
- with_issues_enabled: _this.withIssuesEnabled,
- with_merge_requests_enabled: _this.withMergeRequestsEnabled,
- membership: !_this.allProjects,
- },
- projectsCallback,
- );
- }
- };
- })(this),
- id: function(project) {
- if (simpleFilter) return project.id;
- return JSON.stringify({
- name: project.name,
- url: project.web_url,
- });
- },
- text: function(project) {
- return project.name_with_namespace || project.name;
- },
+ })(this),
+ id: function(project) {
+ if (simpleFilter) return project.id;
+ return JSON.stringify({
+ name: project.name,
+ url: project.web_url,
+ });
+ },
+ text: function(project) {
+ return project.name_with_namespace || project.name;
+ },
- initSelection: function(el, callback) {
- return Api.project(el.val()).then(({ data }) => callback(data));
- },
+ initSelection: function(el, callback) {
+ return Api.project(el.val()).then(({ data }) => callback(data));
+ },
- allowClear: this.allowClear,
+ allowClear: this.allowClear,
- dropdownCssClass: 'ajax-project-dropdown',
- });
- if (simpleFilter) return select;
- return new ProjectSelectComboButton(select);
- });
+ dropdownCssClass: 'ajax-project-dropdown',
+ });
+ if (simpleFilter) return select;
+ return new ProjectSelectComboButton(select);
+ });
+ })
+ .catch(() => {});
}
diff --git a/app/assets/javascripts/project_select_combo_button.js b/app/assets/javascripts/project_select_combo_button.js
index 3dbac3ff942..d3b5f532dc1 100644
--- a/app/assets/javascripts/project_select_combo_button.js
+++ b/app/assets/javascripts/project_select_combo_button.js
@@ -44,9 +44,13 @@ export default class ProjectSelectComboButton {
// eslint-disable-next-line class-methods-use-this
openDropdown(event) {
- $(event.currentTarget)
- .siblings('.project-item-select')
- .select2('open');
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $(event.currentTarget)
+ .siblings('.project-item-select')
+ .select2('open');
+ })
+ .catch(() => {});
}
selectProject() {
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue
index 21095fcba16..83811ab489a 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue
@@ -108,9 +108,7 @@ export default {
</span>
</li>
<li v-for="result in results" :key="result.id">
- <button type="button" @click.prevent="setItem(result.name);">
- {{ result.name }}
- </button>
+ <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue
index 056584c8865..a2eb79af4f9 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue
@@ -169,7 +169,7 @@ export default {
</span>
</li>
<li v-for="result in results" :key="result.project_number">
- <button type="button" @click.prevent="setItem(result);">{{ result.name }}</button>
+ <button type="button" @click.prevent="setItem(result)">{{ result.name }}</button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
index 728616a441f..fd5d5f86401 100644
--- a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
+++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue
@@ -34,7 +34,7 @@ export default {
},
errorMessage() {
return sprintf(
- s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'),
+ s__('ClusterIntegration|An error occurred while trying to fetch project zones: %{error}'),
{ error: this.gapiError },
);
},
@@ -82,9 +82,7 @@ export default {
</span>
</li>
<li v-for="result in results" :key="result.id">
- <button type="button" @click.prevent="setItem(result.name);">
- {{ result.name }}
- </button>
+ <button type="button" @click.prevent="setItem(result.name)">{{ result.name }}</button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js
index 998554d1be5..ea82ff4e340 100644
--- a/app/assets/javascripts/projects/project_new.js
+++ b/app/assets/javascripts/projects/project_new.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils';
import { slugifyWithHyphens } from '../lib/utils/text_utility';
+import { s__ } from '~/locale';
let hasUserDefinedProjectPath = false;
@@ -114,16 +115,72 @@ const bindEvents = () => {
const value = $(this).val();
const templates = {
rails: {
- text: 'Ruby on Rails',
- icon: '.template-option svg.icon-rails',
+ text: s__('ProjectTemplates|Ruby on Rails'),
+ icon: '.template-option .icon-rails',
},
express: {
- text: 'NodeJS Express',
- icon: '.template-option svg.icon-node-express',
+ text: s__('ProjectTemplates|NodeJS Express'),
+ icon: '.template-option .icon-express',
},
spring: {
- text: 'Spring',
- icon: '.template-option svg.icon-java-spring',
+ text: s__('ProjectTemplates|Spring'),
+ icon: '.template-option .icon-spring',
+ },
+ iosswift: {
+ text: s__('ProjectTemplates|iOS (Swift)'),
+ icon: '.template-option svg.icon-gitlab',
+ },
+ dotnetcore: {
+ text: s__('ProjectTemplates|.NET Core'),
+ icon: '.template-option .icon-dotnet',
+ },
+ android: {
+ text: s__('ProjectTemplates|Android'),
+ icon: '.template-option svg.icon-android',
+ },
+ gomicro: {
+ text: s__('ProjectTemplates|Go Micro'),
+ icon: '.template-option .icon-gomicro',
+ },
+ hugo: {
+ text: s__('ProjectTemplates|Pages/Hugo'),
+ icon: '.template-option .icon-hugo',
+ },
+ jekyll: {
+ text: s__('ProjectTemplates|Pages/Jekyll'),
+ icon: '.template-option .icon-jekyll',
+ },
+ plainhtml: {
+ text: s__('ProjectTemplates|Pages/Plain HTML'),
+ icon: '.template-option .icon-plainhtml',
+ },
+ gitbook: {
+ text: s__('ProjectTemplates|Pages/GitBook'),
+ icon: '.template-option .icon-gitbook',
+ },
+ hexo: {
+ text: s__('ProjectTemplates|Pages/Hexo'),
+ icon: '.template-option .icon-hexo',
+ },
+ nfhugo: {
+ text: s__('ProjectTemplates|Netlify/Hugo'),
+ icon: '.template-option .icon-netlify',
+ },
+ nfjekyll: {
+ text: s__('ProjectTemplates|Netlify/Jekyll'),
+ icon: '.template-option .icon-netlify',
+ },
+ nfplainhtml: {
+ text: s__('ProjectTemplates|Netlify/Plain HTML'),
+ icon: '.template-option .icon-netlify',
+ },
+ nfgitbook: {
+ text: s__('ProjectTemplates|Netlify/GitBook'),
+ icon: '.template-option .icon-netlify',
+ },
+ nfhexo: {
+ text: s__('ProjectTemplates|Netlify/Hexo'),
+ icon: '.template-option .icon-netlify',
},
};
@@ -161,6 +218,12 @@ const bindEvents = () => {
$projectImportUrl.keyup(() => deriveProjectPathFromUrl($projectImportUrl));
+ $('.js-import-git-toggle-button').on('click', () => {
+ const $projectMirror = $('#project_mirror');
+
+ $projectMirror.attr('disabled', !$projectMirror.attr('disabled'));
+ });
+
$projectName.on('keyup change', () => {
onProjectNameChange($projectName, $projectPath);
hasUserDefinedProjectPath = $projectPath.val().trim().length > 0;
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index 40a873833e1..41e295387ae 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class ProtectedBranchAccessDropdown {
constructor(options) {
this.options = options;
@@ -15,7 +17,7 @@ export default class ProtectedBranchAccessDropdown {
if ($el.is('.is-active')) {
return item.text;
}
- return 'Select';
+ return __('Select');
},
clicked(options) {
options.e.preventDefault();
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 48343c8ba0a..16ecd5523d6 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
+import { __ } from '~/locale';
export default class ProtectedBranchCreate {
constructor() {
@@ -35,7 +36,7 @@ export default class ProtectedBranchCreate {
this.createItemDropdown = new CreateItemDropdown({
$dropdown: $protectedBranchDropdown,
- defaultToggleLabel: 'Protected Branch',
+ defaultToggleLabel: __('Protected Branch'),
fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches,
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 5bc08f60d16..08d8c9919dd 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,6 +1,7 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+import { __ } from '~/locale';
export default class ProtectedBranchEdit {
constructor(options) {
@@ -68,7 +69,7 @@ export default class ProtectedBranchEdit {
this.$allowedToPushDropdown.enable();
flash(
- 'Failed to update branch!',
+ __('Failed to update branch!'),
'alert',
document.querySelector('.js-protected-branches-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
index b803da798d5..def2f091947 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_access_dropdown.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class ProtectedTagAccessDropdown {
constructor(options) {
this.options = options;
@@ -15,7 +17,7 @@ export default class ProtectedTagAccessDropdown {
if ($el.is('.is-active')) {
return item.text;
}
- return 'Select';
+ return __('Select');
},
clicked(options) {
options.e.preventDefault();
diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js
index fddf2674cbb..03a5fe6b353 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_create.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_create.js
@@ -1,6 +1,7 @@
import $ from 'jquery';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
+import { __ } from '~/locale';
export default class ProtectedTagCreate {
constructor() {
@@ -27,7 +28,7 @@ export default class ProtectedTagCreate {
// Protected tag dropdown
this.createItemDropdown = new CreateItemDropdown({
$dropdown: this.$form.find('.js-protected-tag-select'),
- defaultToggleLabel: 'Protected Tag',
+ defaultToggleLabel: __('Protected Tag'),
fieldName: 'protected_tag[name]',
onSelect: this.onSelectCallback,
getData: ProtectedTagCreate.getProtectedTags,
diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js
index c52497e62f2..70bfd71abce 100644
--- a/app/assets/javascripts/protected_tags/protected_tag_edit.js
+++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js
@@ -1,6 +1,7 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedTagAccessDropdown from './protected_tag_access_dropdown';
+import { __ } from '~/locale';
export default class ProtectedTagEdit {
constructor(options) {
@@ -47,7 +48,11 @@ export default class ProtectedTagEdit {
.catch(() => {
this.$allowedToCreateDropdownButton.enable();
- flash('Failed to update tag!', 'alert', document.querySelector('.js-protected-tags-list'));
+ flash(
+ __('Failed to update tag!'),
+ 'alert',
+ document.querySelector('.js-protected-tags-list'),
+ );
});
}
}
diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/raven/index.js
index edc2293915f..4dd0175e528 100644
--- a/app/assets/javascripts/raven/index.js
+++ b/app/assets/javascripts/raven/index.js
@@ -4,8 +4,11 @@ const index = function index() {
RavenConfig.init({
sentryDsn: gon.sentry_dsn,
currentUserId: gon.current_user_id,
- whitelistUrls: [gon.gitlab_url],
- isProduction: process.env.NODE_ENV,
+ whitelistUrls:
+ process.env.NODE_ENV === 'production'
+ ? [gon.gitlab_url]
+ : [gon.gitlab_url, 'webpack-internal://'],
+ environment: gon.sentry_environment,
release: gon.revision,
tags: {
revision: gon.revision,
diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/raven/raven_config.js
index 338006ce2b9..7259e0df104 100644
--- a/app/assets/javascripts/raven/raven_config.js
+++ b/app/assets/javascripts/raven/raven_config.js
@@ -1,5 +1,6 @@
import Raven from 'raven-js';
import $ from 'jquery';
+import { __ } from '~/locale';
const IGNORE_ERRORS = [
// Random plugins/extensions
@@ -9,9 +10,9 @@ const IGNORE_ERRORS = [
'canvas.contentDocument',
'MyApp_RemoveAllHighlights',
'http://tt.epicplay.com',
- "Can't find variable: ZiteReader",
- 'jigsaw is not defined',
- 'ComboSearch is not defined',
+ __("Can't find variable: ZiteReader"),
+ __('jigsaw is not defined'),
+ __('ComboSearch is not defined'),
'http://loading.retry.widdit.com/',
'atomicFindClose',
// Facebook borked
@@ -61,7 +62,7 @@ const RavenConfig = {
release: this.options.release,
tags: this.options.tags,
whitelistUrls: this.options.whitelistUrls,
- environment: this.options.isProduction ? 'production' : 'development',
+ environment: this.options.environment,
ignoreErrors: this.IGNORE_ERRORS,
ignoreUrls: this.IGNORE_URLS,
shouldSendCallback: this.shouldSendSample.bind(this),
@@ -80,7 +81,7 @@ const RavenConfig = {
handleRavenErrors(event, req, config, err) {
const error = err || req.statusText;
- const responseText = req.responseText || 'Unknown response text';
+ const responseText = req.responseText || __('Unknown response text');
Raven.captureMessage(error, {
extra: {
diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue
index 78c7671856a..81fe0a48c06 100644
--- a/app/assets/javascripts/registry/components/table_registry.vue
+++ b/app/assets/javascripts/registry/components/table_registry.vue
@@ -70,7 +70,7 @@ export default {
</thead>
<tbody>
<tr v-for="item in repo.list" :key="item.tag">
- <td>
+ <td class="monospace">
{{ item.tag }}
<clipboard-button
v-if="item.location"
@@ -80,7 +80,9 @@ export default {
/>
</td>
<td>
- <span v-gl-tooltip.bottom :title="item.revision">{{ item.shortRevision }}</span>
+ <span v-gl-tooltip.bottom class="monospace" :title="item.revision">{{
+ item.shortRevision
+ }}</span>
</td>
<td>
{{ formatSize(item.size) }}
@@ -104,7 +106,7 @@ export default {
:aria-label="s__('ContainerRegistry|Remove tag')"
variant="danger"
class="js-delete-registry d-none d-sm-block float-right"
- @click="handleDeleteRegistry(item);"
+ @click="handleDeleteRegistry(item)"
>
<icon name="remove" />
</gl-button>
diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
new file mode 100644
index 00000000000..6d908524da9
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue
@@ -0,0 +1,118 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
+import { sprintf, n__, s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import { parseIssuableData } from '../../issue_show/utils/parse_data';
+
+export default {
+ name: 'RelatedMergeRequests',
+ components: {
+ Icon,
+ GlLoadingIcon,
+ RelatedIssuableItem,
+ },
+ props: {
+ endpoint: {
+ type: String,
+ required: true,
+ },
+ projectNamespace: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']),
+ closingMergeRequestsText() {
+ if (!this.hasClosingMergeRequest) {
+ return '';
+ }
+
+ const mrText = n__(
+ 'When this merge request is accepted',
+ 'When these merge requests are accepted',
+ this.totalCount,
+ );
+
+ return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText });
+ },
+ },
+ mounted() {
+ this.setInitialState({ apiEndpoint: this.endpoint });
+ this.fetchMergeRequests();
+ },
+ created() {
+ this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest;
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'fetchMergeRequests']),
+ getAssignees(mr) {
+ if (mr.assignees) {
+ return mr.assignees;
+ }
+
+ return mr.assignee ? [mr.assignee] : [];
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)">
+ <div id="merge-requests" class="card-slim mt-3">
+ <div class="card-header">
+ <div class="card-title mt-0 mb-0 h5 merge-requests-title">
+ <span class="mr-1">
+ {{ __('Related merge requests') }}
+ </span>
+ <div v-if="totalCount" class="d-inline-flex lh-100 align-middle">
+ <div class="mr-count-badge">
+ <div class="mr-count-badge-count">
+ <svg class="s16 mr-1 text-secondary">
+ <icon name="merge-request" class="mr-1 text-secondary" />
+ </svg>
+ <span class="js-items-count">{{ totalCount }}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div>
+ <div v-if="isFetchingMergeRequests" class="qa-related-merge-requests-loading-icon">
+ <gl-loading-icon label="Fetching related merge requests" class="py-2" />
+ </div>
+ <ul v-else class="content-list related-items-list">
+ <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0">
+ <related-issuable-item
+ :id-key="mr.id"
+ :display-reference="mr.reference"
+ :title="mr.title"
+ :milestone="mr.milestone"
+ :assignees="getAssignees(mr)"
+ :created-at="mr.created_at"
+ :closed-at="mr.closed_at"
+ :merged-at="mr.merged_at"
+ :path="mr.web_url"
+ :state="mr.state"
+ :is-merge-request="true"
+ :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status"
+ path-id-separator="!"
+ />
+ </li>
+ </ul>
+ </div>
+ </div>
+ <div
+ v-if="hasClosingMergeRequest && !isFetchingMergeRequests"
+ class="issue-closed-by-widget second-block"
+ >
+ {{ closingMergeRequestsText }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js
new file mode 100644
index 00000000000..092ff1df00f
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import RelatedMergeRequests from './components/related_merge_requests.vue';
+import createStore from './store';
+
+export default function initRelatedMergeRequests() {
+ const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests');
+
+ if (relatedMergeRequestsElement) {
+ const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset;
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: relatedMergeRequestsElement,
+ components: {
+ RelatedMergeRequests,
+ },
+ store: createStore(),
+ render: createElement =>
+ createElement('related-merge-requests', {
+ props: { endpoint, projectNamespace, projectPath },
+ }),
+ });
+ }
+}
diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js
new file mode 100644
index 00000000000..69abeaaf7db
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/actions.js
@@ -0,0 +1,37 @@
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+import { normalizeHeaders } from '~/lib/utils/common_utils';
+import * as types from './mutation_types';
+
+const REQUEST_PAGE_COUNT = 100;
+
+export const setInitialState = ({ commit }, props) => {
+ commit(types.SET_INITIAL_STATE, props);
+};
+
+export const requestData = ({ commit }) => commit(types.REQUEST_DATA);
+
+export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data);
+
+export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR);
+
+export const fetchMergeRequests = ({ state, dispatch }) => {
+ dispatch('requestData');
+
+ return axios
+ .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`)
+ .then(res => {
+ const { headers, data } = res;
+ const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0;
+
+ dispatch('receiveDataSuccess', { data, total });
+ })
+ .catch(() => {
+ dispatch('receiveDataError');
+ createFlash(s__('Something went wrong while fetching related merge requests.'));
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js
new file mode 100644
index 00000000000..dcb70c22bcb
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import createState from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ state: createState(),
+ actions,
+ mutations,
+ });
diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
new file mode 100644
index 00000000000..31d4fe032e1
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js
@@ -0,0 +1,4 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+export const REQUEST_DATA = 'REQUEST_DATA';
+export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS';
+export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR';
diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js
new file mode 100644
index 00000000000..11ca28a5fb9
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/mutations.js
@@ -0,0 +1,19 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, { apiEndpoint }) {
+ state.apiEndpoint = apiEndpoint;
+ },
+ [types.REQUEST_DATA](state) {
+ state.isFetchingMergeRequests = true;
+ },
+ [types.RECEIVE_DATA_SUCCESS](state, { data, total }) {
+ state.isFetchingMergeRequests = false;
+ state.mergeRequests = data;
+ state.totalCount = total;
+ },
+ [types.RECEIVE_DATA_ERROR](state) {
+ state.isFetchingMergeRequests = false;
+ state.hasErrorFetchingMergeRequests = true;
+ },
+};
diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js
new file mode 100644
index 00000000000..bc3468a025b
--- /dev/null
+++ b/app/assets/javascripts/related_merge_requests/store/state.js
@@ -0,0 +1,7 @@
+export default () => ({
+ apiEndpoint: '',
+ isFetchingMergeRequests: false,
+ hasErrorFetchingMergeRequests: false,
+ mergeRequests: [],
+ totalCount: 0,
+});
diff --git a/app/assets/javascripts/releases/components/app.vue b/app/assets/javascripts/releases/components/app.vue
new file mode 100644
index 00000000000..5a06c4fec58
--- /dev/null
+++ b/app/assets/javascripts/releases/components/app.vue
@@ -0,0 +1,82 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui';
+import ReleaseBlock from './release_block.vue';
+
+export default {
+ name: 'ReleasesApp',
+ components: {
+ GlSkeletonLoading,
+ GlEmptyState,
+ ReleaseBlock,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ documentationLink: {
+ type: String,
+ required: true,
+ },
+ illustrationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['isLoading', 'releases', 'hasError']),
+ shouldRenderEmptyState() {
+ return !this.releases.length && !this.hasError && !this.isLoading;
+ },
+ shouldRenderSuccessState() {
+ return this.releases.length && !this.isLoading && !this.hasError;
+ },
+ },
+ created() {
+ this.fetchReleases(this.projectId);
+ },
+ methods: {
+ ...mapActions(['fetchReleases']),
+ },
+};
+</script>
+<template>
+ <div class="prepend-top-default">
+ <gl-skeleton-loading v-if="isLoading" class="js-loading" />
+
+ <gl-empty-state
+ v-else-if="shouldRenderEmptyState"
+ class="js-empty-state"
+ :title="__('Getting started with releases')"
+ :svg-path="illustrationPath"
+ :description="
+ __(
+ 'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.',
+ )
+ "
+ :primary-button-link="documentationLink"
+ :primary-button-text="__('Open Documentation')"
+ />
+
+ <div v-else-if="shouldRenderSuccessState" class="js-success-state">
+ <release-block
+ v-for="(release, index) in releases"
+ :key="release.tag_name"
+ :release="release"
+ :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
+ />
+ </div>
+ </div>
+</template>
+<style>
+.linked-card::after {
+ width: 1px;
+ content: ' ';
+ border: 1px solid #e5e5e5;
+ height: 17px;
+ top: 100%;
+ position: absolute;
+ left: 32px;
+}
+</style>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index bd65a225d8f..0958b9fa926 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -1,4 +1,5 @@
<script>
+import _ from 'underscore';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
@@ -17,52 +18,16 @@ export default {
},
mixins: [timeagoMixin],
props: {
- name: {
- type: String,
- required: true,
- },
- tag: {
- type: String,
- required: true,
- },
- commit: {
- type: Object,
- required: true,
- },
- description: {
- type: String,
- required: false,
- default: '',
- },
- author: {
+ release: {
type: Object,
required: true,
- },
- createdAt: {
- type: String,
- required: false,
- default: '',
- },
- assetsCount: {
- type: Number,
- required: false,
- default: 0,
- },
- sources: {
- type: Array,
- required: false,
- default: () => [],
- },
- links: {
- type: Array,
- required: false,
- default: () => [],
+ default: () => ({}),
},
},
computed: {
releasedTimeAgo() {
return sprintf('released %{time}', {
- time: this.timeFormated(this.createdAt),
+ time: this.timeFormated(this.release.created_at),
});
},
userImageAltDescription() {
@@ -70,13 +35,25 @@ export default {
? sprintf("%{username}'s avatar", { username: this.author.username })
: null;
},
+ commit() {
+ return this.release.commit || {};
+ },
+ assets() {
+ return this.release.assets || {};
+ },
+ author() {
+ return this.release.author || {};
+ },
+ hasAuthor() {
+ return !_.isEmpty(this.author);
+ },
},
};
</script>
<template>
<div class="card">
<div class="card-body">
- <h2 class="card-title mt-0">{{ name }}</h2>
+ <h2 class="card-title mt-0">{{ release.name }}</h2>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
@@ -86,15 +63,17 @@ export default {
<div class="append-right-8">
<icon name="tag" class="align-middle" />
- <span v-gl-tooltip.bottom :title="__('Tag')">{{ tag }}</span>
+ <span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
</div>
<div class="append-right-4">
&bull;
- <span v-gl-tooltip.bottom :title="tooltipTitle(createdAt)">{{ releasedTimeAgo }}</span>
+ <span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">{{
+ releasedTimeAgo
+ }}</span>
</div>
- <div class="d-flex">
+ <div v-if="hasAuthor" class="d-flex">
by
<user-avatar-link
class="prepend-left-4"
@@ -106,20 +85,25 @@ export default {
</div>
</div>
- <div class="card-text prepend-top-default">
+ <div
+ v-if="assets.links.length || (assets.sources && assets.sources.length)"
+ class="card-text prepend-top-default"
+ >
<b>
- {{ __('Assets') }} <span class="js-assets-count badge badge-pill">{{ assetsCount }}</span>
+ {{ __('Assets') }}
+ <span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
</b>
- <ul class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
- <li v-for="link in links" :key="link.name" class="append-bottom-8">
+ <ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
+ <li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
- <icon name="package" class="align-middle append-right-4" /> {{ link.name }}
+ <icon name="package" class="align-middle append-right-4 align-text-bottom" />
+ {{ link.name }} <span v-if="link.external"> {{ __('(external source)') }}</span>
</gl-link>
</li>
</ul>
- <div class="dropdown">
+ <div v-if="assets.sources && assets.sources.length" class="dropdown">
<button
type="button"
class="btn btn-link"
@@ -132,14 +116,14 @@ export default {
</button>
<div class="js-sources-dropdown dropdown-menu">
- <li v-for="asset in sources" :key="asset.url">
+ <li v-for="asset in assets.sources" :key="asset.url">
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
</li>
</div>
</div>
</div>
- <div class="card-text prepend-top-default"><div v-html="description"></div></div>
+ <div class="card-text prepend-top-default"><div v-html="release.description_html"></div></div>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/releases/index.js b/app/assets/javascripts/releases/index.js
new file mode 100644
index 00000000000..adbed3cb8e2
--- /dev/null
+++ b/app/assets/javascripts/releases/index.js
@@ -0,0 +1,24 @@
+import Vue from 'vue';
+import App from './components/app.vue';
+import createStore from './store';
+
+export default () => {
+ const element = document.getElementById('js-releases-page');
+
+ return new Vue({
+ el: element,
+ store: createStore(),
+ components: {
+ App,
+ },
+ render(createElement) {
+ return createElement('app', {
+ props: {
+ projectId: element.dataset.projectId,
+ documentationLink: element.dataset.documentationPath,
+ illustrationPath: element.dataset.illustrationPath,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/releases/store/actions.js b/app/assets/javascripts/releases/store/actions.js
new file mode 100644
index 00000000000..e0a922d5ef6
--- /dev/null
+++ b/app/assets/javascripts/releases/store/actions.js
@@ -0,0 +1,37 @@
+import * as types from './mutation_types';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import api from '~/api';
+
+/**
+ * Commits a mutation to update the state while the main endpoint is being requested.
+ */
+export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
+
+/**
+ * Fetches the main endpoint.
+ * Will dispatch requestNamespace action before starting the request.
+ * Will dispatch receiveNamespaceSuccess if the request is successful
+ * Will dispatch receiveNamesapceError if the request returns an error
+ *
+ * @param {String} projectId
+ */
+export const fetchReleases = ({ dispatch }, projectId) => {
+ dispatch('requestReleases');
+
+ api
+ .releases(projectId)
+ .then(({ data }) => dispatch('receiveReleasesSuccess', data))
+ .catch(() => dispatch('receiveReleasesError'));
+};
+
+export const receiveReleasesSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_RELEASES_SUCCESS, data);
+
+export const receiveReleasesError = ({ commit }) => {
+ commit(types.RECEIVE_RELEASES_ERROR);
+ createFlash(__('An error occurred while fetching the releases. Please try again.'));
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/releases/store/index.js b/app/assets/javascripts/releases/store/index.js
new file mode 100644
index 00000000000..968b94f0e0d
--- /dev/null
+++ b/app/assets/javascripts/releases/store/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import state from './state';
+import * as actions from './actions';
+import mutations from './mutations';
+
+Vue.use(Vuex);
+
+export default () =>
+ new Vuex.Store({
+ actions,
+ mutations,
+ state: state(),
+ });
diff --git a/app/assets/javascripts/releases/store/mutation_types.js b/app/assets/javascripts/releases/store/mutation_types.js
new file mode 100644
index 00000000000..a74bf15c515
--- /dev/null
+++ b/app/assets/javascripts/releases/store/mutation_types.js
@@ -0,0 +1,3 @@
+export const REQUEST_RELEASES = 'REQUEST_RELEASES';
+export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
+export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
diff --git a/app/assets/javascripts/releases/store/mutations.js b/app/assets/javascripts/releases/store/mutations.js
new file mode 100644
index 00000000000..b97dc6cb0ab
--- /dev/null
+++ b/app/assets/javascripts/releases/store/mutations.js
@@ -0,0 +1,37 @@
+import * as types from './mutation_types';
+
+export default {
+ /**
+ * Sets isLoading to true while the request is being made.
+ * @param {Object} state
+ */
+ [types.REQUEST_RELEASES](state) {
+ state.isLoading = true;
+ },
+
+ /**
+ * Sets isLoading to false.
+ * Sets hasError to false.
+ * Sets the received data
+ * @param {Object} state
+ * @param {Object} data
+ */
+ [types.RECEIVE_RELEASES_SUCCESS](state, data) {
+ state.hasError = false;
+ state.isLoading = false;
+ state.releases = data;
+ },
+
+ /**
+ * Sets isLoading to false.
+ * Sets hasError to true.
+ * Resets the data
+ * @param {Object} state
+ * @param {Object} data
+ */
+ [types.RECEIVE_RELEASES_ERROR](state) {
+ state.isLoading = false;
+ state.releases = [];
+ state.hasError = true;
+ },
+};
diff --git a/app/assets/javascripts/releases/store/state.js b/app/assets/javascripts/releases/store/state.js
new file mode 100644
index 00000000000..bf25e651c99
--- /dev/null
+++ b/app/assets/javascripts/releases/store/state.js
@@ -0,0 +1,5 @@
+export default () => ({
+ isLoading: false,
+ hasError: false,
+ releases: [],
+});
diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue
index 2946fbc6a1f..04fba43b2f3 100644
--- a/app/assets/javascripts/reports/components/issue_status_icon.vue
+++ b/app/assets/javascripts/reports/components/issue_status_icon.vue
@@ -13,6 +13,11 @@ export default {
type: String,
required: true,
},
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 32,
+ },
},
computed: {
iconName() {
@@ -45,6 +50,6 @@ export default {
}"
class="report-block-list-icon"
>
- <icon :name="iconName" :size="32" />
+ <icon :name="iconName" :size="statusIconSize" />
</div>
</template>
diff --git a/app/assets/javascripts/reports/components/issues_list.vue b/app/assets/javascripts/reports/components/issues_list.vue
index f4243522ef8..50f2910e02d 100644
--- a/app/assets/javascripts/reports/components/issues_list.vue
+++ b/app/assets/javascripts/reports/components/issues_list.vue
@@ -52,6 +52,11 @@ export default {
required: false,
default: '',
},
+ showReportSectionStatus: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
issuesWithState() {
@@ -81,6 +86,7 @@ export default {
:status="wrapped.status"
:component="component"
:is-new="wrapped.isNew"
+ :show-report-section-status="showReportSectionStatus"
/>
</smart-virtual-list>
</template>
diff --git a/app/assets/javascripts/reports/components/modal_open_name.vue b/app/assets/javascripts/reports/components/modal_open_name.vue
index 118e4b02c46..4f81cee2a38 100644
--- a/app/assets/javascripts/reports/components/modal_open_name.vue
+++ b/app/assets/javascripts/reports/components/modal_open_name.vue
@@ -26,7 +26,7 @@ export default {
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
- @click="handleIssueClick();"
+ @click="handleIssueClick()"
>
{{ issue.title }}
</button>
diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue
index 839e86bdf17..01a30809e1a 100644
--- a/app/assets/javascripts/reports/components/report_item.vue
+++ b/app/assets/javascripts/reports/components/report_item.vue
@@ -24,17 +24,32 @@ export default {
type: String,
required: true,
},
+ statusIconSize: {
+ type: Number,
+ required: false,
+ default: 32,
+ },
isNew: {
type: Boolean,
required: false,
default: false,
},
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
};
</script>
<template>
<li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue">
- <issue-status-icon :status="status" class="append-right-5" />
+ <issue-status-icon
+ v-if="showReportSectionStatusIcon"
+ :status="status"
+ :status-icon-size="statusIconSize"
+ class="append-right-5"
+ />
<component :is="component" v-if="component" :issue="issue" :status="status" :is-new="isNew" />
</li>
diff --git a/app/assets/javascripts/reports/components/report_section.vue b/app/assets/javascripts/reports/components/report_section.vue
index d6483e95278..241185e3126 100644
--- a/app/assets/javascripts/reports/components/report_section.vue
+++ b/app/assets/javascripts/reports/components/report_section.vue
@@ -73,6 +73,11 @@ export default {
default: () => ({}),
required: false,
},
+ showReportSectionStatusIcon: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
@@ -151,7 +156,7 @@ export default {
<button
v-if="isCollapsible"
type="button"
- class="js-collapse-btn btn float-right btn-sm"
+ class="js-collapse-btn btn float-right btn-sm qa-expand-report-button"
@click="toggleCollapsed"
>
{{ collapseText }}
@@ -166,6 +171,7 @@ export default {
:resolved-issues="resolvedIssues"
:neutral-issues="neutralIssues"
:component="component"
+ :show-report-section-status-icon="showReportSectionStatusIcon"
/>
</slot>
</div>
diff --git a/app/assets/javascripts/reports/components/test_issue_body.vue b/app/assets/javascripts/reports/components/test_issue_body.vue
index 938e83de546..7700f49bf7d 100644
--- a/app/assets/javascripts/reports/components/test_issue_body.vue
+++ b/app/assets/javascripts/reports/components/test_issue_body.vue
@@ -30,7 +30,7 @@ export default {
<button
type="button"
class="btn-link btn-blank text-left break-link vulnerability-name-button"
- @click="openModal({ issue });"
+ @click="openModal({ issue })"
>
<div v-if="isNew" class="badge badge-danger append-right-5">{{ s__('New') }}</div>
{{ issue.name }}
diff --git a/app/assets/javascripts/reports/store/state.js b/app/assets/javascripts/reports/store/state.js
index 5484900276c..25f9f70d095 100644
--- a/app/assets/javascripts/reports/store/state.js
+++ b/app/assets/javascripts/reports/store/state.js
@@ -40,6 +40,11 @@ export default () => ({
text: s__('Reports|Class'),
type: fieldTypes.link,
},
+ classname: {
+ value: null,
+ text: s__('Reports|Classname'),
+ type: fieldTypes.text,
+ },
execution_time: {
value: null,
text: s__('Reports|Execution time'),
diff --git a/app/assets/javascripts/reports/store/utils.js b/app/assets/javascripts/reports/store/utils.js
index 35632218269..10560d0ae8e 100644
--- a/app/assets/javascripts/reports/store/utils.js
+++ b/app/assets/javascripts/reports/store/utils.js
@@ -1,4 +1,4 @@
-import { sprintf, n__, s__ } from '~/locale';
+import { sprintf, n__, s__, __ } from '~/locale';
import {
STATUS_FAILED,
STATUS_SUCCESS,
@@ -38,12 +38,12 @@ const textBuilder = results => {
export const summaryTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
- return `${name} contained ${resultsString}`;
+ return sprintf(__('%{name} contained %{resultsString}'), { name, resultsString });
};
export const reportTextBuilder = (name = '', results = {}) => {
const resultsString = textBuilder(results);
- return `${name} found ${resultsString}`;
+ return sprintf(__('%{name} found %{resultsString}'), { name, resultsString });
};
export const statusIcon = status => {
diff --git a/app/assets/javascripts/repository/components/app.vue b/app/assets/javascripts/repository/components/app.vue
new file mode 100644
index 00000000000..98240aef810
--- /dev/null
+++ b/app/assets/javascripts/repository/components/app.vue
@@ -0,0 +1,3 @@
+<template>
+ <router-view />
+</template>
diff --git a/app/assets/javascripts/repository/components/table/header.vue b/app/assets/javascripts/repository/components/table/header.vue
new file mode 100644
index 00000000000..9d30aa88155
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/header.vue
@@ -0,0 +1,9 @@
+<template>
+ <thead>
+ <tr>
+ <th id="name" scope="col">{{ s__('ProjectFileTree|Name') }}</th>
+ <th id="last-commit" scope="col" class="d-none d-sm-table-cell">{{ __('Last commit') }}</th>
+ <th id="last-update" scope="col" class="text-right">{{ __('Last update') }}</th>
+ </tr>
+ </thead>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue
new file mode 100644
index 00000000000..2b0a4644bf6
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/index.vue
@@ -0,0 +1,144 @@
+<script>
+import { GlLoadingIcon } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { sprintf, __ } from '../../../locale';
+import getRefMixin from '../../mixins/get_ref';
+import getFiles from '../../queries/getFiles.graphql';
+import getProjectPath from '../../queries/getProjectPath.graphql';
+import TableHeader from './header.vue';
+import TableRow from './row.vue';
+import ParentRow from './parent_row.vue';
+
+const PAGE_SIZE = 100;
+
+export default {
+ components: {
+ GlLoadingIcon,
+ TableHeader,
+ TableRow,
+ ParentRow,
+ },
+ mixins: [getRefMixin],
+ apollo: {
+ projectPath: {
+ query: getProjectPath,
+ },
+ },
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ nextPageCursor: '',
+ entries: {
+ trees: [],
+ submodules: [],
+ blobs: [],
+ },
+ isLoadingFiles: false,
+ };
+ },
+ computed: {
+ tableCaption() {
+ return sprintf(
+ __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'),
+ { path: this.path, ref: this.ref },
+ );
+ },
+ showParentRow() {
+ return !this.isLoadingFiles && this.path !== '';
+ },
+ },
+ watch: {
+ $route: function routeChange() {
+ this.entries.trees = [];
+ this.entries.submodules = [];
+ this.entries.blobs = [];
+ this.nextPageCursor = '';
+ this.fetchFiles();
+ },
+ },
+ mounted() {
+ // We need to wait for `ref` and `projectPath` to be set
+ this.$nextTick(() => this.fetchFiles());
+ },
+ methods: {
+ fetchFiles() {
+ this.isLoadingFiles = true;
+
+ return this.$apollo
+ .query({
+ query: getFiles,
+ variables: {
+ projectPath: this.projectPath,
+ ref: this.ref,
+ path: this.path,
+ nextPageCursor: this.nextPageCursor,
+ pageSize: PAGE_SIZE,
+ },
+ })
+ .then(({ data }) => {
+ if (!data) return;
+
+ const pageInfo = this.hasNextPage(data.project.repository.tree);
+
+ this.isLoadingFiles = false;
+ this.entries = Object.keys(this.entries).reduce(
+ (acc, key) => ({
+ ...acc,
+ [key]: this.normalizeData(key, data.project.repository.tree[key].edges),
+ }),
+ {},
+ );
+
+ if (pageInfo && pageInfo.hasNextPage) {
+ this.nextPageCursor = pageInfo.endCursor;
+ this.fetchFiles();
+ }
+ })
+ .catch(() => createFlash(__('An error occurding while fetching folder content.')));
+ },
+ normalizeData(key, data) {
+ return this.entries[key].concat(data.map(({ node }) => node));
+ },
+ hasNextPage(data) {
+ return []
+ .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
+ .find(({ hasNextPage }) => hasNextPage);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="tree-content-holder">
+ <div class="table-holder bordered-box">
+ <table class="table tree-table qa-file-tree" aria-live="polite">
+ <caption class="sr-only">
+ {{
+ tableCaption
+ }}
+ </caption>
+ <table-header v-once />
+ <tbody>
+ <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" />
+ <template v-for="val in entries">
+ <table-row
+ v-for="entry in val"
+ :id="entry.id"
+ :key="`${entry.flatPath}-${entry.id}`"
+ :current-path="path"
+ :path="entry.flatPath"
+ :type="entry.type"
+ />
+ </template>
+ </tbody>
+ </table>
+ <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/parent_row.vue b/app/assets/javascripts/repository/components/table/parent_row.vue
new file mode 100644
index 00000000000..b4433f00d8a
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/parent_row.vue
@@ -0,0 +1,37 @@
+<script>
+export default {
+ props: {
+ commitRef: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ parentRoute() {
+ const splitArray = this.path.split('/');
+ splitArray.pop();
+
+ return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` };
+ },
+ },
+ methods: {
+ clickRow() {
+ this.$router.push(this.parentRoute);
+ },
+ },
+};
+</script>
+
+<template>
+ <tr v-once @click="clickRow">
+ <td colspan="3" class="tree-item-file-name">
+ <router-link :to="parentRoute" :aria-label="__('Go to parent')">
+ ..
+ </router-link>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue
new file mode 100644
index 00000000000..9a264bef87e
--- /dev/null
+++ b/app/assets/javascripts/repository/components/table/row.vue
@@ -0,0 +1,72 @@
+<script>
+import { getIconName } from '../../utils/icon';
+import getRefMixin from '../../mixins/get_ref';
+
+export default {
+ mixins: [getRefMixin],
+ props: {
+ id: {
+ type: String,
+ required: true,
+ },
+ currentPath: {
+ type: String,
+ required: true,
+ },
+ path: {
+ type: String,
+ required: true,
+ },
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ routerLinkTo() {
+ return this.isFolder ? { path: `/tree/${this.ref}/${this.path}` } : null;
+ },
+ iconName() {
+ return `fa-${getIconName(this.type, this.path)}`;
+ },
+ isFolder() {
+ return this.type === 'tree';
+ },
+ isSubmodule() {
+ return this.type === 'commit';
+ },
+ linkComponent() {
+ return this.isFolder ? 'router-link' : 'a';
+ },
+ fullPath() {
+ return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
+ },
+ shortSha() {
+ return this.id.slice(0, 8);
+ },
+ },
+ methods: {
+ openRow() {
+ if (this.isFolder) {
+ this.$router.push(this.routerLinkTo);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow">
+ <td class="tree-item-file-name">
+ <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
+ <component :is="linkComponent" :to="routerLinkTo" class="str-truncated">
+ {{ fullPath }}
+ </component>
+ <template v-if="isSubmodule">
+ @ <a href="#" class="commit-sha">{{ shortSha }}</a>
+ </template>
+ </td>
+ <td class="d-none d-sm-table-cell tree-commit"></td>
+ <td class="tree-time-ago text-right"></td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/repository/fragmentTypes.json b/app/assets/javascripts/repository/fragmentTypes.json
new file mode 100644
index 00000000000..949ebca432b
--- /dev/null
+++ b/app/assets/javascripts/repository/fragmentTypes.json
@@ -0,0 +1 @@
+{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js
new file mode 100644
index 00000000000..c64d16ef02a
--- /dev/null
+++ b/app/assets/javascripts/repository/graphql.js
@@ -0,0 +1,43 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
+import createDefaultClient from '~/lib/graphql';
+import introspectionQueryResultData from './fragmentTypes.json';
+
+Vue.use(VueApollo);
+
+// We create a fragment matcher so that we can create a fragment from an interface
+// Without this, Apollo throws a heuristic fragment matcher warning
+const fragmentMatcher = new IntrospectionFragmentMatcher({
+ introspectionQueryResultData,
+});
+
+const defaultClient = createDefaultClient(
+ {},
+ {
+ cacheConfig: {
+ fragmentMatcher,
+ dataIdFromObject: obj => {
+ // eslint-disable-next-line no-underscore-dangle
+ switch (obj.__typename) {
+ // We need to create a dynamic ID for each entry
+ // Each entry can have the same ID as the ID is a commit ID
+ // So we create a unique cache ID with the path and the ID
+ case 'TreeEntry':
+ case 'Submodule':
+ case 'Blob':
+ return `${obj.flatPath}-${obj.id}`;
+ default:
+ // If the type doesn't match any of the above we fallback
+ // to using the default Apollo ID
+ // eslint-disable-next-line no-underscore-dangle
+ return obj.id || obj._id;
+ }
+ },
+ },
+ },
+);
+
+export default new VueApollo({
+ defaultClient,
+});
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
new file mode 100644
index 00000000000..a5c125c2ff7
--- /dev/null
+++ b/app/assets/javascripts/repository/index.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import createRouter from './router';
+import App from './components/app.vue';
+import apolloProvider from './graphql';
+import { setTitle } from './utils/title';
+
+export default function setupVueRepositoryList() {
+ const el = document.getElementById('js-tree-list');
+ const { projectPath, ref, fullName } = el.dataset;
+ const router = createRouter(projectPath, ref);
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ projectPath,
+ ref,
+ },
+ });
+
+ router.afterEach(({ params: { pathMatch } }) => setTitle(pathMatch, ref, fullName));
+
+ return new Vue({
+ el,
+ router,
+ apolloProvider,
+ render(h) {
+ return h(App);
+ },
+ });
+}
diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js
new file mode 100644
index 00000000000..b06087d6f42
--- /dev/null
+++ b/app/assets/javascripts/repository/mixins/get_ref.js
@@ -0,0 +1,14 @@
+import getRef from '../queries/getRef.graphql';
+
+export default {
+ apollo: {
+ ref: {
+ query: getRef,
+ },
+ },
+ data() {
+ return {
+ ref: '',
+ };
+ },
+};
diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue
new file mode 100644
index 00000000000..2d92e9174ca
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/index.vue
@@ -0,0 +1,18 @@
+<script>
+import FileTable from '../components/table/index.vue';
+
+export default {
+ components: {
+ FileTable,
+ },
+ data() {
+ return {
+ ref: '',
+ };
+ },
+};
+</script>
+
+<template>
+ <file-table path="/" />
+</template>
diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue
new file mode 100644
index 00000000000..3b898d1aa91
--- /dev/null
+++ b/app/assets/javascripts/repository/pages/tree.vue
@@ -0,0 +1,20 @@
+<script>
+import FileTable from '../components/table/index.vue';
+
+export default {
+ components: {
+ FileTable,
+ },
+ props: {
+ path: {
+ type: String,
+ required: false,
+ default: '/',
+ },
+ },
+};
+</script>
+
+<template>
+ <file-table :path="path" />
+</template>
diff --git a/app/assets/javascripts/repository/queries/getFiles.graphql b/app/assets/javascripts/repository/queries/getFiles.graphql
new file mode 100644
index 00000000000..a9b61d28560
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getFiles.graphql
@@ -0,0 +1,55 @@
+fragment TreeEntry on Entry {
+ id
+ flatPath
+ type
+}
+
+fragment PageInfo on PageInfo {
+ hasNextPage
+ endCursor
+}
+
+query getFiles(
+ $projectPath: ID!
+ $path: String
+ $ref: String!
+ $pageSize: Int!
+ $nextPageCursor: String
+) {
+ project(fullPath: $projectPath) {
+ repository {
+ tree(path: $path, ref: $ref) {
+ trees(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ submodules(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ blobs(first: $pageSize, after: $nextPageCursor) {
+ edges {
+ node {
+ ...TreeEntry
+ }
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/repository/queries/getProjectPath.graphql b/app/assets/javascripts/repository/queries/getProjectPath.graphql
new file mode 100644
index 00000000000..74e73e07577
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getProjectPath.graphql
@@ -0,0 +1,3 @@
+query getProjectPath {
+ projectPath
+}
diff --git a/app/assets/javascripts/repository/queries/getRef.graphql b/app/assets/javascripts/repository/queries/getRef.graphql
new file mode 100644
index 00000000000..58c09844c3f
--- /dev/null
+++ b/app/assets/javascripts/repository/queries/getRef.graphql
@@ -0,0 +1,3 @@
+query getRef {
+ ref @client
+}
diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js
new file mode 100644
index 00000000000..f7132b99d9e
--- /dev/null
+++ b/app/assets/javascripts/repository/router.js
@@ -0,0 +1,36 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { joinPaths } from '../lib/utils/url_utility';
+import IndexPage from './pages/index.vue';
+import TreePage from './pages/tree.vue';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base, baseRef) {
+ return new VueRouter({
+ mode: 'history',
+ base: joinPaths(gon.relative_url_root || '', base),
+ routes: [
+ {
+ path: `/tree/${baseRef}(/.*)?`,
+ name: 'treePath',
+ component: TreePage,
+ props: route => ({
+ path: route.params.pathMatch.replace(/^\//, ''),
+ }),
+ beforeEnter(to, from, next) {
+ document
+ .querySelectorAll('.js-hide-on-navigation')
+ .forEach(el => el.classList.add('hidden'));
+
+ next();
+ },
+ },
+ {
+ path: '/',
+ name: 'projectRoot',
+ component: IndexPage,
+ },
+ ],
+ });
+}
diff --git a/app/assets/javascripts/repository/utils/icon.js b/app/assets/javascripts/repository/utils/icon.js
new file mode 100644
index 00000000000..661ebb6edfc
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/icon.js
@@ -0,0 +1,99 @@
+const entryTypeIcons = {
+ tree: 'folder',
+ commit: 'archive',
+};
+
+const fileTypeIcons = [
+ { extensions: ['pdf'], name: 'file-pdf-o' },
+ {
+ extensions: [
+ 'jpg',
+ 'jpeg',
+ 'jif',
+ 'jfif',
+ 'jp2',
+ 'jpx',
+ 'j2k',
+ 'j2c',
+ 'png',
+ 'gif',
+ 'tif',
+ 'tiff',
+ 'svg',
+ 'ico',
+ 'bmp',
+ ],
+ name: 'file-image-o',
+ },
+ {
+ extensions: ['zip', 'zipx', 'tar', 'gz', 'bz', 'bzip', 'xz', 'rar', '7z'],
+ name: 'file-archive-o',
+ },
+ { extensions: ['mp3', 'wma', 'ogg', 'oga', 'wav', 'flac', 'aac'], name: 'file-audio-o' },
+ {
+ extensions: [
+ 'mp4',
+ 'm4p',
+ 'm4v',
+ 'mpg',
+ 'mp2',
+ 'mpeg',
+ 'mpe',
+ 'mpv',
+ 'm2v',
+ 'avi',
+ 'mkv',
+ 'flv',
+ 'ogv',
+ 'mov',
+ '3gp',
+ '3g2',
+ ],
+ name: 'file-video-o',
+ },
+ { extensions: ['doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'docb'], name: 'file-word-o' },
+ {
+ extensions: [
+ 'xls',
+ 'xlt',
+ 'xlm',
+ 'xlsx',
+ 'xlsm',
+ 'xltx',
+ 'xltm',
+ 'xlsb',
+ 'xla',
+ 'xlam',
+ 'xll',
+ 'xlw',
+ ],
+ name: 'file-excel-o',
+ },
+ {
+ extensions: [
+ 'ppt',
+ 'pot',
+ 'pps',
+ 'pptx',
+ 'pptm',
+ 'potx',
+ 'potm',
+ 'ppam',
+ 'ppsx',
+ 'ppsm',
+ 'sldx',
+ 'sldm',
+ ],
+ name: 'file-powerpoint-o',
+ },
+];
+
+// eslint-disable-next-line import/prefer-default-export
+export const getIconName = (type, path) => {
+ if (entryTypeIcons[type]) return entryTypeIcons[type];
+
+ const extension = path.split('.').pop();
+ const file = fileTypeIcons.find(t => t.extensions.some(ext => ext === extension));
+
+ return file ? file.name : 'file-text-o';
+};
diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js
new file mode 100644
index 00000000000..3c3e918c0a8
--- /dev/null
+++ b/app/assets/javascripts/repository/utils/title.js
@@ -0,0 +1,7 @@
+// eslint-disable-next-line import/prefer-default-export
+export const setTitle = (pathMatch, ref, project) => {
+ const path = pathMatch.replace(/^\//, '');
+ const isEmpty = path === '';
+
+ document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`;
+};
diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js
index 225e21ad322..72e061df573 100644
--- a/app/assets/javascripts/right_sidebar.js
+++ b/app/assets/javascripts/right_sidebar.js
@@ -5,7 +5,7 @@ import _ from 'underscore';
import Cookies from 'js-cookie';
import flash from './flash';
import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import { sprintf, s__, __ } from './locale';
function Sidebar(currentUser) {
this.toggleTodo = this.toggleTodo.bind(this);
@@ -79,11 +79,12 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) {
Sidebar.prototype.toggleTodo = function(e) {
var $btnText, $this, $todoLoading, ajaxType, url;
$this = $(e.currentTarget);
- ajaxType = $this.attr('data-delete-path') ? 'delete' : 'post';
- if ($this.attr('data-delete-path')) {
- url = '' + $this.attr('data-delete-path');
+ ajaxType = $this.data('deletePath') ? 'delete' : 'post';
+
+ if ($this.data('deletePath')) {
+ url = '' + $this.data('deletePath');
} else {
- url = '' + $this.data('url');
+ url = '' + $this.data('createPath');
}
$this.tooltip('hide');
@@ -100,7 +101,10 @@ Sidebar.prototype.toggleTodo = function(e) {
this.todoUpdateDone(data);
})
.catch(() =>
- flash(`There was an error ${ajaxType === 'post' ? 'adding a' : 'deleting the'} todo.`),
+ flash(sprintf(__('There was an error %{message} todo.')), {
+ message:
+ ajaxType === 'post' ? s__('RightSidebar|adding a') : s__('RightSidebar|deleting the'),
+ }),
);
};
@@ -119,14 +123,14 @@ Sidebar.prototype.todoUpdateDone = function(data) {
.removeClass('is-loading')
.enable()
.attr('aria-label', $el.data(`${attrPrefix}Text`))
- .attr('data-delete-path', deletePath)
- .attr('title', $el.data(`${attrPrefix}Text`));
+ .attr('title', $el.data(`${attrPrefix}Text`))
+ .data('deletePath', deletePath);
if ($el.hasClass('has-tooltip')) {
$el.tooltip('_fixTitle');
}
- if ($el.data(`${attrPrefix}Icon`)) {
+ if (typeof $el.data('isCollapsed') !== 'undefined') {
$elText.html($el.data(`${attrPrefix}Icon`));
} else {
$elText.text($el.data(`${attrPrefix}Text`));
diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js
index 0a4583b5861..ab43c2139bf 100644
--- a/app/assets/javascripts/search_autocomplete.js
+++ b/app/assets/javascripts/search_autocomplete.js
@@ -2,7 +2,7 @@
import $ from 'jquery';
import { escape, throttle } from 'underscore';
-import { s__, sprintf } from '~/locale';
+import { s__, __, sprintf } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import DropdownUtils from './filtered_search/dropdown_utils';
@@ -407,6 +407,7 @@ export class SearchAutocomplete {
if (this.searchInput.val() === '') {
return this.restoreOriginalState();
}
+ this.dropdownMenu.removeClass('show');
}
restoreOriginalState() {
@@ -439,7 +440,7 @@ export class SearchAutocomplete {
restoreMenu() {
var html;
- html = '<ul><li class="dropdown-menu-empty-item"><a>Loading...</a></li></ul>';
+ html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`;
return this.dropdownContent.html(html);
}
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue
new file mode 100644
index 00000000000..32c9d6eccb8
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/area.vue
@@ -0,0 +1,146 @@
+<script>
+import { GlAreaChart } from '@gitlab/ui/dist/charts';
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import dateFormat from 'dateformat';
+import { X_INTERVAL } from '../constants';
+import { validateGraphData } from '../utils';
+
+let debouncedResize;
+
+export default {
+ components: {
+ GlAreaChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: validateGraphData,
+ },
+ containerWidth: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tooltipPopoverTitle: '',
+ tooltipPopoverContent: '',
+ width: this.containerWidth,
+ };
+ },
+ computed: {
+ chartData() {
+ return this.graphData.queries.reduce((accumulator, query) => {
+ accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []);
+ return accumulator;
+ }, {});
+ },
+ extractTimeData() {
+ return this.chartData.requests.map(data => data.time);
+ },
+ generateSeries() {
+ return {
+ name: 'Invocations',
+ type: 'line',
+ data: this.chartData.requests.map(data => [data.time, data.value]),
+ symbolSize: 0,
+ };
+ },
+ getInterval() {
+ const { result } = this.graphData.queries[0];
+
+ if (result.length === 0) {
+ return 1;
+ }
+
+ const split = result[0].values.reduce(
+ (acc, pair) => (pair.value > acc ? pair.value : acc),
+ 1,
+ );
+
+ return split < X_INTERVAL ? split : X_INTERVAL;
+ },
+ chartOptions() {
+ return {
+ xAxis: {
+ name: 'time',
+ type: 'time',
+ axisLabel: {
+ formatter: date => dateFormat(date, 'h:MM TT'),
+ },
+ data: this.extractTimeData,
+ nameTextStyle: {
+ padding: [18, 0, 0, 0],
+ },
+ },
+ yAxis: {
+ name: this.yAxisLabel,
+ nameTextStyle: {
+ padding: [0, 0, 36, 0],
+ },
+ splitNumber: this.getInterval,
+ },
+ legend: {
+ formatter: this.xAxisLabel,
+ },
+ series: this.generateSeries,
+ };
+ },
+ xAxisLabel() {
+ return this.graphData.queries.map(query => query.label).join(', ');
+ },
+ yAxisLabel() {
+ const [query] = this.graphData.queries;
+ return `${this.graphData.y_label} (${query.unit})`;
+ },
+ },
+ watch: {
+ containerWidth: 'onResize',
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', debouncedResize);
+ },
+ created() {
+ debouncedResize = debounceByAnimationFrame(this.onResize);
+ window.addEventListener('resize', debouncedResize);
+ },
+ methods: {
+ formatTooltipText(params) {
+ const [seriesData] = params.seriesData;
+ this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT');
+ this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`;
+ },
+ onResize() {
+ const { width } = this.$refs.areaChart.$el.getBoundingClientRect();
+ this.width = width;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="prometheus-graph">
+ <div class="prometheus-graph-header">
+ <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5>
+ <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div>
+ </div>
+ <gl-area-chart
+ ref="areaChart"
+ v-bind="$attrs"
+ :data="[]"
+ :option="chartOptions"
+ :format-tooltip-text="formatTooltipText"
+ :width="width"
+ :include-legend-avg-max="false"
+ >
+ <template slot="tooltipTitle">
+ {{ tooltipPopoverTitle }}
+ </template>
+ <template slot="tooltipContent">
+ {{ tooltipPopoverContent }}
+ </template>
+ </gl-area-chart>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/environment_row.vue b/app/assets/javascripts/serverless/components/environment_row.vue
new file mode 100644
index 00000000000..4d18c5c4bdd
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/environment_row.vue
@@ -0,0 +1,65 @@
+<script>
+import FunctionRow from './function_row.vue';
+import ItemCaret from '~/groups/components/item_caret.vue';
+
+export default {
+ components: {
+ ItemCaret,
+ FunctionRow,
+ },
+ props: {
+ env: {
+ type: Array,
+ required: true,
+ },
+ envName: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isOpen: true,
+ };
+ },
+ computed: {
+ envId() {
+ if (this.envName === '*') {
+ return 'env-global';
+ }
+
+ return `env-${this.envName}`;
+ },
+ isOpenClass() {
+ return {
+ 'is-open': this.isOpen,
+ };
+ },
+ },
+ methods: {
+ toggleOpen() {
+ this.isOpen = !this.isOpen;
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="envId" :class="isOpenClass" class="group-row has-children">
+ <div
+ class="group-row-contents d-flex justify-content-end align-items-center"
+ role="button"
+ @click.stop="toggleOpen"
+ >
+ <div class="folder-toggle-wrap d-flex align-items-center">
+ <item-caret :is-group-open="isOpen" />
+ </div>
+ <div class="group-text flex-grow title namespace-title prepend-left-default">
+ {{ envName }}
+ </div>
+ </div>
+ <ul v-if="isOpen" class="content-list group-list-tree">
+ <function-row v-for="(f, index) in env" :key="f.name" :index="index" :func="f" />
+ </ul>
+ </li>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue
new file mode 100644
index 00000000000..b8906cfca4e
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/function_details.vue
@@ -0,0 +1,102 @@
+<script>
+import _ from 'underscore';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import PodBox from './pod_box.vue';
+import Url from './url.vue';
+import AreaChart from './area.vue';
+import MissingPrometheus from './missing_prometheus.vue';
+
+export default {
+ components: {
+ PodBox,
+ Url,
+ AreaChart,
+ MissingPrometheus,
+ },
+ props: {
+ func: {
+ type: Object,
+ required: true,
+ },
+ hasPrometheus: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ elWidth: 0,
+ };
+ },
+ computed: {
+ name() {
+ return this.func.name;
+ },
+ description() {
+ return _.isString(this.func.description) ? this.func.description : '';
+ },
+ funcUrl() {
+ return this.func.url;
+ },
+ podCount() {
+ return Number(this.func.podcount) || 0;
+ },
+ ...mapState(['graphData', 'hasPrometheusData']),
+ ...mapGetters(['hasPrometheusMissingData']),
+ },
+ created() {
+ this.fetchMetrics({
+ metricsPath: this.func.metricsUrl,
+ hasPrometheus: this.hasPrometheus,
+ });
+ },
+ mounted() {
+ this.elWidth = this.$el.clientWidth;
+ },
+ methods: {
+ ...mapActions(['fetchMetrics']),
+ },
+};
+</script>
+
+<template>
+ <section id="serverless-function-details">
+ <h3 class="serverless-function-name">{{ name }}</h3>
+ <div class="append-bottom-default serverless-function-description">
+ <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
+ </div>
+ <url :uri="funcUrl" />
+
+ <h4>{{ s__('ServerlessDetails|Kubernetes Pods') }}</h4>
+ <div v-if="podCount > 0">
+ <p>
+ <b v-if="podCount == 1">{{ podCount }} {{ s__('ServerlessDetails|pod in use') }}</b>
+ <b v-else>{{ podCount }} {{ s__('ServerlessDetails|pods in use') }}</b>
+ </p>
+ <pod-box :count="podCount" />
+ <p>
+ {{
+ s__('ServerlessDetails|Number of Kubernetes pods in use over time based on necessity.')
+ }}
+ </p>
+ </div>
+ <div v-else><p>No pods loaded at this time.</p></div>
+
+ <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" />
+ <missing-prometheus
+ v-if="!hasPrometheus || hasPrometheusMissingData"
+ :help-path="helpPath"
+ :clusters-path="clustersPath"
+ :missing-data="hasPrometheusMissingData"
+ />
+ </section>
+</template>
diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue
index 31f5427c771..4b3bb078eae 100644
--- a/app/assets/javascripts/serverless/components/function_row.vue
+++ b/app/assets/javascripts/serverless/components/function_row.vue
@@ -1,9 +1,13 @@
<script>
+import _ from 'underscore';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
+import Url from './url.vue';
+import { visitUrl } from '~/lib/utils/url_utility';
export default {
components: {
Timeago,
+ Url,
},
props: {
func: {
@@ -15,7 +19,22 @@ export default {
name() {
return this.func.name;
},
- url() {
+ description() {
+ if (!_.isString(this.func.description)) {
+ return '';
+ }
+
+ const desc = this.func.description.split('\n');
+ if (desc.length > 1) {
+ return desc[1];
+ }
+
+ return desc[0];
+ },
+ detailUrl() {
+ return this.func.detail_url;
+ },
+ targetUrl() {
return this.func.url;
},
image() {
@@ -25,16 +44,34 @@ export default {
return this.func.created_at;
},
},
+ methods: {
+ checkClass(element) {
+ if (element.closest('.no-expand') === null) {
+ return true;
+ }
+
+ return false;
+ },
+ openDetails(e) {
+ if (this.checkClass(e.target)) {
+ visitUrl(this.detailUrl);
+ }
+ },
+ },
};
</script>
<template>
- <div class="gl-responsive-table-row">
- <div class="table-section section-20">{{ name }}</div>
- <div class="table-section section-50">
- <a :href="url">{{ url }}</a>
+ <li :id="name" class="group-row">
+ <div class="group-row-contents" role="button" @click="openDetails">
+ <p class="float-right text-right">
+ <span>{{ image }}</span
+ ><br />
+ <timeago :time="timestamp" />
+ </p>
+ <b>{{ name }}</b>
+ <div v-for="line in description.split('\n')" :key="line">{{ line }}</div>
+ <url :uri="targetUrl" class="prepend-top-8 no-expand" />
</div>
- <div class="table-section section-20">{{ image }}</div>
- <div class="table-section section-10"><timeago :time="timestamp" /></div>
- </div>
+ </li>
</template>
diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue
index 7874a7b6b6a..f9b4e789563 100644
--- a/app/assets/javascripts/serverless/components/functions.vue
+++ b/app/assets/javascripts/serverless/components/functions.vue
@@ -1,20 +1,18 @@
<script>
-import { GlSkeletonLoading } from '@gitlab/ui';
+import { mapState, mapActions, mapGetters } from 'vuex';
+import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue';
+import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue';
export default {
components: {
+ EnvironmentRow,
FunctionRow,
EmptyState,
- GlSkeletonLoading,
+ GlLoadingIcon,
},
props: {
- functions: {
- type: Array,
- required: true,
- default: () => [],
- },
installed: {
type: Boolean,
required: true,
@@ -27,17 +25,23 @@ export default {
type: String,
required: true,
},
- loadingData: {
- type: Boolean,
- required: false,
- default: true,
- },
- hasFunctionData: {
- type: Boolean,
- required: false,
- default: true,
+ statusPath: {
+ type: String,
+ required: true,
},
},
+ computed: {
+ ...mapState(['isLoading', 'hasFunctionData']),
+ ...mapGetters(['getFunctions']),
+ },
+ created() {
+ this.fetchFunctions({
+ functionsPath: this.statusPath,
+ });
+ },
+ methods: {
+ ...mapActions(['fetchFunctions']),
+ },
};
</script>
@@ -45,30 +49,23 @@ export default {
<section id="serverless-functions">
<div v-if="installed">
<div v-if="hasFunctionData">
- <div class="ci-table js-services-list function-element">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Function') }}
- </div>
- <div class="table-section section-50" role="rowheader">
- {{ s__('Serverless|Domain') }}
- </div>
- <div class="table-section section-20" role="rowheader">
- {{ s__('Serverless|Runtime') }}
- </div>
- <div class="table-section section-10" role="rowheader">
- {{ s__('Serverless|Last Update') }}
- </div>
+ <gl-loading-icon
+ v-if="isLoading"
+ :size="2"
+ class="prepend-top-default append-bottom-default"
+ />
+ <template v-else>
+ <div class="groups-list-tree-container">
+ <ul class="content-list group-list-tree">
+ <environment-row
+ v-for="(env, index) in getFunctions"
+ :key="index"
+ :env="env"
+ :env-name="index"
+ />
+ </ul>
</div>
- <template v-if="loadingData">
- <div v-for="j in 3" :key="j" class="gl-responsive-table-row">
- <gl-skeleton-loading />
- </div>
- </template>
- <template v-else>
- <function-row v-for="f in functions" :key="f.name" :func="f" />
- </template>
- </div>
+ </template>
</div>
<div v-else class="empty-state js-empty-state">
<div class="text-content">
@@ -81,7 +78,7 @@ export default {
</p>
<ul>
<li>Your repository does not have a corresponding <code>serverless.yml</code> file.</li>
- <li>Your <code>gitlab-ci.yml</code> file is not properly configured.</li>
+ <li>Your <code>.gitlab-ci.yml</code> file is not properly configured.</li>
<li>
The functions listed in the <code>serverless.yml</code> file don't match the namespace
of your cluster.
@@ -108,16 +105,3 @@ export default {
<empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" />
</section>
</template>
-
-<style>
-.top-area {
- border-bottom: 0;
-}
-
-.function-element {
- border-bottom: 1px solid #e5e5e5;
- border-bottom-color: rgb(229, 229, 229);
- border-bottom-style: solid;
- border-bottom-width: 1px;
-}
-</style>
diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue
new file mode 100644
index 00000000000..6c19434f202
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue
@@ -0,0 +1,63 @@
+<script>
+import { GlButton, GlLink } from '@gitlab/ui';
+import { s__ } from '../../locale';
+
+export default {
+ components: {
+ GlButton,
+ GlLink,
+ },
+ props: {
+ clustersPath: {
+ type: String,
+ required: true,
+ },
+ helpPath: {
+ type: String,
+ required: true,
+ },
+ missingData: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ missingStateClass() {
+ return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state';
+ },
+ prometheusHelpPath() {
+ return `${this.helpPath}#prometheus-support`;
+ },
+ description() {
+ return this.missingData
+ ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`)
+ : s__(
+ `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`,
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="row" :class="missingStateClass">
+ <div class="col-12">
+ <div class="text-content">
+ <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4>
+ <p class="state-description">
+ {{ description }}
+ <gl-link :href="prometheusHelpPath">{{
+ s__(`ServerlessDetails|More information`)
+ }}</gl-link
+ >.
+ </p>
+
+ <div v-if="!missingData" class="text-left">
+ <gl-button :href="clustersPath" variant="success">
+ {{ s__('ServerlessDetails|Install Prometheus') }}
+ </gl-button>
+ </div>
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/components/pod_box.vue b/app/assets/javascripts/serverless/components/pod_box.vue
new file mode 100644
index 00000000000..04d3641bce3
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/pod_box.vue
@@ -0,0 +1,36 @@
+<script>
+export default {
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ color: {
+ type: String,
+ required: false,
+ default: 'green',
+ },
+ },
+ methods: {
+ boxOffset(i) {
+ return 20 * (i - 1);
+ },
+ },
+};
+</script>
+
+<template>
+ <svg :width="boxOffset(count + 1)" :height="20">
+ <rect
+ v-for="i in count"
+ :key="i"
+ width="15"
+ height="15"
+ rx="5"
+ ry="5"
+ :fill="color"
+ :x="boxOffset(i)"
+ y="0"
+ />
+ </svg>
+</template>
diff --git a/app/assets/javascripts/serverless/components/url.vue b/app/assets/javascripts/serverless/components/url.vue
new file mode 100644
index 00000000000..e47a03f1939
--- /dev/null
+++ b/app/assets/javascripts/serverless/components/url.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ ClipboardButton,
+ },
+ props: {
+ uri: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="clipboard-group">
+ <div class="url-text-field label label-monospace monospace">{{ uri }}</div>
+ <clipboard-button
+ :text="uri"
+ :title="s__('ServerlessURL|Copy URL to clipboard')"
+ class="input-group-text js-clipboard-btn"
+ />
+ <gl-button
+ :href="uri"
+ target="_blank"
+ rel="noopener noreferrer nofollow"
+ class="input-group-text btn btn-default"
+ >
+ <icon name="external-link" />
+ </gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js
new file mode 100644
index 00000000000..35f77205f2c
--- /dev/null
+++ b/app/assets/javascripts/serverless/constants.js
@@ -0,0 +1,3 @@
+export const MAX_REQUESTS = 3; // max number of times to retry
+
+export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis
diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js
index 3e3b81ba247..2d3f086ffee 100644
--- a/app/assets/javascripts/serverless/serverless_bundle.js
+++ b/app/assets/javascripts/serverless/serverless_bundle.js
@@ -1,106 +1,76 @@
-import Visibility from 'visibilityjs';
import Vue from 'vue';
-import { s__ } from '../locale';
-import Flash from '../flash';
-import Poll from '../lib/utils/poll';
-import ServerlessStore from './stores/serverless_store';
-import GetFunctionsService from './services/get_functions_service';
import Functions from './components/functions.vue';
+import FunctionDetails from './components/function_details.vue';
+import { createStore } from './store';
export default class Serverless {
constructor() {
- const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
- '.js-serverless-functions-page',
- ).dataset;
+ if (document.querySelector('.js-serverless-function-details-page') != null) {
+ const {
+ serviceName,
+ serviceDescription,
+ serviceEnvironment,
+ serviceUrl,
+ serviceNamespace,
+ servicePodcount,
+ serviceMetricsUrl,
+ prometheus,
+ clustersPath,
+ helpPath,
+ } = document.querySelector('.js-serverless-function-details-page').dataset;
+ const el = document.querySelector('#js-serverless-function-details');
- this.service = new GetFunctionsService(statusPath);
- this.knativeInstalled = installed !== undefined;
- this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath);
- this.initServerless();
- this.functionLoadCount = 0;
+ const service = {
+ name: serviceName,
+ description: serviceDescription,
+ environment: serviceEnvironment,
+ url: serviceUrl,
+ namespace: serviceNamespace,
+ podcount: servicePodcount,
+ metricsUrl: serviceMetricsUrl,
+ };
- if (statusPath && this.knativeInstalled) {
- this.initPolling();
- }
- }
-
- initServerless() {
- const { store } = this;
- const el = document.querySelector('#js-serverless-functions');
-
- this.functions = new Vue({
- el,
- data() {
- return {
- state: store.state,
- };
- },
- render(createElement) {
- return createElement(Functions, {
- props: {
- functions: this.state.functions,
- installed: this.state.installed,
- clustersPath: this.state.clustersPath,
- helpPath: this.state.helpPath,
- loadingData: this.state.loadingData,
- hasFunctionData: this.state.hasFunctionData,
- },
- });
- },
- });
- }
-
- initPolling() {
- this.poll = new Poll({
- resource: this.service,
- method: 'fetchData',
- successCallback: data => this.handleSuccess(data),
- errorCallback: () => this.handleError(),
- });
-
- if (!Visibility.hidden()) {
- this.poll.makeRequest();
+ this.functionDetails = new Vue({
+ el,
+ store: createStore(),
+ render(createElement) {
+ return createElement(FunctionDetails, {
+ props: {
+ func: service,
+ hasPrometheus: prometheus !== undefined,
+ clustersPath,
+ helpPath,
+ },
+ });
+ },
+ });
} else {
- this.service
- .fetchData()
- .then(data => this.handleSuccess(data))
- .catch(() => this.handleError());
- }
-
- Visibility.change(() => {
- if (!Visibility.hidden() && !this.destroyed) {
- this.poll.restart();
- } else {
- this.poll.stop();
- }
- });
- }
+ const { statusPath, clustersPath, helpPath, installed } = document.querySelector(
+ '.js-serverless-functions-page',
+ ).dataset;
- handleSuccess(data) {
- if (data.status === 200) {
- this.store.updateFunctionsFromServer(data.data);
- this.store.updateLoadingState(false);
- } else if (data.status === 204) {
- /* Time out after 3 attempts to retrieve data */
- this.functionLoadCount += 1;
- if (this.functionLoadCount === 3) {
- this.poll.stop();
- this.store.toggleNoFunctionData();
- }
+ const el = document.querySelector('#js-serverless-functions');
+ this.functions = new Vue({
+ el,
+ store: createStore(),
+ render(createElement) {
+ return createElement(Functions, {
+ props: {
+ installed: installed !== undefined,
+ clustersPath,
+ helpPath,
+ statusPath,
+ },
+ });
+ },
+ });
}
}
- static handleError() {
- Flash(s__('Serverless|An error occurred while retrieving serverless components'));
- }
-
destroy() {
this.destroyed = true;
- if (this.poll) {
- this.poll.stop();
- }
-
this.functions.$destroy();
+ this.functionDetails.$destroy();
}
}
diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js
deleted file mode 100644
index 303b42dc66c..00000000000
--- a/app/assets/javascripts/serverless/services/get_functions_service.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import axios from '~/lib/utils/axios_utils';
-
-export default class GetFunctionsService {
- constructor(endpoint) {
- this.endpoint = endpoint;
- }
-
- fetchData() {
- return axios.get(this.endpoint);
- }
-}
diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js
new file mode 100644
index 00000000000..826501c9022
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/actions.js
@@ -0,0 +1,113 @@
+import * as types from './mutation_types';
+import axios from '~/lib/utils/axios_utils';
+import statusCodes from '~/lib/utils/http_status';
+import { backOff } from '~/lib/utils/common_utils';
+import createFlash from '~/flash';
+import { MAX_REQUESTS } from '../constants';
+
+export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING);
+export const receiveFunctionsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_FUNCTIONS_SUCCESS, data);
+export const receiveFunctionsNoDataSuccess = ({ commit }) =>
+ commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS);
+export const receiveFunctionsError = ({ commit }, error) =>
+ commit(types.RECEIVE_FUNCTIONS_ERROR, error);
+
+export const receiveMetricsSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_SUCCESS, data);
+export const receiveMetricsNoPrometheus = ({ commit }) =>
+ commit(types.RECEIVE_METRICS_NO_PROMETHEUS);
+export const receiveMetricsNoDataSuccess = ({ commit }, data) =>
+ commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data);
+export const receiveMetricsError = ({ commit }, error) =>
+ commit(types.RECEIVE_METRICS_ERROR, error);
+
+export const fetchFunctions = ({ dispatch }, { functionsPath }) => {
+ let retryCount = 0;
+
+ dispatch('requestFunctionsLoading');
+
+ backOff((next, stop) => {
+ axios
+ .get(functionsPath)
+ .then(response => {
+ if (response.status === statusCodes.NO_CONTENT) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ next();
+ } else {
+ stop(null);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data !== null) {
+ dispatch('receiveFunctionsSuccess', data);
+ } else {
+ dispatch('receiveFunctionsNoDataSuccess');
+ }
+ })
+ .catch(error => {
+ dispatch('receiveFunctionsError', error);
+ createFlash(error);
+ });
+};
+
+export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => {
+ let retryCount = 0;
+
+ if (!hasPrometheus) {
+ dispatch('receiveMetricsNoPrometheus');
+ return;
+ }
+
+ backOff((next, stop) => {
+ axios
+ .get(metricsPath)
+ .then(response => {
+ if (response.status === statusCodes.NO_CONTENT) {
+ retryCount += 1;
+ if (retryCount < MAX_REQUESTS) {
+ next();
+ } else {
+ dispatch('receiveMetricsNoDataSuccess');
+ stop(null);
+ }
+ } else {
+ stop(response.data);
+ }
+ })
+ .catch(stop);
+ })
+ .then(data => {
+ if (data === null) {
+ return;
+ }
+
+ const updatedMetric = data.metrics;
+ const queries = data.metrics.queries.map(query => ({
+ ...query,
+ result: query.result.map(result => ({
+ ...result,
+ values: result.values.map(([timestamp, value]) => ({
+ time: new Date(timestamp * 1000).toISOString(),
+ value: Number(value),
+ })),
+ })),
+ }));
+
+ updatedMetric.queries = queries;
+ dispatch('receiveMetricsSuccess', updatedMetric);
+ })
+ .catch(error => {
+ dispatch('receiveMetricsError', error);
+ createFlash(error);
+ });
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js
new file mode 100644
index 00000000000..071f663d9d2
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/getters.js
@@ -0,0 +1,10 @@
+import { translate } from '../utils';
+
+export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData;
+
+// Convert the function list into a k/v grouping based on the environment scope
+
+export const getFunctions = state => translate(state.functions);
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js
new file mode 100644
index 00000000000..5f72060633e
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/index.js
@@ -0,0 +1,18 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import createState from './state';
+
+Vue.use(Vuex);
+
+export const createStore = () =>
+ new Vuex.Store({
+ actions,
+ getters,
+ mutations,
+ state: createState(),
+ });
+
+export default createStore();
diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js
new file mode 100644
index 00000000000..25b2f7ac38a
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutation_types.js
@@ -0,0 +1,9 @@
+export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING';
+export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS';
+export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS';
+export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR';
+
+export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS';
+export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS';
+export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS';
+export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR';
diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js
new file mode 100644
index 00000000000..991f32a275d
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/mutations.js
@@ -0,0 +1,38 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.REQUEST_FUNCTIONS_LOADING](state) {
+ state.isLoading = true;
+ },
+ [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) {
+ state.functions = data;
+ state.isLoading = false;
+ state.hasFunctionData = true;
+ },
+ [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) {
+ state.isLoading = false;
+ state.hasFunctionData = false;
+ },
+ [types.RECEIVE_FUNCTIONS_ERROR](state, error) {
+ state.error = error;
+ state.hasFunctionData = false;
+ state.isLoading = false;
+ },
+ [types.RECEIVE_METRICS_SUCCESS](state, data) {
+ state.isLoading = false;
+ state.hasPrometheusData = true;
+ state.graphData = data;
+ },
+ [types.RECEIVE_METRICS_NODATA_SUCCESS](state) {
+ state.isLoading = false;
+ state.hasPrometheusData = false;
+ },
+ [types.RECEIVE_METRICS_ERROR](state, error) {
+ state.hasPrometheusData = false;
+ state.error = error;
+ },
+ [types.RECEIVE_METRICS_NO_PROMETHEUS](state) {
+ state.hasPrometheusData = false;
+ state.hasPrometheus = false;
+ },
+};
diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js
new file mode 100644
index 00000000000..afc3f37d7ba
--- /dev/null
+++ b/app/assets/javascripts/serverless/store/state.js
@@ -0,0 +1,13 @@
+export default () => ({
+ error: null,
+ isLoading: true,
+
+ // functions
+ functions: [],
+ hasFunctionData: true,
+
+ // function_details
+ hasPrometheus: true,
+ hasPrometheusData: false,
+ graphData: {},
+});
diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js
deleted file mode 100644
index 774c15b5b12..00000000000
--- a/app/assets/javascripts/serverless/stores/serverless_store.js
+++ /dev/null
@@ -1,24 +0,0 @@
-export default class ServerlessStore {
- constructor(knativeInstalled = false, clustersPath, helpPath) {
- this.state = {
- functions: [],
- hasFunctionData: true,
- loadingData: true,
- installed: knativeInstalled,
- clustersPath,
- helpPath,
- };
- }
-
- updateFunctionsFromServer(functions = []) {
- this.state.functions = functions;
- }
-
- updateLoadingState(loadingData) {
- this.state.loadingData = loadingData;
- }
-
- toggleNoFunctionData() {
- this.state.hasFunctionData = false;
- }
-}
diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js
new file mode 100644
index 00000000000..8b9e96ce9aa
--- /dev/null
+++ b/app/assets/javascripts/serverless/utils.js
@@ -0,0 +1,23 @@
+// Validate that the object coming in has valid query details and results
+export const validateGraphData = data =>
+ data.queries &&
+ Array.isArray(data.queries) &&
+ data.queries.filter(query => {
+ if (Array.isArray(query.result)) {
+ return query.result.filter(res => Array.isArray(res.values)).length === query.result.length;
+ }
+
+ return false;
+ }).length === data.queries.length;
+
+export const translate = functions =>
+ functions.reduce(
+ (acc, func) =>
+ Object.assign(acc, {
+ [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]),
+ }),
+ {},
+ );
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js
index 14a89ef9293..3a8631a196f 100644
--- a/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js
+++ b/app/assets/javascripts/set_status_modal/emoji_menu_in_modal.js
@@ -12,9 +12,8 @@ class EmojiMenuInModal extends AwardsHandler {
this.bindEvents();
}
- postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
+ postEmoji($emojiButton, awardUrl, selectedEmoji) {
this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
- callback();
}
}
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index f04f7606976..35eba266625 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -2,7 +2,7 @@
import $ from 'jquery';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
-import GfmAutoComplete from '~/gfm_auto_complete';
+import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete';
import { __, s__ } from '~/locale';
import Api from '~/api';
import { GlModal, GlTooltipDirective } from '@gitlab/ui';
@@ -178,7 +178,7 @@ export default {
/>
<div ref="userStatusForm" class="form-group position-relative m-0">
<div class="input-group">
- <span class="input-group-btn">
+ <span class="input-group-prepend">
<button
ref="toggleEmojiMenuButton"
v-gl-tooltip.bottom
@@ -194,9 +194,9 @@ export default {
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
- <icon name="emoji_slightly_smiling_face" css-classes="award-control-icon-neutral" />
- <icon name="emoji_smiley" css-classes="award-control-icon-positive" />
- <icon name="emoji_smile" css-classes="award-control-icon-super-positive" />
+ <icon name="slight-smile" css-classes="award-control-icon-neutral" />
+ <icon name="smiley" css-classes="award-control-icon-positive" />
+ <icon name="smile" css-classes="award-control-icon-super-positive" />
</span>
</button>
</span>
@@ -211,7 +211,7 @@ export default {
@keyup.enter.prevent
@click="hideEmojiMenu"
/>
- <span v-show="isDirty" class="input-group-btn">
+ <span v-show="isDirty" class="input-group-append">
<button
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Clear status')"
@@ -219,7 +219,7 @@ export default {
name="button"
type="button"
class="js-clear-user-status-button clear-user-status btn"
- @click="clearStatusInputs();"
+ @click="clearStatusInputs()"
>
<icon name="close" />
</button>
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
index d1a396182b3..0074d7099dc 100644
--- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue
@@ -74,8 +74,7 @@ export default {
}
if (!this.users.length) {
- const emptyTooltipLabel =
- this.issuableType === 'issue' ? __('Assignee(s)') : __('Assignee');
+ const emptyTooltipLabel = __('Assignee(s)');
names.push(emptyTooltipLabel);
}
@@ -90,6 +89,27 @@ export default {
return counter;
},
+ mergeNotAllowedTooltipMessage() {
+ const assigneesCount = this.users.length;
+
+ if (this.issuableType !== 'merge_request' || assigneesCount === 0) {
+ return null;
+ }
+
+ const cannotMergeCount = this.users.filter(u => u.can_merge === false).length;
+ const canMergeCount = assigneesCount - cannotMergeCount;
+
+ if (canMergeCount === assigneesCount) {
+ // Everyone can merge
+ return null;
+ } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) {
+ return 'No one can merge';
+ } else if (assigneesCount === 1) {
+ return 'Cannot merge';
+ }
+
+ return `${canMergeCount}/${assigneesCount} can merge`;
+ },
},
methods: {
assignSelf() {
@@ -133,7 +153,7 @@ export default {
data-placement="left"
data-boundary="viewport"
>
- <i v-if="hasNoUsers" aria-label="No Assignee" class="fa fa-user"> </i>
+ <i v-if="hasNoUsers" aria-label="None" class="fa fa-user"> </i>
<button
v-for="(user, index) in users"
v-if="shouldRenderCollapsedAssignee(index)"
@@ -154,9 +174,18 @@ export default {
</button>
</div>
<div class="value hide-collapsed">
+ <span
+ v-if="mergeNotAllowedTooltipMessage"
+ v-tooltip
+ :title="mergeNotAllowedTooltipMessage"
+ data-placement="left"
+ class="float-right cannot-be-merged"
+ >
+ <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i>
+ </span>
<template v-if="hasNoUsers">
- <span class="assign-yourself no-value">
- No assignee
+ <span class="assign-yourself no-value qa-assign-yourself">
+ None
<template v-if="editable">
- <button type="button" class="btn-link" @click="assignSelf">assign yourself</button>
</template>
diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
index faea64c9841..c5cfa92f3c8 100644
--- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
+++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue
@@ -104,9 +104,7 @@ export default {
</div>
<div class="title hide-collapsed">
- {{
- sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName })
- }}
+ {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }}
<button
v-if="isEditable"
class="float-right lock-edit"
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
index d3a4f9c81e0..c03b2a68c78 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue
@@ -102,13 +102,13 @@ export default {
/>
<div class="title hide-collapsed">
{{ __('Time tracking') }}
- <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true);">
+ <div v-if="!showHelpState" class="help-button float-right" @click="toggleHelpState(true)">
<i class="fa fa-question-circle" aria-hidden="true"> </i>
</div>
<div
v-if="showHelpState"
class="close-help-button float-right"
- @click="toggleHelpState(false);"
+ @click="toggleHelpState(false)"
>
<i class="fa fa-close" aria-hidden="true"> </i>
</div>
diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
index 706e6ca19c3..57125c78cf6 100644
--- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
+++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue
@@ -50,6 +50,9 @@ export default {
buttonLabel() {
return this.isTodo ? MARK_TEXT : TODO_TEXT;
},
+ buttonTooltip() {
+ return !this.collapsed ? undefined : this.buttonLabel;
+ },
collapsedButtonIconClasses() {
return this.isTodo ? 'todo-undone' : '';
},
@@ -69,7 +72,7 @@ export default {
<button
v-tooltip
:class="buttonClasses"
- :title="buttonLabel"
+ :title="buttonTooltip"
:aria-label="buttonLabel"
:data-issuable-id="issuableId"
:data-issuable-type="issuableType"
diff --git a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
index 225ebb61195..110175a6779 100644
--- a/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
+++ b/app/assets/javascripts/sidebar/lib/sidebar_move_issue.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import _ from 'underscore';
+import { __ } from '~/locale';
function isValidProjectId(id) {
return id > 0;
@@ -40,7 +41,9 @@ class SidebarMoveIssue {
this.mediator
.fetchAutocompleteProjects(searchTerm)
.then(callback)
- .catch(() => new window.Flash('An error occurred while fetching projects autocomplete.'));
+ .catch(
+ () => new window.Flash(__('An error occurred while fetching projects autocomplete.')),
+ );
},
renderRow: project => `
<li>
@@ -72,7 +75,7 @@ class SidebarMoveIssue {
this.$confirmButton.disable().addClass('is-loading');
this.mediator.moveIssue().catch(() => {
- window.Flash('An error occurred while moving the issue.');
+ window.Flash(__('An error occurred while moving the issue.'));
this.$confirmButton.enable().removeClass('is-loading');
});
}
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
index 3e040ec8428..22ac8df9699 100644
--- a/app/assets/javascripts/sidebar/sidebar_mediator.js
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -2,6 +2,7 @@ import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import Service from './services/sidebar_service';
import Store from './stores/sidebar_store';
+import { __ } from '~/locale';
export default class SidebarMediator {
constructor(options) {
@@ -45,7 +46,7 @@ export default class SidebarMediator {
.then(data => {
this.processFetchedData(data);
})
- .catch(() => new Flash('Error occurred when fetching sidebar data'));
+ .catch(() => new Flash(__('Error occurred when fetching sidebar data')));
}
processFetchedData(data) {
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
index f20cc6d8cca..7b8b4c5d856 100644
--- a/app/assets/javascripts/sidebar/stores/sidebar_store.js
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -71,7 +71,7 @@ export default class SidebarStore {
}
findAssignee(findAssignee) {
- return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ return this.assignees.find(assignee => assignee.id === findAssignee.id);
}
removeAssignee(removeAssignee) {
diff --git a/app/assets/javascripts/snippet/snippet_embed.js b/app/assets/javascripts/snippet/snippet_embed.js
index 873a506a92f..fe08d2c7ebb 100644
--- a/app/assets/javascripts/snippet/snippet_embed.js
+++ b/app/assets/javascripts/snippet/snippet_embed.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default () => {
const { protocol, host, pathname } = window.location;
const shareBtn = document.querySelector('.js-share-btn');
@@ -10,7 +12,7 @@ export default () => {
shareBtn.classList.add('is-active');
embedBtn.classList.remove('is-active');
snippetUrlArea.value = url;
- embedAction.innerText = 'Share';
+ embedAction.innerText = __('Share');
});
embedBtn.addEventListener('click', () => {
@@ -18,6 +20,6 @@ export default () => {
shareBtn.classList.remove('is-active');
const scriptTag = `<script src="${url}.js"></script>`;
snippetUrlArea.value = scriptTag;
- embedAction.innerText = 'Embed';
+ embedAction.innerText = __('Embed');
});
};
diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js
index 7404dfbf22a..70f89152f70 100644
--- a/app/assets/javascripts/star.js
+++ b/app/assets/javascripts/star.js
@@ -31,7 +31,7 @@ export default class Star {
$this.prepend(spriteIcon('star', iconClasses));
}
})
- .catch(() => Flash('Star toggle failed. Try again later.'));
+ .catch(() => Flash(__('Star toggle failed. Try again later.')));
});
}
}
diff --git a/app/assets/javascripts/subscription_select.js b/app/assets/javascripts/subscription_select.js
index ebe1c6dd02d..7206bbd7109 100644
--- a/app/assets/javascripts/subscription_select.js
+++ b/app/assets/javascripts/subscription_select.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import { __ } from './locale';
export default function subscriptionSelect() {
$('.js-subscription-event').each((i, element) => {
@@ -8,7 +9,7 @@ export default function subscriptionSelect() {
selectable: true,
fieldName,
toggleLabel(selected, el, instance) {
- let label = 'Subscription';
+ let label = __('Subscription');
const $item = instance.dropdown.find('.is-active');
if ($item.length) {
label = $item.text();
diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js
index edefb3735d7..5172a1ef3d6 100644
--- a/app/assets/javascripts/task_list.js
+++ b/app/assets/javascripts/task_list.js
@@ -1,5 +1,6 @@
import $ from 'jquery';
import 'deckar01-task_list';
+import { __ } from '~/locale';
import axios from './lib/utils/axios_utils';
import Flash from './flash';
@@ -8,46 +9,79 @@ export default class TaskList {
this.selector = options.selector;
this.dataType = options.dataType;
this.fieldName = options.fieldName;
+ this.lockVersion = options.lockVersion;
+ this.taskListContainerSelector = `${this.selector} .js-task-list-container`;
+ this.updateHandler = this.update.bind(this);
this.onSuccess = options.onSuccess || (() => {});
- this.onError = function showFlash(e) {
- let errorMessages = '';
+ this.onError =
+ options.onError ||
+ function showFlash(e) {
+ let errorMessages = '';
- if (e.response.data && typeof e.response.data === 'object') {
- errorMessages = e.response.data.errors.join(' ');
- }
+ if (e.response.data && typeof e.response.data === 'object') {
+ errorMessages = e.response.data.errors.join(' ');
+ }
- return new Flash(errorMessages || 'Update failed', 'alert');
- };
+ return new Flash(errorMessages || __('Update failed'), 'alert');
+ };
this.init();
}
init() {
- // Prevent duplicate event bindings
- this.disable();
- $(`${this.selector} .js-task-list-container`).taskList('enable');
- $(document).on(
- 'tasklist:changed',
- `${this.selector} .js-task-list-container`,
- this.update.bind(this),
- );
+ this.disable(); // Prevent duplicate event bindings
+
+ $(this.taskListContainerSelector).taskList('enable');
+ $(document).on('tasklist:changed', this.taskListContainerSelector, this.updateHandler);
+ }
+
+ getTaskListTarget(e) {
+ return e && e.currentTarget ? $(e.currentTarget) : $(this.taskListContainerSelector);
+ }
+
+ disableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('disable');
+ }
+
+ enableTaskListItems(e) {
+ this.getTaskListTarget(e).taskList('enable');
}
disable() {
- $(`${this.selector} .js-task-list-container`).taskList('disable');
- $(document).off('tasklist:changed', `${this.selector} .js-task-list-container`);
+ this.disableTaskListItems();
+ $(document).off('tasklist:changed', this.taskListContainerSelector);
}
update(e) {
const $target = $(e.target);
+ const { index, checked, lineNumber, lineSource } = e.detail;
const patchData = {};
+
patchData[this.dataType] = {
[this.fieldName]: $target.val(),
+ lock_version: this.lockVersion,
+ update_task: {
+ index,
+ checked,
+ line_number: lineNumber,
+ line_source: lineSource,
+ },
};
+ this.disableTaskListItems(e);
+
return axios
.patch($target.data('updateUrl') || $('form.js-issuable-update').attr('action'), patchData)
- .then(({ data }) => this.onSuccess(data))
- .catch(err => this.onError(err));
+ .then(({ data }) => {
+ this.lockVersion = data.lock_version;
+ this.enableTaskListItems(e);
+
+ return this.onSuccess(data);
+ })
+ .catch(({ response }) => {
+ this.enableTaskListItems(e);
+
+ return this.onError(response.data);
+ });
}
}
diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js
index 6065770e68d..78609ce0610 100644
--- a/app/assets/javascripts/templates/issuable_template_selector.js
+++ b/app/assets/javascripts/templates/issuable_template_selector.js
@@ -3,6 +3,7 @@
import $ from 'jquery';
import Api from '../api';
import TemplateSelector from '../blob/template_selector';
+import { __ } from '~/locale';
export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
@@ -25,7 +26,7 @@ export default class IssuableTemplateSelector extends TemplateSelector {
$('.no-template', this.dropdown.parent()).on('click', () => {
this.currentTemplate.content = '';
this.setInputValueToTemplateContent();
- $('.dropdown-toggle-text', this.dropdown).text('Choose a template');
+ $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template'));
});
}
diff --git a/app/assets/javascripts/terminal/terminal.js b/app/assets/javascripts/terminal/terminal.js
index 560f50ebf8f..9c7c10d9864 100644
--- a/app/assets/javascripts/terminal/terminal.js
+++ b/app/assets/javascripts/terminal/terminal.js
@@ -2,11 +2,14 @@ import _ from 'underscore';
import $ from 'jquery';
import { Terminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';
+import * as webLinks from 'xterm/lib/addons/webLinks/webLinks';
import { canScrollUp, canScrollDown } from '~/lib/utils/dom_utils';
+import { __ } from '~/locale';
const SCROLL_MARGIN = 5;
Terminal.applyAddon(fit);
+Terminal.applyAddon(webLinks);
export default class GLTerminal {
constructor(element, options = {}) {
@@ -48,6 +51,7 @@ export default class GLTerminal {
this.terminal.open(this.container);
this.terminal.fit();
+ this.terminal.webLinksInit();
this.terminal.focus();
this.socket.onopen = () => {
@@ -75,7 +79,8 @@ export default class GLTerminal {
}
handleSocketFailure() {
- this.terminal.write('\r\nConnection failure');
+ this.terminal.write('\r\n');
+ this.terminal.write(__('Connection failure'));
}
addScrollListener(onScrollLimit) {
diff --git a/app/assets/javascripts/u2f/error.js b/app/assets/javascripts/u2f/error.js
index 1a98564ff55..ca0fc0700ad 100644
--- a/app/assets/javascripts/u2f/error.js
+++ b/app/assets/javascripts/u2f/error.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class U2FError {
constructor(errorCode, u2fFlowType) {
this.errorCode = errorCode;
@@ -8,15 +10,17 @@ export default class U2FError {
message() {
if (this.errorCode === window.u2f.ErrorCodes.BAD_REQUEST && this.httpsDisabled) {
- return 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.';
+ return __(
+ 'U2F only works with HTTPS-enabled websites. Contact your administrator for more details.',
+ );
} else if (this.errorCode === window.u2f.ErrorCodes.DEVICE_INELIGIBLE) {
if (this.u2fFlowType === 'authenticate') {
- return 'This device has not been registered with us.';
+ return __('This device has not been registered with us.');
}
if (this.u2fFlowType === 'register') {
- return 'This device has already been registered with us.';
+ return __('This device has already been registered with us.');
}
}
- return 'There was a problem communicating with your device.';
+ return __('There was a problem communicating with your device.');
}
}
diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js
index d3d745a3c11..1e7a5fb19c2 100644
--- a/app/assets/javascripts/usage_ping_consent.js
+++ b/app/assets/javascripts/usage_ping_consent.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import axios from './lib/utils/axios_utils';
import Flash, { hideFlash } from './flash';
import { parseBoolean } from './lib/utils/common_utils';
+import { __ } from './locale';
export default () => {
$('body').on('click', '.js-usage-consent-action', e => {
@@ -25,7 +26,7 @@ export default () => {
})
.catch(() => {
hideConsentMessage();
- Flash('Something went wrong. Try again later.');
+ Flash(__('Something went wrong. Try again later.'));
});
});
};
diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js
index ce051582299..7e6f02b10af 100644
--- a/app/assets/javascripts/users_select.js
+++ b/app/assets/javascripts/users_select.js
@@ -5,7 +5,7 @@
import $ from 'jquery';
import _ from 'underscore';
import axios from './lib/utils/axios_utils';
-import { __ } from './locale';
+import { s__, __, sprintf } from './locale';
import ModalStore from './boards/stores/modal_store';
// TODO: remove eventHub hack after code splitting refactor
@@ -93,23 +93,22 @@ function UsersSelect(currentUser, els, options = {}) {
}
// Save current selected user to the DOM
- const input = document.createElement('input');
- input.type = 'hidden';
- input.name = $dropdown.data('fieldName');
-
- const currentUserInfo = $dropdown.data('currentUserInfo');
-
- if (currentUserInfo) {
- input.value = currentUserInfo.id;
- input.dataset.meta = _.escape(currentUserInfo.name);
- } else if (_this.currentUser) {
- input.value = _this.currentUser.id;
- }
+ const currentUserInfo = $dropdown.data('currentUserInfo') || {};
+ const currentUser = _this.currentUser || {};
+ const fieldName = $dropdown.data('fieldName');
+ const userName = currentUserInfo.name;
+ const userId = currentUserInfo.id || currentUser.id;
+
+ const inputHtmlString = _.template(`
+ <input type="hidden" name="<%- fieldName %>"
+ data-meta="<%- userName %>"
+ value="<%- userId %>" />
+ `)({ fieldName, userName, userId });
if ($selectbox) {
- $dropdown.parent().before(input);
+ $dropdown.parent().before(inputHtmlString);
} else {
- $dropdown.after(input);
+ $dropdown.after(inputHtmlString);
}
};
@@ -158,14 +157,20 @@ function UsersSelect(currentUser, els, options = {}) {
.get(0);
if (selectedUsers.length === 0) {
- return 'Unassigned';
+ return s__('UsersSelect|Unassigned');
} else if (selectedUsers.length === 1) {
return firstUser.name;
} else if (isSelected) {
const otherSelected = selectedUsers.filter(s => s !== selectedUser.id);
- return `${selectedUser.name} + ${otherSelected.length} more`;
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: selectedUser.name,
+ length: otherSelected.length,
+ });
} else {
- return `${firstUser.name} + ${selectedUsers.length - 1} more`;
+ return sprintf(s__('UsersSelect|%{name} + %{length} more'), {
+ name: firstUser.name,
+ length: selectedUsers.length - 1,
+ });
}
};
@@ -219,11 +224,11 @@ function UsersSelect(currentUser, els, options = {}) {
tooltipTitle = _.escape(user.name);
} else {
user = {
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
username: '',
avatar: '',
};
- tooltipTitle = __('Assignee');
+ tooltipTitle = s__('UsersSelect|Assignee');
}
$value.html(assigneeTemplate(user));
$collapsedSidebar.attr('title', tooltipTitle).tooltip('_fixTitle');
@@ -234,7 +239,11 @@ function UsersSelect(currentUser, els, options = {}) {
'<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>',
);
assigneeTemplate = _.template(
- '<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> No assignee - <a href="#" class="js-assign-yourself"> assign yourself </a> </span> <% } %>',
+ `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself">
+ ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), {
+ openingTag: '<a href="#" class="js-assign-yourself">',
+ closingTag: '</a>',
+ })}</span> <% } %>`,
);
return $dropdown.glDropdown({
showMenuAbove: showMenuAbove,
@@ -303,7 +312,7 @@ function UsersSelect(currentUser, els, options = {}) {
showDivider += 1;
users.unshift({
beforeDivider: true,
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
id: 0,
});
}
@@ -311,7 +320,7 @@ function UsersSelect(currentUser, els, options = {}) {
showDivider += 1;
name = showAnyUser;
if (name === true) {
- name = 'Any User';
+ name = s__('UsersSelect|Any User');
}
anyUser = {
beforeDivider: true,
@@ -579,101 +588,109 @@ function UsersSelect(currentUser, els, options = {}) {
};
})(this),
);
- $('.ajax-users-select').each(
- (function(_this) {
- return function(i, select) {
- var firstUser, showAnyUser, showEmailUser, showNullUser;
- var options = {};
- options.skipLdap = $(select).hasClass('skip_ldap');
- options.projectId = $(select).data('projectId');
- options.groupId = $(select).data('groupId');
- options.showCurrentUser = $(select).data('currentUser');
- options.authorId = $(select).data('authorId');
- options.skipUsers = $(select).data('skipUsers');
- showNullUser = $(select).data('nullUser');
- showAnyUser = $(select).data('anyUser');
- showEmailUser = $(select).data('emailUser');
- firstUser = $(select).data('firstUser');
- return $(select).select2({
- placeholder: 'Search for a user',
- multiple: $(select).hasClass('multiselect'),
- minimumInputLength: 0,
- query: function(query) {
- return _this.users(query.term, options, function(users) {
- var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
- data = {
- results: users,
- };
- if (query.term.length === 0) {
- if (firstUser) {
- // Move current user to the front of the list
- ref = data.results;
-
- for (index = 0, len = ref.length; index < len; index += 1) {
- obj = ref[index];
- if (obj.username === firstUser) {
- data.results.splice(index, 1);
- data.results.unshift(obj);
- break;
+ import(/* webpackChunkName: 'select2' */ 'select2/select2')
+ .then(() => {
+ $('.ajax-users-select').each(
+ (function(_this) {
+ return function(i, select) {
+ var firstUser, showAnyUser, showEmailUser, showNullUser;
+ var options = {};
+ options.skipLdap = $(select).hasClass('skip_ldap');
+ options.projectId = $(select).data('projectId');
+ options.groupId = $(select).data('groupId');
+ options.showCurrentUser = $(select).data('currentUser');
+ options.authorId = $(select).data('authorId');
+ options.skipUsers = $(select).data('skipUsers');
+ showNullUser = $(select).data('nullUser');
+ showAnyUser = $(select).data('anyUser');
+ showEmailUser = $(select).data('emailUser');
+ firstUser = $(select).data('firstUser');
+ return $(select).select2({
+ placeholder: __('Search for a user'),
+ multiple: $(select).hasClass('multiselect'),
+ minimumInputLength: 0,
+ query: function(query) {
+ return _this.users(query.term, options, function(users) {
+ var anyUser, data, emailUser, index, len, name, nullUser, obj, ref;
+ data = {
+ results: users,
+ };
+ if (query.term.length === 0) {
+ if (firstUser) {
+ // Move current user to the front of the list
+ ref = data.results;
+
+ for (index = 0, len = ref.length; index < len; index += 1) {
+ obj = ref[index];
+ if (obj.username === firstUser) {
+ data.results.splice(index, 1);
+ data.results.unshift(obj);
+ break;
+ }
+ }
+ }
+ if (showNullUser) {
+ nullUser = {
+ name: s__('UsersSelect|Unassigned'),
+ id: 0,
+ };
+ data.results.unshift(nullUser);
+ }
+ if (showAnyUser) {
+ name = showAnyUser;
+ if (name === true) {
+ name = s__('UsersSelect|Any User');
+ }
+ anyUser = {
+ name: name,
+ id: null,
+ };
+ data.results.unshift(anyUser);
}
}
- }
- if (showNullUser) {
- nullUser = {
- name: 'Unassigned',
- id: 0,
- };
- data.results.unshift(nullUser);
- }
- if (showAnyUser) {
- name = showAnyUser;
- if (name === true) {
- name = 'Any User';
+ if (
+ showEmailUser &&
+ data.results.length === 0 &&
+ query.term.match(/^[^@]+@[^@]+$/)
+ ) {
+ var trimmed = query.term.trim();
+ emailUser = {
+ name: sprintf(__('Invite "%{trimmed}" by email'), { trimmed }),
+ username: trimmed,
+ id: trimmed,
+ invite: true,
+ };
+ data.results.unshift(emailUser);
}
- anyUser = {
- name: name,
- id: null,
- };
- data.results.unshift(anyUser);
- }
- }
- if (showEmailUser && data.results.length === 0 && query.term.match(/^[^@]+@[^@]+$/)) {
- var trimmed = query.term.trim();
- emailUser = {
- name: 'Invite "' + trimmed + '" by email',
- username: trimmed,
- id: trimmed,
- invite: true,
- };
- data.results.unshift(emailUser);
- }
- return query.callback(data);
+ return query.callback(data);
+ });
+ },
+ initSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.initSelection.apply(_this, args);
+ },
+ formatResult: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatResult.apply(_this, args);
+ },
+ formatSelection: function() {
+ var args;
+ args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
+ return _this.formatSelection.apply(_this, args);
+ },
+ dropdownCssClass: 'ajax-users-dropdown',
+ // we do not want to escape markup since we are displaying html in results
+ escapeMarkup: function(m) {
+ return m;
+ },
});
- },
- initSelection: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.initSelection.apply(_this, args);
- },
- formatResult: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatResult.apply(_this, args);
- },
- formatSelection: function() {
- var args;
- args = 1 <= arguments.length ? [].slice.call(arguments, 0) : [];
- return _this.formatSelection.apply(_this, args);
- },
- dropdownCssClass: 'ajax-users-dropdown',
- // we do not want to escape markup since we are displaying html in results
- escapeMarkup: function(m) {
- return m;
- },
- });
- };
- })(this),
- );
+ };
+ })(this),
+ );
+ })
+ .catch(() => {});
}
UsersSelect.prototype.initSelection = function(element, callback) {
@@ -681,7 +698,7 @@ UsersSelect.prototype.initSelection = function(element, callback) {
id = $(element).val();
if (id === '0') {
nullUser = {
- name: 'Unassigned',
+ name: s__('UsersSelect|Unassigned'),
};
return callback(nullUser);
} else if (id !== '') {
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
index 2f2a37347af..ad0464a3a98 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue
@@ -23,6 +23,8 @@ export default {
TooltipOnTruncate,
FilteredSearchDropdown,
ReviewAppLink,
+ VisualReviewAppLink: () =>
+ import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'),
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -37,6 +39,20 @@ export default {
type: Boolean,
required: true,
},
+ showVisualReviewApp: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ visualReviewAppMeta: {
+ type: Object,
+ required: false,
+ default: () => ({
+ sourceProjectId: '',
+ mergeRequestId: '',
+ appUrl: '',
+ }),
+ },
},
deployedTextMap: {
running: __('Deploying to'),
@@ -54,6 +70,12 @@ export default {
deployTimeago() {
return this.timeFormated(this.deployment.deployed_at);
},
+ deploymentExternalUrl() {
+ if (this.deployment.changes && this.deployment.changes.length === 1) {
+ return this.deployment.changes[0].external_url;
+ }
+ return this.deployment.external_url;
+ },
hasExternalUrls() {
return !!(this.deployment.external_url && this.deployment.external_url_formatted);
},
@@ -78,7 +100,7 @@ export default {
: '';
},
shouldRenderDropdown() {
- return this.deployment.changes && this.deployment.changes.length > 0;
+ return this.deployment.changes && this.deployment.changes.length > 1;
},
showMemoryUsage() {
return this.hasMetrics && this.showMetrics;
@@ -154,14 +176,19 @@ export default {
v-if="shouldRenderDropdown"
class="js-mr-wigdet-deployment-dropdown inline"
:items="deployment.changes"
- :main-action-link="deployment.external_url"
+ :main-action-link="deploymentExternalUrl"
filter-key="path"
>
<template slot="mainAction" slot-scope="slotProps">
<review-app-link
- :link="deployment.external_url"
+ :link="deploymentExternalUrl"
:css-class="`deploy-link js-deploy-url inline ${slotProps.className}`"
/>
+ <visual-review-app-link
+ v-if="showVisualReviewApp"
+ :link="deploymentExternalUrl"
+ :app-metadata="visualReviewAppMeta"
+ />
</template>
<template slot="result" slot-scope="slotProps">
@@ -181,11 +208,17 @@ export default {
</a>
</template>
</filtered-search-dropdown>
- <review-app-link
- v-else
- :link="deployment.external_url"
- css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inlin"
- />
+ <template v-else>
+ <review-app-link
+ :link="deploymentExternalUrl"
+ css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline"
+ />
+ <visual-review-app-link
+ v-if="showVisualReviewApp"
+ :link="deploymentExternalUrl"
+ :app-metadata="visualReviewAppMeta"
+ />
+ </template>
</template>
<span
v-if="deployment.stop_url"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue
new file mode 100644
index 00000000000..040315b3c66
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_alert_message.vue
@@ -0,0 +1,46 @@
+<script>
+import { GlLink } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import { WARNING, DANGER, WARNING_MESSAGE_CLASS, DANGER_MESSAGE_CLASS } from '../constants';
+
+export default {
+ name: 'MrWidgetAlertMessage',
+ components: {
+ GlLink,
+ Icon,
+ },
+ props: {
+ type: {
+ type: String,
+ required: false,
+ default: DANGER,
+ validator: value => [WARNING, DANGER].includes(value),
+ },
+ helpPath: {
+ type: String,
+ required: false,
+ default: undefined,
+ },
+ },
+ computed: {
+ messageClass() {
+ if (this.type === WARNING) {
+ return WARNING_MESSAGE_CLASS;
+ } else if (this.type === DANGER) {
+ return DANGER_MESSAGE_CLASS;
+ }
+
+ return '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="m-3 ml-5" :class="messageClass">
+ <slot></slot>
+ <gl-link v-if="helpPath" :href="helpPath" target="_blank">
+ <icon :size="16" name="question-o" class="align-middle" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
index 3b9fc2661ef..361441640e1 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue
@@ -109,33 +109,35 @@ export default {
></div>
</div>
- <div v-if="mr.isOpen" class="branch-actions d-flex">
- <a
- v-if="!mr.sourceBranchRemoved"
- v-tooltip
- :href="webIdePath"
- :title="ideButtonTitle"
- :class="{ disabled: !mr.canPushToSourceBranch }"
- class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8"
- data-placement="bottom"
- tabindex="0"
- role="button"
- >
- {{ s__('mrWidget|Open in Web IDE') }}
- </a>
- <button
- :disabled="mr.sourceBranchRemoved"
- data-target="#modal_merge_info"
- data-toggle="modal"
- class="btn btn-default js-check-out-branch append-right-default"
- type="button"
- >
- {{ s__('mrWidget|Check out branch') }}
- </button>
+ <div class="branch-actions d-flex">
+ <template v-if="mr.isOpen">
+ <a
+ v-if="!mr.sourceBranchRemoved"
+ v-tooltip
+ :href="webIdePath"
+ :title="ideButtonTitle"
+ :class="{ disabled: !mr.canPushToSourceBranch }"
+ class="btn btn-default js-web-ide d-none d-md-inline-block append-right-8"
+ data-placement="bottom"
+ tabindex="0"
+ role="button"
+ >
+ {{ s__('mrWidget|Open in Web IDE') }}
+ </a>
+ <button
+ :disabled="mr.sourceBranchRemoved"
+ data-target="#modal_merge_info"
+ data-toggle="modal"
+ class="btn btn-default js-check-out-branch append-right-default"
+ type="button"
+ >
+ {{ s__('mrWidget|Check out branch') }}
+ </button>
+ </template>
<span class="dropdown">
<button
type="button"
- class="btn dropdown-toggle"
+ class="btn dropdown-toggle qa-dropdown-toggle"
data-toggle="dropdown"
aria-label="Download as"
aria-haspopup="true"
@@ -145,12 +147,20 @@ export default {
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
- <a :href="mr.emailPatchesPath" class="js-download-email-patches" download>
+ <a
+ :href="mr.emailPatchesPath"
+ class="js-download-email-patches qa-download-email-patches"
+ download
+ >
{{ s__('mrWidget|Email patches') }}
</a>
</li>
<li>
- <a :href="mr.plainDiffPath" class="js-download-plain-diff" download>
+ <a
+ :href="mr.plainDiffPath"
+ class="js-download-plain-diff qa-download-plain-diff"
+ download
+ >
{{ s__('mrWidget|Plain diff') }}
</a>
</li>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index f11cf21b0ca..f5fa68308bc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -1,10 +1,12 @@
<script>
/* eslint-disable vue/require-default-prop */
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { sprintf, __ } from '~/locale';
import PipelineStage from '~/pipelines/components/stage.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
+import mrWidgetPipelineMixin from 'ee_else_ce/vue_merge_request_widget/mixins/mr_widget_pipeline';
export default {
name: 'MRWidgetPipeline',
@@ -13,7 +15,14 @@ export default {
CiIcon,
Icon,
TooltipOnTruncate,
+ GlLink,
+ LinkedPipelinesMiniList: () =>
+ import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'),
},
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [mrWidgetPipelineMixin],
props: {
pipeline: {
type: Object,
@@ -74,16 +83,21 @@ export default {
false,
);
},
+ isTriggeredByMergeRequest() {
+ return Boolean(this.pipeline.merge_request);
+ },
+ isMergeRequestPipeline() {
+ return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline);
+ },
},
};
</script>
<template>
- <div v-if="hasPipeline || hasCIError" class="ci-widget media">
- <template v-if="hasCIError">
+ <div class="ci-widget media js-ci-widget">
+ <template v-if="!hasPipeline || hasCIError">
<div
- class="add-border ci-status-icon ci-status-icon-failed ci-error
- js-ci-error append-right-default"
+ class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default"
>
<icon :size="32" name="status_failed_borderless" />
</div>
@@ -96,24 +110,58 @@ export default {
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
- <div class="font-weight-bold">
- Pipeline
- <a :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
- >#{{ pipeline.id }}</a
+ <div class="font-weight-bold js-pipeline-info-container">
+ {{ s__('Pipeline|Pipeline') }}
+ <gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
+ >#{{ pipeline.id }}</gl-link
>
-
{{ pipeline.details.status.label }}
-
<template v-if="hasCommitInfo">
- for
- <a
+ {{ s__('Pipeline|for') }}
+ <gl-link
:href="pipeline.commit.commit_path"
class="commit-sha js-commit-link font-weight-normal"
+ >{{ pipeline.commit.short_id }}</gl-link
>
- {{ pipeline.commit.short_id }}</a
- >
- on
+ {{ s__('Pipeline|on') }}
+ <template v-if="isTriggeredByMergeRequest">
+ <gl-link
+ v-gl-tooltip
+ :href="pipeline.merge_request.path"
+ :title="pipeline.merge_request.title"
+ class="font-weight-normal"
+ >!{{ pipeline.merge_request.iid }}</gl-link
+ >
+ {{ s__('Pipeline|with') }}
+ <tooltip-on-truncate
+ :title="pipeline.merge_request.source_branch"
+ truncate-target="child"
+ class="label-branch label-truncate"
+ >
+ <gl-link
+ :href="pipeline.merge_request.source_branch_path"
+ class="font-weight-normal"
+ >{{ pipeline.merge_request.source_branch }}</gl-link
+ >
+ </tooltip-on-truncate>
+
+ <template v-if="isMergeRequestPipeline">
+ {{ s__('Pipeline|into') }}
+ <tooltip-on-truncate
+ :title="pipeline.merge_request.target_branch"
+ truncate-target="child"
+ class="label-branch label-truncate"
+ >
+ <gl-link
+ :href="pipeline.merge_request.target_branch_path"
+ class="font-weight-normal"
+ >{{ pipeline.merge_request.target_branch }}</gl-link
+ >
+ </tooltip-on-truncate>
+ </template>
+ </template>
<tooltip-on-truncate
+ v-else
:title="sourceBranch"
truncate-target="child"
class="label-branch label-truncate"
@@ -121,20 +169,29 @@ export default {
/>
</template>
</div>
- <div v-if="pipeline.coverage" class="coverage">Coverage {{ pipeline.coverage }}%</div>
+ <div v-if="pipeline.coverage" class="coverage">
+ {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}%
+ </div>
</div>
</div>
<div>
<span class="mr-widget-pipeline-graph">
- <span v-if="hasStages" class="stage-cell">
- <div
- v-for="(stage, i) in pipeline.details.stages"
- :key="i"
- class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
- >
- <pipeline-stage :stage="stage" />
- </div>
+ <span class="stage-cell">
+ <linked-pipelines-mini-list v-if="triggeredBy.length" :triggered-by="triggeredBy" />
+ <template v-if="hasStages">
+ <div
+ v-for="(stage, i) in pipeline.details.stages"
+ :key="i"
+ :class="{
+ 'has-downstream': hasDownstream(i),
+ }"
+ class="stage-container dropdown js-mini-pipeline-graph mr-widget-pipeline-stages"
+ >
+ <pipeline-stage :stage="stage" />
+ </div>
+ </template>
</span>
+ <linked-pipelines-mini-list v-if="triggered.length" :triggered="triggered" />
</span>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 5f5fe67b3c1..0686409a785 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -30,9 +30,6 @@ export default {
},
},
computed: {
- pipeline() {
- return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
- },
branch() {
return this.isPostMerge ? this.mr.targetBranch : this.mr.sourceBranch;
},
@@ -48,6 +45,19 @@ export default {
hasDeploymentMetrics() {
return this.isPostMerge;
},
+ visualReviewAppMeta() {
+ return {
+ appUrl: this.mr.appUrl,
+ mergeRequestId: this.mr.iid,
+ sourceProjectId: this.mr.sourceProjectId,
+ };
+ },
+ pipeline() {
+ return this.isPostMerge ? this.mr.mergePipeline : this.mr.pipeline;
+ },
+ showVisualReviewAppLink() {
+ return !!(this.mr.visualReviewFF && this.mr.visualReviewAppAvailable);
+ },
},
};
</script>
@@ -61,14 +71,18 @@ export default {
:source-branch-link="branchLink"
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
/>
- <div v-if="deployments.length" slot="footer" class="mr-widget-extension">
- <deployment
- v-for="deployment in deployments"
- :key="deployment.id"
- :class="deploymentClass"
- :deployment="deployment"
- :show-metrics="hasDeploymentMetrics"
- />
- </div>
+ <template v-slot:footer>
+ <div v-if="deployments.length" class="mr-widget-extension">
+ <deployment
+ v-for="deployment in deployments"
+ :key="deployment.id"
+ :class="deploymentClass"
+ :deployment="deployment"
+ :show-metrics="hasDeploymentMetrics"
+ :show-visual-review-app="true"
+ :visual-review-app-meta="visualReviewAppMeta"
+ />
+ </div>
+ </template>
</mr-widget-container>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
index 780ced4d382..392eb6fb425 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue
@@ -33,7 +33,7 @@ export default {
</script>
<template>
<div class="space-children d-flex append-right-10 widget-status-icon">
- <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon /></div>
+ <div v-if="isLoading" class="mr-widget-icon"><gl-loading-icon size="md" /></div>
<ci-icon v-else :status="statusObj" :size="24" />
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
index de9c122f268..457a71cab95 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue
@@ -19,6 +19,6 @@ export default {
</script>
<template>
<a :href="link" target="_blank" rel="noopener noreferrer nofollow" :class="cssClass">
- {{ __('View app') }} <icon name="external-link" />
+ {{ __('View app') }} <icon css-classes="fgray" name="external-link" />
</a>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
index dd940548e30..6aad2a26a53 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue
@@ -7,14 +7,14 @@ export default {
tooltip,
},
created() {
- this.removesBranchText = __('<strong>Removes</strong> source branch');
+ this.removesBranchText = __('<strong>Deletes</strong> source branch');
this.tooltipTitle = __('A user with write access to the source branch selected this option');
},
};
</script>
<template>
- <p v-once class="mr-info-list mr-links source-branch-removal-status append-bottom-0">
+ <p v-once class="mr-info-list mr-links append-bottom-0">
<span class="status-text" v-html="removesBranchText"> </span>
<i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle">
</i>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
new file mode 100644
index 00000000000..acd8037cfb2
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_edit.vue
@@ -0,0 +1,41 @@
+<script>
+export default {
+ props: {
+ value: {
+ type: String,
+ required: true,
+ },
+ label: {
+ type: String,
+ required: true,
+ },
+ inputId: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <li>
+ <div class="commit-message-editor">
+ <div class="d-flex flex-wrap align-items-center justify-content-between">
+ <label class="col-form-label" :for="inputId">
+ <strong>{{ label }}</strong>
+ </label>
+ <slot name="header"></slot>
+ </div>
+ <textarea
+ :id="inputId"
+ :value="value"
+ class="form-control js-gfm-input append-bottom-default commit-message-edit"
+ dir="auto"
+ required="required"
+ rows="7"
+ @input="$emit('input', $event.target.value)"
+ ></textarea>
+ <slot name="checkbox"></slot>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
new file mode 100644
index 00000000000..b6722de5277
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ props: {
+ commits: {
+ type: Array,
+ required: true,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-dropdown
+ right
+ text="Use an existing commit message"
+ variant="link"
+ class="mr-commit-dropdown"
+ >
+ <gl-dropdown-item
+ v-for="commit in commits"
+ :key="commit.short_id"
+ class="text-nowrap text-truncate"
+ @click="$emit('input', commit.message)"
+ >
+ <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
new file mode 100644
index 00000000000..0312b147b62
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commits_header.vue
@@ -0,0 +1,101 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import _ from 'underscore';
+import { __, n__, sprintf, s__ } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
+
+export default {
+ components: {
+ Icon,
+ GlButton,
+ },
+ props: {
+ isSquashEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ isFastForwardEnabled: {
+ type: Boolean,
+ required: true,
+ },
+ commitsCount: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ targetBranch: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ expanded: false,
+ };
+ },
+ computed: {
+ collapseIcon() {
+ return this.expanded ? 'chevron-down' : 'chevron-right';
+ },
+ commitsCountMessage() {
+ return n__(__('%d commit'), __('%d commits'), this.isSquashEnabled ? 1 : this.commitsCount);
+ },
+ modifyLinkMessage() {
+ if (this.isFastForwardEnabled) return __('Modify commit message');
+ else if (this.isSquashEnabled) return __('Modify commit messages');
+ return __('Modify merge commit');
+ },
+ ariaLabel() {
+ return this.expanded ? __('Collapse') : __('Expand');
+ },
+ message() {
+ const message = this.isFastForwardEnabled
+ ? s__('mrWidgetCommitsAdded|%{commitCount} will be added to %{targetBranch}.')
+ : s__(
+ 'mrWidgetCommitsAdded|%{commitCount} and %{mergeCommitCount} will be added to %{targetBranch}.',
+ );
+
+ return sprintf(
+ message,
+ {
+ commitCount: `<strong class="commits-count-message">${this.commitsCountMessage}</strong>`,
+ mergeCommitCount: `<strong>${s__('mrWidgetCommitsAdded|1 merge commit')}</strong>`,
+ targetBranch: `<span class="label-branch">${_.escape(this.targetBranch)}</span>`,
+ },
+ false,
+ );
+ },
+ },
+ methods: {
+ toggle() {
+ this.expanded = !this.expanded;
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <div
+ class="js-mr-widget-commits-count mr-widget-extension clickable d-flex align-items-center px-3 py-2"
+ @click="toggle()"
+ >
+ <gl-button
+ :aria-label="ariaLabel"
+ variant="blank"
+ class="commit-edit-toggle square s24 mr-2"
+ @click.stop="toggle()"
+ >
+ <icon :name="collapseIcon" :size="16" />
+ </gl-button>
+ <span v-if="expanded">{{ __('Collapse') }}</span>
+ <span v-else>
+ <span class="vertical-align-middle" v-html="message"></span>
+ <gl-button variant="link" class="modify-message-button">
+ {{ modifyLinkMessage }}
+ </gl-button>
+ </span>
+ </div>
+ <div v-show="expanded"><slot></slot></div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
index 27352e0b2b1..f6f445c1cef 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_conflicts.vue
@@ -1,10 +1,14 @@
<script>
-import statusIcon from '../mr_widget_status_icon.vue';
+import $ from 'jquery';
+import _ from 'underscore';
+import { s__, sprintf } from '~/locale';
+import { mouseenter, debouncedMouseleave, togglePopover } from '~/shared/popover';
+import StatusIcon from '../mr_widget_status_icon.vue';
export default {
name: 'MRWidgetConflicts',
components: {
- statusIcon,
+ StatusIcon,
},
props: {
/* TODO: This is providing all store and service down when it
@@ -15,6 +19,52 @@ export default {
default: () => ({}),
},
},
+ computed: {
+ popoverTitle() {
+ return s__(
+ 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
+ );
+ },
+ showResolveButton() {
+ return this.mr.conflictResolutionPath && this.mr.canMerge;
+ },
+ showPopover() {
+ return this.showResolveButton && this.mr.sourceBranchProtected;
+ },
+ },
+ mounted() {
+ if (this.showPopover) {
+ const $el = $(this.$refs.popover);
+
+ $el
+ .popover({
+ html: true,
+ trigger: 'focus',
+ container: 'body',
+ placement: 'top',
+ template:
+ '<div class="popover" role="tooltip"><div class="arrow"></div><p class="popover-header"></p><div class="popover-body"></div></div>',
+ title: s__(
+ 'mrWidget|This feature merges changes from the target branch to the source branch. You cannot use this feature since the source branch is protected.',
+ ),
+ content: sprintf(
+ s__('mrWidget|%{link_start}Learn more about resolving conflicts%{link_end}'),
+ {
+ link_start: `<a href="${_.escape(
+ this.mr.conflictsDocsPath,
+ )}" target="_blank" rel="noopener noreferrer">`,
+ link_end: '</a>',
+ },
+ false,
+ ),
+ })
+ .on('mouseenter', mouseenter)
+ .on('mouseleave', debouncedMouseleave(300))
+ .on('show.bs.popover', () => {
+ window.addEventListener('scroll', togglePopover.bind($el, false), { once: true });
+ });
+ }
+ },
};
</script>
<template>
@@ -38,13 +88,15 @@ To merge this request, first rebase locally.`)
}}
</span>
</span>
- <a
- v-if="mr.canMerge && mr.conflictResolutionPath"
- :href="mr.conflictResolutionPath"
- class="js-resolve-conflicts-button btn btn-default btn-sm"
- >
- {{ s__('mrWidget|Resolve conflicts') }}
- </a>
+ <span v-if="showResolveButton" ref="popover">
+ <a
+ :href="mr.conflictResolutionPath"
+ :disabled="mr.sourceBranchProtected"
+ class="js-resolve-conflicts-button btn btn-default btn-sm"
+ >
+ {{ s__('mrWidget|Resolve conflicts') }}
+ </a>
+ </span>
<button
v-if="mr.canMerge"
class="js-merge-locally-button btn btn-default btn-sm"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
index 2a4dff71d9b..11bc8c73ee9 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_failed_to_merge.vue
@@ -80,7 +80,7 @@ export default {
<status-icon :show-disabled-button="true" status="warning" />
<div class="media-body space-children">
<span class="bold">
- <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }}. </span>
+ <span v-if="mr.mergeError" class="has-error-message"> {{ mergeError }} </span>
<span v-else> {{ s__('mrWidget|Merge failed.') }} </span>
<span :class="{ 'has-custom-error': mr.mergeError }"> {{ timerText }} </span>
</span>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
index 02c76db4a50..1b3af2fccf2 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue
@@ -106,11 +106,11 @@ export default {
<a :href="mr.targetBranchPath" class="label-branch"> {{ mr.targetBranch }} </a>
</p>
<p v-if="mr.shouldRemoveSourceBranch">
- {{ s__('mrWidget|The source branch will be removed') }}
+ {{ s__('mrWidget|The source branch will be deleted') }}
</p>
<p v-else class="d-flex align-items-start">
<span class="append-right-10">
- {{ s__('mrWidget|The source branch will not be removed') }}
+ {{ s__('mrWidget|The source branch will not be deleted') }}
</span>
<a
v-if="canRemoveSourceBranch"
@@ -121,7 +121,7 @@ export default {
@click.prevent="removeSourceBranch"
>
<i v-if="isRemovingSourceBranch" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
- {{ s__('mrWidget|Remove source branch') }}
+ {{ s__('mrWidget|Delete source branch') }}
</a>
</p>
</section>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
index fe83fe58b67..b9562fbc260 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue
@@ -84,7 +84,7 @@ export default {
.removeSourceBranch()
.then(res => res.data)
.then(data => {
- if (data.message === 'Branch was removed') {
+ if (data.message === 'Branch was deleted') {
eventHub.$emit('MRWidgetUpdateRequested', () => {
this.isMakingRequest = false;
});
@@ -174,22 +174,22 @@ export default {
</template>
</p>
<p v-if="mr.sourceBranchRemoved">
- {{ s__('mrWidget|The source branch has been removed') }}
+ {{ s__('mrWidget|The source branch has been deleted') }}
</p>
<p v-if="shouldShowRemoveSourceBranch" class="space-children">
- <span>{{ s__('mrWidget|You can remove source branch now') }}</span>
+ <span>{{ s__('mrWidget|You can delete the source branch now') }}</span>
<button
:disabled="isMakingRequest"
type="button"
class="btn btn-sm btn-default js-remove-branch-button"
@click="removeSourceBranch"
>
- {{ s__('mrWidget|Remove Source Branch') }}
+ {{ s__('mrWidget|Delete source branch') }}
</button>
</p>
<p v-if="shouldShowSourceBranchRemoving">
<gl-loading-icon :inline="true" />
- <span> {{ s__('mrWidget|The source branch is being removed') }} </span>
+ <span> {{ s__('mrWidget|The source branch is being deleted') }} </span>
</p>
</section>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 84c8a3464a5..851939d5d4e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -2,18 +2,27 @@
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
+import { __ } from '~/locale';
+import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import MergeRequest from '../../../merge_request';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
import eventHub from '../../event_hub';
import SquashBeforeMerge from './squash_before_merge.vue';
+import CommitsHeader from './commits_header.vue';
+import CommitEdit from './commit_edit.vue';
+import CommitMessageDropdown from './commit_message_dropdown.vue';
export default {
name: 'ReadyToMerge',
components: {
statusIcon,
- 'squash-before-merge': SquashBeforeMerge,
+ SquashBeforeMerge,
+ CommitsHeader,
+ CommitEdit,
+ CommitMessageDropdown,
},
+ mixins: [readyToMergeMixin],
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
@@ -22,26 +31,20 @@ export default {
return {
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false,
- useCommitMessageWithDescription: false,
setToMergeWhenPipelineSucceeds: false,
- showCommitMessageEditor: false,
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
+ squashBeforeMerge: this.mr.squash,
successSvg,
warningSvg,
+ squashCommitMessage: this.mr.squashCommitMessage,
};
},
computed: {
shouldShowMergeWhenPipelineSucceedsText() {
return this.mr.isPipelineActive;
},
- commitMessageLinkTitle() {
- const withDesc = 'Include description in commit message';
- const withoutDesc = "Don't include description in commit message";
-
- return this.useCommitMessageWithDescription ? withoutDesc : withDesc;
- },
status() {
const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
@@ -83,9 +86,9 @@ export default {
},
mergeButtonText() {
if (this.isMergingImmediately) {
- return 'Merge in progress';
+ return __('Merge in progress');
} else if (this.shouldShowMergeWhenPipelineSucceedsText) {
- return 'Merge when pipeline succeeds';
+ return __('Merge when pipeline succeeds');
}
return 'Merge';
@@ -93,15 +96,6 @@ export default {
shouldShowMergeOptionsDropdown() {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
},
- isMergeButtonDisabled() {
- const { commitMessage } = this;
- return Boolean(
- !commitMessage.length ||
- !this.shouldShowMergeControls() ||
- this.isMakingRequest ||
- this.mr.preventMerge,
- );
- },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -109,24 +103,20 @@ export default {
const { commitsCount, enableSquashBeforeMerge } = this.mr;
return enableSquashBeforeMerge && commitsCount > 1;
},
- },
- created() {
- eventHub.$on('MRWidgetUpdateSquash', this.handleUpdateSquash);
- },
- beforeDestroy() {
- eventHub.$off('MRWidgetUpdateSquash', this.handleUpdateSquash);
- },
- methods: {
shouldShowMergeControls() {
return this.mr.isMergeAllowed || this.shouldShowMergeWhenPipelineSucceedsText;
},
- updateCommitMessage() {
- const cmwd = this.mr.commitMessageWithDescription;
- this.useCommitMessageWithDescription = !this.useCommitMessageWithDescription;
- this.commitMessage = this.useCommitMessageWithDescription ? cmwd : this.mr.commitMessage;
+ shouldShowSquashEdit() {
+ return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
+ },
+ shouldShowMergeEdit() {
+ return !this.mr.ffOnlyEnabled;
},
- toggleCommitMessageEditor() {
- this.showCommitMessageEditor = !this.showCommitMessageEditor;
+ },
+ methods: {
+ updateMergeCommitMessage(includeDescription) {
+ const { commitMessageWithDescription, commitMessage } = this.mr;
+ this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
},
handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) {
// TODO: Remove no-param-reassign
@@ -143,7 +133,8 @@ export default {
commit_message: this.commitMessage,
merge_when_pipeline_succeeds: this.setToMergeWhenPipelineSucceeds,
should_remove_source_branch: this.removeSourceBranch === true,
- squash: this.mr.squash,
+ squash: this.squashBeforeMerge,
+ squash_commit_message: this.squashCommitMessage,
};
this.isMakingRequest = true;
@@ -163,16 +154,16 @@ export default {
})
.catch(() => {
this.isMakingRequest = false;
- new Flash('Something went wrong. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong. Please try again.')); // eslint-disable-line
});
},
- handleUpdateSquash(val) {
- this.mr.squash = val;
- },
initiateMergePolling() {
- simplePoll((continuePolling, stopPolling) => {
- this.handleMergePolling(continuePolling, stopPolling);
- });
+ simplePoll(
+ (continuePolling, stopPolling) => {
+ this.handleMergePolling(continuePolling, stopPolling);
+ },
+ { timeout: 0 },
+ );
},
handleMergePolling(continuePolling, stopPolling) {
this.service
@@ -202,7 +193,8 @@ export default {
}
})
.catch(() => {
- new Flash('Something went wrong while merging this merge request. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong while merging this merge request. Please try again.')); // eslint-disable-line
+ stopPolling();
});
},
initiateRemoveSourceBranchPolling() {
@@ -231,7 +223,7 @@ export default {
}
})
.catch(() => {
- new Flash('Something went wrong while removing the source branch. Please try again.'); // eslint-disable-line
+ new Flash(__('Something went wrong while deleting the source branch. Please try again.')); // eslint-disable-line
});
},
},
@@ -239,126 +231,137 @@ export default {
</script>
<template>
- <div class="mr-widget-body media">
- <status-icon :status="iconClass" />
- <div class="media-body">
- <div class="mr-widget-body-controls media space-children">
- <span class="btn-group">
- <button
- :disabled="isMergeButtonDisabled"
- :class="mergeButtonClass"
- type="button"
- class="qa-merge-button"
- @click="handleMergeButtonClick();"
- >
- <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
- {{ mergeButtonText }}
- </button>
- <button
- v-if="shouldShowMergeOptionsDropdown"
- :disabled="isMergeButtonDisabled"
- type="button"
- class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
- data-toggle="dropdown"
- aria-label="Select merge moment"
- >
- <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
- </button>
- <ul
- v-if="shouldShowMergeOptionsDropdown"
- class="dropdown-menu dropdown-menu-right"
- role="menu"
- >
- <li>
- <a
- class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option"
- href="#"
- @click.prevent="handleMergeButtonClick(true);"
- >
- <span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
- <span class="media-body merge-opt-title">Merge when pipeline succeeds</span>
- </span>
- </a>
- </li>
- <li>
- <a
- class="accept-merge-request qa-merge-immediately-option"
- href="#"
- @click.prevent="handleMergeButtonClick(false, true);"
- >
- <span class="media">
- <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
- <span class="media-body merge-opt-title">Merge immediately</span>
- </span>
- </a>
- </li>
- </ul>
- </span>
- <div class="media-body-wrap space-children">
- <template v-if="shouldShowMergeControls()">
- <label v-if="mr.canRemoveSourceBranch">
- <input
- id="remove-source-branch-input"
- v-model="removeSourceBranch"
- :disabled="isRemoveSourceBranchButtonDisabled"
- class="js-remove-source-branch-checkbox"
- type="checkbox"
- />
- Remove source branch
- </label>
-
- <!-- Placeholder for EE extension of this component -->
- <squash-before-merge
- v-if="shouldShowSquashBeforeMerge"
- :mr="mr"
- :is-merge-button-disabled="isMergeButtonDisabled"
- />
-
- <span v-if="mr.ffOnlyEnabled" class="js-fast-forward-message">
- Fast-forward merge without a merge commit
- </span>
+ <div>
+ <div class="mr-widget-body media">
+ <status-icon :status="iconClass" />
+ <div class="media-body">
+ <div class="mr-widget-body-controls media space-children">
+ <span class="btn-group">
<button
- v-else
:disabled="isMergeButtonDisabled"
- class="js-modify-commit-message-button btn btn-default btn-sm"
+ :class="mergeButtonClass"
type="button"
- @click="toggleCommitMessageEditor"
+ class="qa-merge-button"
+ @click="handleMergeButtonClick()"
>
- Modify commit message
+ <i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
+ {{ mergeButtonText }}
</button>
- </template>
- <template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- You can only merge once the items above are resolved
- </span>
- </template>
- </div>
- </div>
- <div v-if="showCommitMessageEditor" class="prepend-top-default commit-message-editor">
- <div class="form-group clearfix">
- <label class="col-form-label" for="commit-message"> Commit message </label>
- <div class="col-sm-10">
- <div class="commit-message-container">
- <div class="max-width-marker"></div>
- <textarea
- id="commit-message"
- v-model="commitMessage"
- class="form-control js-commit-message"
- required="required"
- rows="14"
- name="Commit message"
- ></textarea>
- </div>
- <p class="hint">
- Try to keep the first line under 52 characters and the others under 72
- </p>
- <div class="hint">
- <a href="#" @click.prevent="updateCommitMessage"> {{ commitMessageLinkTitle }} </a>
- </div>
+ <button
+ v-if="shouldShowMergeOptionsDropdown"
+ :disabled="isMergeButtonDisabled"
+ type="button"
+ class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
+ data-toggle="dropdown"
+ aria-label="Select merge moment"
+ >
+ <i class="fa fa-chevron-down qa-merge-moment-dropdown" aria-hidden="true"></i>
+ </button>
+ <ul
+ v-if="shouldShowMergeOptionsDropdown"
+ class="dropdown-menu dropdown-menu-right"
+ role="menu"
+ >
+ <li>
+ <a
+ class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option"
+ href="#"
+ @click.prevent="handleMergeButtonClick(true)"
+ >
+ <span class="media">
+ <span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
+ <span class="media-body merge-opt-title">{{
+ __('Merge when pipeline succeeds')
+ }}</span>
+ </span>
+ </a>
+ </li>
+ <li>
+ <a
+ class="accept-merge-request qa-merge-immediately-option"
+ href="#"
+ @click.prevent="handleMergeButtonClick(false, true)"
+ >
+ <span class="media">
+ <span class="merge-opt-icon" aria-hidden="true" v-html="warningSvg"></span>
+ <span class="media-body merge-opt-title">{{ __('Merge immediately') }}</span>
+ </span>
+ </a>
+ </li>
+ </ul>
+ </span>
+ <div class="media-body-wrap space-children">
+ <template v-if="shouldShowMergeControls">
+ <label v-if="mr.canRemoveSourceBranch">
+ <input
+ id="remove-source-branch-input"
+ v-model="removeSourceBranch"
+ :disabled="isRemoveSourceBranchButtonDisabled"
+ class="js-remove-source-branch-checkbox"
+ type="checkbox"
+ />
+ {{ __('Delete source branch') }}
+ </label>
+
+ <!-- Placeholder for EE extension of this component -->
+ <squash-before-merge
+ v-if="shouldShowSquashBeforeMerge"
+ v-model="squashBeforeMerge"
+ :help-path="mr.squashBeforeMergeHelpPath"
+ :is-disabled="isMergeButtonDisabled"
+ />
+ </template>
+ <template v-else>
+ <span class="bold js-resolve-mr-widget-items-message">
+ {{ __('You can only merge once the items above are resolved') }}
+ </span>
+ </template>
</div>
</div>
</div>
</div>
+ <template v-if="shouldShowMergeControls">
+ <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message">
+ {{ __('Fast-forward merge without a merge commit') }}
+ </div>
+ <commits-header
+ v-if="shouldShowSquashEdit || shouldShowMergeEdit"
+ :is-squash-enabled="squashBeforeMerge"
+ :commits-count="mr.commitsCount"
+ :target-branch="mr.targetBranch"
+ :is-fast-forward-enabled="mr.ffOnlyEnabled"
+ >
+ <ul class="border-top content-list commits-list flex-list">
+ <commit-edit
+ v-if="shouldShowSquashEdit"
+ v-model="squashCommitMessage"
+ :label="__('Squash commit message')"
+ input-id="squash-message-edit"
+ squash
+ >
+ <commit-message-dropdown
+ slot="header"
+ v-model="squashCommitMessage"
+ :commits="mr.commits"
+ />
+ </commit-edit>
+ <commit-edit
+ v-if="shouldShowMergeEdit"
+ v-model="commitMessage"
+ :label="__('Merge commit message')"
+ input-id="merge-message-edit"
+ >
+ <label slot="checkbox">
+ <input
+ id="include-description"
+ type="checkbox"
+ @change="updateMergeCommitMessage($event.target.checked)"
+ />
+ {{ __('Include merge request description') }}
+ </label>
+ </commit-edit>
+ </ul>
+ </commits-header>
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
index e71acf0d7dd..b1f5655a15a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue
@@ -1,6 +1,5 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
-import eventHub from '~/vue_merge_request_widget/event_hub';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
@@ -11,23 +10,19 @@ export default {
tooltip,
},
props: {
- mr: {
- type: Object,
- required: true,
- },
- isMergeButtonDisabled: {
+ value: {
type: Boolean,
required: true,
},
- },
- data() {
- return {
- squashBeforeMerge: this.mr.squash,
- };
- },
- methods: {
- updateSquashModel() {
- eventHub.$emit('MRWidgetUpdateSquash', this.squashBeforeMerge);
+ helpPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isDisabled: {
+ type: Boolean,
+ required: false,
+ default: false,
},
},
};
@@ -37,18 +32,19 @@ export default {
<div class="accept-control inline">
<label class="merge-param-checkbox">
<input
- v-model="squashBeforeMerge"
- :disabled="isMergeButtonDisabled"
+ :checked="value"
+ :disabled="isDisabled"
type="checkbox"
name="squash"
class="qa-squash-checkbox"
- @change="updateSquashModel"
+ @change="$emit('input', $event.target.checked)"
/>
{{ __('Squash commits') }}
</label>
<a
+ v-if="helpPath"
v-tooltip
- :href="mr.squashBeforeMergeHelpPath"
+ :href="helpPath"
data-title="About this feature"
data-placement="bottom"
target="_blank"
diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js
new file mode 100644
index 00000000000..0a29d55fbd6
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/constants.js
@@ -0,0 +1,5 @@
+export const WARNING = 'warning';
+export const DANGER = 'danger';
+
+export const WARNING_MESSAGE_CLASS = 'warning_message';
+export const DANGER_MESSAGE_CLASS = 'danger_message';
diff --git a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js
deleted file mode 100644
index 8780aa4bd1c..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/ee_switch_mr_widget_options.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MRWidgetOptions from './mr_widget_options.vue';
-
-export default MRWidgetOptions;
diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js
index 60cebbfc2b2..0cedbdbdfef 100644
--- a/app/assets/javascripts/vue_merge_request_widget/index.js
+++ b/app/assets/javascripts/vue_merge_request_widget/index.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import MrWidgetOptions from './ee_switch_mr_widget_options';
+import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import Translate from '../vue_shared/translate';
Vue.use(Translate);
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
new file mode 100644
index 00000000000..96e8bb45e34
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/mr_widget_pipeline.js
@@ -0,0 +1,15 @@
+export default {
+ computed: {
+ triggered() {
+ return [];
+ },
+ triggeredBy() {
+ return [];
+ },
+ },
+ methods: {
+ hasDownstream() {
+ return false;
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
new file mode 100644
index 00000000000..b2e64506472
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -0,0 +1,13 @@
+export default {
+ computed: {
+ isMergeButtonDisabled() {
+ const { commitMessage } = this;
+ return Boolean(
+ !commitMessage.length ||
+ !this.shouldShowMergeControls ||
+ this.isMakingRequest ||
+ this.mr.preventMerge,
+ );
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index d8a75388e84..bf175eb5f69 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -3,12 +3,16 @@ import _ from 'underscore';
import { __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
+import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_store';
+import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
+import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import createFlash from '../flash';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
import Deployment from './components/deployment.vue';
import WidgetRelatedLinks from './components/mr_widget_related_links.vue';
+import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue';
import MergedState from './components/states/mr_widget_merged.vue';
import ClosedState from './components/states/mr_widget_closed.vue';
import MergingState from './components/states/mr_widget_merging.vue';
@@ -28,11 +32,7 @@ import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
-import MRWidgetStore from './stores/ee_switch_mr_widget_store';
-import MRWidgetService from './services/ee_switch_mr_widget_service';
import eventHub from './event_hub';
-import stateMaps from './stores/ee_switch_state_maps';
-import SquashBeforeMerge from './components/states/squash_before_merge.vue';
import notify from '~/lib/utils/notify';
import SourceBranchRemovalStatus from './components/source_branch_removal_status.vue';
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
@@ -47,6 +47,7 @@ export default {
MrWidgetPipelineContainer,
Deployment,
'mr-widget-related-links': WidgetRelatedLinks,
+ MrWidgetAlertMessage,
'mr-widget-merged': MergedState,
'mr-widget-closed': ClosedState,
'mr-widget-merging': MergingState,
@@ -59,7 +60,6 @@ export default {
'mr-widget-missing-branch': MissingBranchState,
'mr-widget-ready-to-merge': ReadyToMergeState,
'sha-mismatch': ShaMismatchState,
- 'mr-widget-squash-before-merge': SquashBeforeMerge,
'mr-widget-checking': CheckingState,
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
'mr-widget-pipeline-blocked': PipelineBlockedState,
@@ -106,9 +106,25 @@ export default {
(!this.mr.isNothingToMergeState && !this.mr.isMergedState)
);
},
+ shouldRenderCollaborationStatus() {
+ return this.mr.allowCollaboration && this.mr.isOpen;
+ },
shouldRenderMergedPipeline() {
return this.mr.state === 'merged' && !_.isEmpty(this.mr.mergePipeline);
},
+ showMergePipelineForkWarning() {
+ return Boolean(
+ this.mr.mergePipelinesEnabled && this.mr.sourceProjectId !== this.mr.targetProjectId,
+ );
+ },
+ showTargetBranchAdvancedError() {
+ return Boolean(
+ this.mr.isOpen &&
+ this.mr.pipeline &&
+ this.mr.pipeline.target_sha &&
+ this.mr.pipeline.target_sha !== this.mr.targetBranchSha,
+ );
+ },
},
watch: {
state(newVal, oldVal) {
@@ -141,8 +157,8 @@ export default {
}
},
methods: {
- createService(store) {
- const endpoints = {
+ getServiceEndpoints(store) {
+ return {
mergePath: store.mergePath,
mergeCheckPath: store.mergeCheckPath,
cancelAutoMergePath: store.cancelAutoMergePath,
@@ -153,7 +169,9 @@ export default {
mergeActionsContentPath: store.mergeActionsContentPath,
rebasePath: store.rebasePath,
};
- return new MRWidgetService(endpoints);
+ },
+ createService(store) {
+ return new MRWidgetService(this.getServiceEndpoints(store));
},
checkStatus(cb, isRebased) {
return this.service
@@ -315,17 +333,45 @@ export default {
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
- <section v-if="mr.allowCollaboration" class="mr-info-list mr-links">
- {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }}
- </section>
+ <div class="mr-widget-info">
+ <section v-if="shouldRenderCollaborationStatus" class="mr-info-list mr-links">
+ <p>
+ {{ s__('mrWidget|Allows commits from members who can merge to the target branch') }}
+ </p>
+ </section>
+
+ <mr-widget-related-links
+ v-if="shouldRenderRelatedLinks"
+ :state="mr.state"
+ :related-links="mr.relatedLinks"
+ />
+
+ <mr-widget-alert-message
+ v-if="showMergePipelineForkWarning"
+ type="warning"
+ :help-path="mr.mergeRequestPipelinesHelpPath"
+ >
+ {{
+ s__(
+ 'mrWidget|Fork merge requests do not create merge request pipelines which validate a post merge result',
+ )
+ }}
+ </mr-widget-alert-message>
- <mr-widget-related-links
- v-if="shouldRenderRelatedLinks"
- :state="mr.state"
- :related-links="mr.relatedLinks"
- />
+ <mr-widget-alert-message
+ v-if="showTargetBranchAdvancedError"
+ type="danger"
+ :help-path="mr.mergeRequestPipelinesHelpPath"
+ >
+ {{
+ s__(
+ 'mrWidget|The target branch has advanced, which invalidates the merge request pipeline. Please update the source branch and retry merging',
+ )
+ }}
+ </mr-widget-alert-message>
- <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
+ <source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
+ </div>
</div>
<div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js
deleted file mode 100644
index ea2aabb78fe..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/services/ee_switch_mr_widget_service.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MRWidgetService from './mr_widget_service';
-
-export default MRWidgetService;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js
deleted file mode 100644
index ebef30e3eab..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_get_state_key.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import getStateKey from './get_state_key';
-
-export default getStateKey;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js
deleted file mode 100644
index 92a07c53f2d..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_mr_widget_store.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import MergeRequestStore from './mr_widget_store';
-
-export default MergeRequestStore;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js
deleted file mode 100644
index 50cf9503ea7..00000000000
--- a/app/assets/javascripts/vue_merge_request_widget/stores/ee_switch_state_maps.js
+++ /dev/null
@@ -1,3 +0,0 @@
-import stateMaps from './state_maps';
-
-export default stateMaps;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 066a3b833d7..0cc4fd59f5e 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -13,6 +13,8 @@ export default function deviseState(data) {
return stateKey.conflicts;
} else if (data.work_in_progress) {
return stateKey.workInProgress;
+ } else if (this.shouldBeRebased) {
+ return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
} else if (this.hasMergeableDiscussionsState) {
@@ -25,8 +27,6 @@ export default function deviseState(data) {
return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds;
} else if (!this.canMerge) {
return stateKey.notAllowedToMerge;
- } else if (this.shouldBeRebased) {
- return stateKey.rebase;
} else if (this.canBeMerged) {
return stateKey.readyToMerge;
}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index c777bcca0fa..45708d78886 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -1,5 +1,5 @@
import Timeago from 'timeago.js';
-import getStateKey from './ee_switch_get_state_key';
+import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps';
import { formatDate } from '../../lib/utils/datetime_utility';
@@ -28,18 +28,24 @@ export default class MergeRequestStore {
this.iid = data.iid;
this.title = data.title;
this.targetBranch = data.target_branch;
+ this.targetBranchSha = data.target_branch_sha;
this.sourceBranch = data.source_branch;
+ this.sourceBranchProtected = data.source_branch_protected;
+ this.conflictsDocsPath = data.conflicts_docs_path;
+ this.mergeRequestPipelinesHelpPath = data.merge_request_pipelines_docs_path;
this.mergeStatus = data.merge_status;
- this.commitMessage = data.merge_commit_message;
+ this.commitMessage = data.default_merge_commit_message;
this.shortMergeCommitSha = data.short_merge_commit_sha;
this.mergeCommitSha = data.merge_commit_sha;
- this.commitMessageWithDescription = data.merge_commit_message_with_description;
+ this.commitMessageWithDescription = data.default_merge_commit_message_with_description;
this.commitsCount = data.commits_count;
this.divergedCommitsCount = data.diverged_commits_count;
this.pipeline = data.pipeline || {};
this.mergePipeline = data.merge_pipeline || {};
this.deployments = this.deployments || data.deployments || [];
this.postMergeDeployments = this.postMergeDeployments || [];
+ this.commits = data.commits_without_merge_commits || [];
+ this.squashCommitMessage = data.default_squash_commit_message;
this.initRebase(data);
if (data.issues_links) {
@@ -95,6 +101,9 @@ export default class MergeRequestStore {
this.allowCollaboration = data.allow_collaboration;
this.targetProjectFullPath = data.target_project_full_path;
this.sourceProjectFullPath = data.source_project_full_path;
+ this.sourceProjectId = data.source_project_id;
+ this.targetProjectId = data.target_project_id;
+ this.mergePipelinesEnabled = data.merge_pipelines_enabled;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
@@ -108,7 +117,7 @@ export default class MergeRequestStore {
this.ciStatus = data.ci_status;
this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled';
this.isPipelinePassing =
- this.ciStatus === 'success' || this.ciStatus === 'success_with_warnings';
+ this.ciStatus === 'success' || this.ciStatus === 'success-with-warnings';
this.isPipelineSkipped = this.ciStatus === 'skipped';
this.pipelineDetailedStatus = pipelineStatus;
this.isPipelineActive = data.pipeline ? data.pipeline.active : false;
diff --git a/app/assets/javascripts/vue_shared/components/bar_chart.vue b/app/assets/javascripts/vue_shared/components/bar_chart.vue
index 4abf795f7bd..eabf5d4bf60 100644
--- a/app/assets/javascripts/vue_shared/components/bar_chart.vue
+++ b/app/assets/javascripts/vue_shared/components/bar_chart.vue
@@ -293,8 +293,8 @@ export default {
:title="setTooltipTitle(data)"
class="bar-rect"
data-placement="top"
- @mouseover="barHoveredIn(index);"
- @mouseout="barHoveredOut(index);"
+ @mouseover="barHoveredIn(index)"
+ @mouseout="barHoveredOut(index)"
/>
</template>
</g>
diff --git a/app/assets/javascripts/vue_shared/components/callout.vue b/app/assets/javascripts/vue_shared/components/callout.vue
index ddbb14ae812..56bafebf4ce 100644
--- a/app/assets/javascripts/vue_shared/components/callout.vue
+++ b/app/assets/javascripts/vue_shared/components/callout.vue
@@ -11,13 +11,14 @@ export default {
},
message: {
type: String,
- required: true,
+ required: false,
+ default: '',
},
},
};
</script>
<template>
<div :class="`bs-callout bs-callout-${category}`" role="alert" aria-live="assertive">
- {{ message }}
+ {{ message }} <slot></slot>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
index bb7710f708e..e9ab6f5ba7a 100644
--- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue
@@ -37,6 +37,11 @@ export default {
required: false,
default: 12,
},
+ isCentered: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
computed: {
changedIcon() {
@@ -78,7 +83,12 @@ export default {
</script>
<template>
- <span v-gl-tooltip.right :title="tooltipTitle" class="file-changed-icon ml-auto">
+ <span
+ v-gl-tooltip.right
+ :title="tooltipTitle"
+ :class="{ 'ml-auto': isCentered }"
+ class="file-changed-icon"
+ >
<icon v-if="showIcon" :name="changedIcon" :size="size" :css-classes="changedIconClass" />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue
index b8eb555106f..25f80219993 100644
--- a/app/assets/javascripts/vue_shared/components/ci_icon.vue
+++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue
@@ -21,6 +21,8 @@ import Icon from '../../vue_shared/components/icon.vue';
* - Jobs table
* - Jobs show view header
* - Jobs show view sidebar
+ * - Linked pipelines
+ * - Extended MR Popover
*/
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
@@ -46,6 +48,11 @@ export default {
required: false,
default: false,
},
+ cssClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
cssClass() {
@@ -59,5 +66,5 @@ export default {
};
</script>
<template>
- <span :class="cssClass"> <icon :name="icon" :size="size" /> </span>
+ <span :class="cssClass"> <icon :name="icon" :size="size" :css-classes="cssClasses" /> </span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
index 671b4909839..a620f560b52 100644
--- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue
+++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue
@@ -7,7 +7,7 @@
*
* @example
* <clipboard-button
- * title="Copy to clipbard"
+ * title="Copy to clipboard"
* text="Content to be copied"
* css-class="btn-transparent"
* />
diff --git a/app/assets/javascripts/vue_shared/components/commit.vue b/app/assets/javascripts/vue_shared/components/commit.vue
index ee685a4b8cd..3ba946e6447 100644
--- a/app/assets/javascripts/vue_shared/components/commit.vue
+++ b/app/assets/javascripts/vue_shared/components/commit.vue
@@ -1,5 +1,6 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
+import _ from 'underscore';
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import UserAvatarLink from './user_avatar/user_avatar_link.vue';
import Icon from '../../vue_shared/components/icon.vue';
@@ -10,6 +11,7 @@ export default {
components: {
UserAvatarLink,
Icon,
+ GlLink,
},
props: {
/**
@@ -33,6 +35,27 @@ export default {
required: false,
default: () => ({}),
},
+
+ /**
+ * If provided, is used the render the MR IID and link
+ * in place of the branch name. Must contains the
+ * following properties:
+ * - iid (number)
+ * - path (non-empty string)
+ *
+ * May optionally contain the following properties:
+ * - title (string): used in a tooltip if provided
+ *
+ * Any additional properties are ignored.
+ */
+ mergeRequestRef: {
+ type: Object,
+ required: false,
+ default: undefined,
+ validator: ref =>
+ _.isUndefined(ref) || (_.isFinite(ref.iid) && _.isString(ref.path) && !_.isEmpty(ref.path)),
+ },
+
/**
* Used to link to the commit sha.
*/
@@ -70,7 +93,11 @@ export default {
required: false,
default: () => ({}),
},
- showBranch: {
+
+ /**
+ * Indicates whether or not to show the branch/MR ref info
+ */
+ showRefInfo: {
type: Boolean,
required: false,
default: true,
@@ -78,14 +105,12 @@ export default {
},
computed: {
/**
- * Used to verify if all the properties needed to render the commit
- * ref section were provided.
- *
- * @returns {Boolean}
+ * Determines if we shoud render the ref info section based
*/
- hasCommitRef() {
- return this.commitRef && this.commitRef.name && this.commitRef.ref_url;
+ shouldShowRefInfo() {
+ return this.showRefInfo && (this.commitRef || this.mergeRequestRef);
},
+
/**
* Used to verify if all the properties needed to render the commit
* author section were provided.
@@ -108,19 +133,36 @@ export default {
};
</script>
<template>
- <div class="branch-commit">
- <template v-if="hasCommitRef && showBranch">
+ <div class="branch-commit cgray">
+ <template v-if="shouldShowRefInfo">
<div class="icon-container">
- <i v-if="tag" class="fa fa-tag" aria-hidden="true"> </i> <icon v-if="!tag" name="fork" />
+ <icon v-if="tag" name="tag" />
+ <icon v-else-if="mergeRequestRef" name="git-merge" />
+ <icon v-else name="branch" />
</div>
- <a v-gl-tooltip :href="commitRef.ref_url" :title="commitRef.name" class="ref-name">
+ <gl-link
+ v-if="mergeRequestRef"
+ v-gl-tooltip
+ :href="mergeRequestRef.path"
+ :title="mergeRequestRef.title"
+ class="ref-name"
+ >
+ {{ mergeRequestRef.iid }}
+ </gl-link>
+ <gl-link
+ v-else
+ v-gl-tooltip
+ :href="commitRef.ref_url"
+ :title="commitRef.name"
+ class="ref-name"
+ >
{{ commitRef.name }}
- </a>
+ </gl-link>
</template>
<icon name="commit" class="commit-icon js-commit-icon" />
- <a :href="commitUrl" class="commit-sha"> {{ shortSha }} </a>
+ <gl-link :href="commitUrl" class="commit-sha mr-0"> {{ shortSha }} </gl-link>
<div class="commit-title flex-truncate-parent">
<span v-if="title" class="flex-truncate-child">
@@ -132,7 +174,7 @@ export default {
:tooltip-text="author.username"
class="avatar-image-container"
/>
- <a :href="commitUrl" class="commit-row-message"> {{ title }} </a>
+ <gl-link :href="commitUrl" class="commit-row-message cgray"> {{ title }} </gl-link>
</span>
<span v-else> Can't find HEAD commit for this branch </span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
index 4155e1bab9c..1e6f4c376c1 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue
@@ -1,5 +1,4 @@
<script>
-import { viewerInformationForPath } from './lib/viewer_utils';
import MarkdownViewer from './viewers/markdown_viewer.vue';
import ImageViewer from './viewers/image_viewer.vue';
import DownloadViewer from './viewers/download_viewer.vue';
@@ -24,15 +23,18 @@ export default {
required: false,
default: '',
},
+ type: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
viewer() {
if (!this.path) return null;
+ if (!this.type) return DownloadViewer;
- const previewInfo = viewerInformationForPath(this.path);
- if (!previewInfo) return DownloadViewer;
-
- switch (previewInfo.id) {
+ switch (this.type) {
case 'markdown':
return MarkdownViewer;
case 'image':
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
index f01a51da0b3..ba63683f5c0 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/lib/viewer_utils.js
@@ -1,10 +1,12 @@
+import { __ } from '~/locale';
+
const viewers = {
image: {
id: 'image',
},
markdown: {
id: 'markdown',
- previewTitle: 'Preview Markdown',
+ previewTitle: __('Preview Markdown'),
},
};
diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
index c9915f7d685..5fdc915fffb 100644
--- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue
@@ -78,8 +78,8 @@ export default {
</script>
<template>
- <div ref="markdown-preview" class="md md-previewer">
+ <div ref="markdown-preview" class="md-previewer">
<gl-skeleton-loading v-if="isLoading" />
- <div v-else v-html="previewContent"></div>
+ <div v-else class="md" v-html="previewContent"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
index 2129f90d497..36b3ee05456 100644
--- a/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/deprecated_modal.vue
@@ -95,7 +95,7 @@ export default {
class="close float-right"
data-dismiss="modal"
aria-label="Close"
- @click="emitCancel($event);"
+ @click="emitCancel($event)"
>
<span aria-hidden="true">&times;</span>
</button>
@@ -112,7 +112,7 @@ export default {
type="button"
class="btn"
data-dismiss="modal"
- @click="emitCancel($event);"
+ @click="emitCancel($event)"
>
{{ closeButtonLabel }}
</button>
@@ -130,7 +130,7 @@ export default {
type="button"
class="btn js-primary-button"
data-dismiss="modal"
- @click="emitSubmit($event);"
+ @click="emitSubmit($event)"
>
{{ primaryButtonLabel }}
</button>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
index 75c66ed850b..ebb253ff422 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue
@@ -1,6 +1,5 @@
<script>
-import { diffModes } from '~/ide/constants';
-import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils';
+import { diffViewerModes, diffModes } from '~/ide/constants';
import ImageDiffViewer from './viewers/image_diff_viewer.vue';
import DownloadDiffViewer from './viewers/download_diff_viewer.vue';
import RenamedFile from './viewers/renamed.vue';
@@ -12,6 +11,10 @@ export default {
type: String,
required: true,
},
+ diffViewerMode: {
+ type: String,
+ required: true,
+ },
newPath: {
type: String,
required: true,
@@ -46,7 +49,7 @@ export default {
},
computed: {
viewer() {
- if (this.diffMode === diffModes.renamed) {
+ if (this.diffViewerMode === diffViewerModes.renamed) {
return RenamedFile;
} else if (this.diffMode === diffModes.mode_changed) {
return ModeChanged;
@@ -54,11 +57,8 @@ export default {
if (!this.newPath) return null;
- const previewInfo = viewerInformationForPath(this.newPath);
- if (!previewInfo) return DownloadDiffViewer;
-
- switch (previewInfo.id) {
- case 'image':
+ switch (this.diffViewerMode) {
+ case diffViewerModes.image:
return ImageDiffViewer;
default:
return DownloadDiffViewer;
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
index f085ef35ccc..2b5b2269ec8 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/onion_skin_viewer.vue
@@ -40,12 +40,15 @@ export default {
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
- this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
- const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
+ const moveX = e.pageX || e.touches[0].pageX;
+ const left = moveX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
@@ -60,11 +63,13 @@ export default {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
@@ -82,6 +87,7 @@ export default {
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
@@ -102,7 +108,7 @@ export default {
:style="{
width: onionMaxPixelWidth,
height: onionMaxPixelHeight,
- 'user-select': dragging === true ? 'none' : '',
+ 'user-select': dragging ? 'none' : null,
}"
class="onion-skin-frame"
>
@@ -140,7 +146,14 @@ export default {
</div>
<div class="controls">
<div class="transparent"></div>
- <div ref="dragTrack" class="drag-track" @mousedown="startDrag" @mouseup="stopDrag">
+ <div
+ ref="dragTrack"
+ class="drag-track"
+ @mousedown="startDrag"
+ @mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
+ >
<div ref="dragger" :style="{ left: onionDraggerPixelPos }" class="dragger"></div>
</div>
<div class="opaque"></div>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
index 1c970b72a66..8d77b156aa4 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/swipe_viewer.vue
@@ -46,6 +46,8 @@ export default {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchend', this.stopDrag);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
@@ -54,13 +56,13 @@ export default {
dragMove(e) {
if (!this.dragging) return;
- let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
- const spaceLeft = 20;
+ const moveX = e.pageX || e.touches[0].pageX;
+ let leftValue = moveX - this.$refs.swipeFrame.getBoundingClientRect().left;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
leftValue = 0;
- } else if (leftValue > clientWidth - spaceLeft) {
- leftValue = clientWidth - spaceLeft;
+ } else if (leftValue > clientWidth) {
+ leftValue = clientWidth;
}
this.swipeWrapWidth = (leftValue / clientWidth) * 100;
@@ -68,16 +70,16 @@ export default {
},
startDrag() {
this.dragging = true;
- document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
+ document.body.addEventListener('touchmove', this.dragMove);
},
stopDrag() {
this.dragging = false;
- document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
+ document.body.removeEventListener('touchmove', this.dragMove);
},
prepareSwipe() {
- if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
+ if (this.swipeOldImgInfo && this.swipeNewImgInfo && this.swipeOldImgInfo.renderedWidth > 0) {
// Add 2 for border width
this.swipeMaxWidth =
Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2;
@@ -85,6 +87,7 @@ export default {
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
+ document.body.addEventListener('touchend', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
@@ -97,6 +100,8 @@ export default {
},
resize: _.throttle(function throttledResize() {
this.swipeBarPos = 0;
+ this.swipeWrapWidth = 0;
+ this.prepareSwipe();
}, 400),
},
};
@@ -104,7 +109,15 @@ export default {
<template>
<div class="swipe view">
- <div ref="swipeFrame" class="swipe-frame">
+ <div
+ ref="swipeFrame"
+ :style="{
+ width: swipeMaxPixelWidth,
+ height: swipeMaxPixelHeight,
+ 'user-select': dragging ? 'none' : null,
+ }"
+ class="swipe-frame"
+ >
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
@@ -139,6 +152,8 @@ export default {
class="swipe-bar"
@mousedown="startDrag"
@mouseup="stopDrag"
+ @touchstart="startDrag"
+ @touchend="stopDrag"
>
<span class="top-handle"></span> <span class="bottom-handle"></span>
</span>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
index d5fda7e4ed3..cab92297ca7 100644
--- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue
@@ -75,7 +75,7 @@ export default {
:class="{
active: mode === $options.imageViewMode.twoup,
}"
- @click="changeMode($options.imageViewMode.twoup);"
+ @click="changeMode($options.imageViewMode.twoup)"
>
{{ s__('ImageDiffViewer|2-up') }}
</li>
@@ -83,7 +83,7 @@ export default {
:class="{
active: mode === $options.imageViewMode.swipe,
}"
- @click="changeMode($options.imageViewMode.swipe);"
+ @click="changeMode($options.imageViewMode.swipe)"
>
{{ s__('ImageDiffViewer|Swipe') }}
</li>
@@ -91,7 +91,7 @@ export default {
:class="{
active: mode === $options.imageViewMode.onion,
}"
- @click="changeMode($options.imageViewMode.onion);"
+ @click="changeMode($options.imageViewMode.onion)"
>
{{ s__('ImageDiffViewer|Onion skin') }}
</li>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue
new file mode 100644
index 00000000000..c5cdddf2f64
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/no_preview.vue
@@ -0,0 +1,5 @@
+<template>
+ <div class="nothing-here-block">
+ {{ __('No preview for this file type') }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue
new file mode 100644
index 00000000000..d4d3038f066
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/not_diffable.vue
@@ -0,0 +1,5 @@
+<template>
+ <div class="nothing-here-block">
+ {{ __('This diff was suppressed by a .gitattributes entry.') }}
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/empty_component.js b/app/assets/javascripts/vue_shared/components/empty_component.js
new file mode 100644
index 00000000000..e4402020096
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/empty_component.js
@@ -0,0 +1,3 @@
+export default {
+ render: () => null,
+};
diff --git a/app/assets/javascripts/ide/components/file_finder/index.vue b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
index bb391912572..b57455adaad 100644
--- a/app/assets/javascripts/ide/components/file_finder/index.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/index.vue
@@ -1,45 +1,62 @@
<script>
-import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Mousetrap from 'mousetrap';
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item.vue';
-import router from '../../ide_router';
-import {
- MAX_FILE_FINDER_RESULTS,
- FILE_FINDER_ROW_HEIGHT,
- FILE_FINDER_EMPTY_ROW_HEIGHT,
-} from '../../constants';
-import {
- UP_KEY_CODE,
- DOWN_KEY_CODE,
- ENTER_KEY_CODE,
- ESC_KEY_CODE,
-} from '../../../lib/utils/keycodes';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+export const MAX_FILE_FINDER_RESULTS = 40;
+export const FILE_FINDER_ROW_HEIGHT = 55;
+export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
+
+const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
Item,
VirtualList,
},
+ props: {
+ files: {
+ type: Array,
+ required: true,
+ },
+ visible: {
+ type: Boolean,
+ required: true,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ clearSearchOnClose: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
data() {
return {
- focusedIndex: 0,
+ focusedIndex: -1,
searchText: '',
mouseOver: false,
cancelMouseOver: false,
};
},
computed: {
- ...mapGetters(['allBlobs']),
- ...mapState(['fileFindVisible', 'loading']),
filteredBlobs() {
const searchText = this.searchText.trim();
if (searchText === '') {
- return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
+ return this.files.slice(0, MAX_FILE_FINDER_RESULTS);
}
- return fuzzaldrinPlus.filter(this.allBlobs, searchText, {
+ return fuzzaldrinPlus.filter(this.files, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
});
@@ -58,10 +75,12 @@ export default {
},
},
watch: {
- fileFindVisible() {
+ visible() {
this.$nextTick(() => {
- if (!this.fileFindVisible) {
- this.searchText = '';
+ if (!this.visible) {
+ if (this.clearSearchOnClose) {
+ this.searchText = '';
+ }
} else {
this.focusedIndex = 0;
@@ -72,7 +91,11 @@ export default {
});
},
searchText() {
- this.focusedIndex = 0;
+ this.focusedIndex = -1;
+
+ this.$nextTick(() => {
+ this.focusedIndex = 0;
+ });
},
focusedIndex() {
if (!this.mouseOver) {
@@ -98,8 +121,25 @@ export default {
}
},
},
+ mounted() {
+ if (this.files.length) {
+ this.focusedIndex = 0;
+ }
+
+ Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
+ if (e.preventDefault) {
+ e.preventDefault();
+ }
+
+ this.toggle(!this.visible);
+ });
+
+ Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
+ },
methods: {
- ...mapActions(['toggleFileFinder']),
+ toggle(visible) {
+ this.$emit('toggle', visible);
+ },
clearSearchInput() {
this.searchText = '';
@@ -139,15 +179,15 @@ export default {
this.openFile(this.filteredBlobs[this.focusedIndex]);
break;
case ESC_KEY_CODE:
- this.toggleFileFinder(false);
+ this.toggle(false);
break;
default:
break;
}
},
openFile(file) {
- this.toggleFileFinder(false);
- router.push(`/project${file.url}`);
+ this.toggle(false);
+ this.$emit('click', file);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
@@ -159,14 +199,26 @@ export default {
this.cancelMouseOver = false;
this.onMouseOver(index);
},
+ mousetrapStopCallback(e, el, combo) {
+ if (
+ (combo === 't' && el.classList.contains('dropdown-input-field')) ||
+ el.classList.contains('inputarea')
+ ) {
+ return true;
+ } else if (combo === 'command+p' || combo === 'ctrl+p') {
+ return false;
+ }
+
+ return originalStopCallback(e, el, combo);
+ },
},
};
</script>
<template>
- <div class="ide-file-finder-overlay" @mousedown.self="toggleFileFinder(false);">
- <div class="dropdown-menu diff-file-changes ide-file-finder show">
- <div class="dropdown-input">
+ <div class="file-finder-overlay" @mousedown.self="toggle(false)">
+ <div class="dropdown-menu diff-file-changes file-finder show">
+ <div :class="{ 'has-value': showClearInputButton }" class="dropdown-input">
<input
ref="searchInput"
v-model="searchText"
@@ -174,8 +226,8 @@ export default {
type="search"
class="dropdown-input-field"
autocomplete="off"
- @keydown="onKeydown($event);"
- @keyup="onKeyup($event);"
+ @keydown="onKeydown($event)"
+ @keyup="onKeyup($event)"
/>
<i
:class="{
@@ -186,9 +238,6 @@ export default {
></i>
<i
:aria-label="__('Clear search input')"
- :class="{
- show: showClearInputButton,
- }"
role="button"
class="fa fa-times dropdown-input-clear"
@click="clearSearchInput"
@@ -203,6 +252,7 @@ export default {
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
+ :show-diff-stats="showDiffStats"
class="disable-hover"
@click="openFile"
@mouseover="onMouseOver"
@@ -225,3 +275,25 @@ export default {
</div>
</div>
</template>
+
+<style scoped>
+.file-finder-overlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 200;
+}
+
+.file-finder {
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.diff-file-changes {
+ top: 50px;
+ max-height: 327px;
+}
+</style>
diff --git a/app/assets/javascripts/ide/components/file_finder/item.vue b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
index 83e80d50aff..73511879ff2 100644
--- a/app/assets/javascripts/ide/components/file_finder/item.vue
+++ b/app/assets/javascripts/vue_shared/components/file_finder/item.vue
@@ -1,5 +1,6 @@
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '../../../vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../../../vue_shared/components/changed_file_icon.vue';
@@ -7,6 +8,7 @@ const MAX_PATH_LENGTH = 60;
export default {
components: {
+ Icon,
ChangedFileIcon,
FileIcon,
},
@@ -27,6 +29,11 @@ export default {
type: Number,
required: true,
},
+ showDiffStats: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
computed: {
pathWithEllipsis() {
@@ -97,8 +104,23 @@ export default {
</span>
</span>
</span>
- <span v-if="file.changed || file.tempFile" class="diff-changed-stats">
- <changed-file-icon :file="file" />
+ <span v-if="file.changed || file.tempFile" v-once class="diff-changed-stats">
+ <span v-if="showDiffStats">
+ <span class="cgreen bold">
+ <icon name="file-addition" class="align-text-top" /> {{ file.addedLines }}
+ </span>
+ <span class="cred bold ml-1">
+ <icon name="file-deletion" class="align-text-top" /> {{ file.removedLines }}
+ </span>
+ </span>
+ <changed-file-icon v-else :file="file" />
</span>
</button>
</template>
+
+<style scoped>
+.highlighted {
+ color: #1f78d1;
+ font-weight: 600;
+}
+</style>
diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue
index 9e713673678..1bfa91500cb 100644
--- a/app/assets/javascripts/vue_shared/components/file_row.vue
+++ b/app/assets/javascripts/vue_shared/components/file_row.vue
@@ -1,11 +1,13 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
+import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
export default {
name: 'FileRow',
components: {
+ FileHeader,
FileIcon,
Icon,
ChangedFileIcon,
@@ -34,21 +36,10 @@ export default {
required: false,
default: false,
},
- displayTextKey: {
- type: String,
- required: false,
- default: 'name',
- },
- shouldTruncateStart: {
- type: Boolean,
- required: false,
- default: false,
- },
},
data() {
return {
- mouseOver: false,
- truncateStart: 0,
+ dropdownOpen: false,
};
},
computed: {
@@ -60,7 +51,7 @@ export default {
},
levelIndentation() {
return {
- marginLeft: `${this.level * 16}px`,
+ marginLeft: this.level ? `${this.level * 16}px` : null,
};
},
fileClass() {
@@ -71,14 +62,8 @@ export default {
'is-open': this.file.opened,
};
},
- outputText() {
- const text = this.file[this.displayTextKey];
-
- if (this.truncateStart === 0) {
- return text;
- }
-
- return `...${text.substring(this.truncateStart, text.length)}`;
+ childFilesLevel() {
+ return this.file.isHeader ? 0 : this.level + 1;
},
},
watch: {
@@ -92,15 +77,6 @@ export default {
if (this.hasPathAtCurrentRoute()) {
this.scrollIntoView(true);
}
-
- if (this.shouldTruncateStart) {
- const { scrollWidth, offsetWidth } = this.$refs.textOutput;
- const textOverflow = scrollWidth - offsetWidth;
-
- if (textOverflow > 0) {
- this.truncateStart = Math.ceil(textOverflow / 5) + 3;
- }
- }
},
methods: {
toggleTreeOpen(path) {
@@ -147,8 +123,8 @@ export default {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
- toggleHover(over) {
- this.mouseOver = over;
+ toggleDropdown(val) {
+ this.dropdownOpen = val;
},
},
};
@@ -156,13 +132,15 @@ export default {
<template>
<div>
+ <file-header v-if="file.isHeader" :path="file.path" />
<div
+ v-else
:class="fileClass"
+ :title="file.name"
class="file-row"
role="button"
@click="clickFile"
- @mouseover="toggleHover(true);"
- @mouseout="toggleHover(false);"
+ @mouseleave="toggleDropdown(false)"
>
<div class="file-row-name-container">
<span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated">
@@ -175,27 +153,26 @@ export default {
:size="16"
/>
<changed-file-icon v-else :file="file" :size="16" class="append-right-5" />
- {{ outputText }}
+ {{ file.name }}
</span>
<component
:is="extraComponent"
v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')"
:file="file"
- :mouse-over="mouseOver"
+ :dropdown-open="dropdownOpen"
+ @toggle="toggleDropdown($event)"
/>
</div>
</div>
- <template v-if="file.opened">
+ <template v-if="file.opened || file.isHeader">
<file-row
v-for="childFile in file.tree"
:key="childFile.key"
:file="childFile"
- :level="level + 1"
+ :level="childFilesLevel"
:hide-extra-on-tree="hideExtraOnTree"
:extra-component="extraComponent"
:show-changed-icon="showChangedIcon"
- :display-text-key="displayTextKey"
- :should-truncate-start="shouldTruncateStart"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="clickedFile"
/>
diff --git a/app/assets/javascripts/vue_shared/components/file_row_header.vue b/app/assets/javascripts/vue_shared/components/file_row_header.vue
new file mode 100644
index 00000000000..301fd8116a9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/file_row_header.vue
@@ -0,0 +1,25 @@
+<script>
+import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
+
+const MAX_PATH_LENGTH = 40;
+
+export default {
+ props: {
+ path: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ truncatedPath() {
+ return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="file-row-header bg-white sticky-top p-2 js-file-row-header">
+ <span class="bold">{{ truncatedPath }}</span>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
index 834c39a5ee0..4e5dfbf3bf8 100644
--- a/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/filtered_search_dropdown.vue
@@ -1,15 +1,21 @@
<script>
import $ from 'jquery';
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
/**
* Renders a split dropdown with
* an input that allows to search through the given
* array of options.
+ *
+ * When there are no results and `showCreateMode` is true
+ * it renders a create button with the value typed.
*/
export default {
name: 'FilteredSearchDropdown',
components: {
Icon,
+ GlButton,
},
props: {
title: {
@@ -43,6 +49,16 @@ export default {
type: String,
required: true,
},
+ showCreateMode: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ createButtonText: {
+ type: String,
+ required: false,
+ default: __('Create'),
+ },
},
data() {
return {
@@ -64,6 +80,12 @@ export default {
return this.items.slice(0, this.visibleItems);
},
+ computedCreateButtonText() {
+ return `${this.createButtonText} ${this.filter}`;
+ },
+ shouldRenderCreateButton() {
+ return this.showCreateMode && this.filteredResults.length === 0 && this.filter !== '';
+ },
},
mounted() {
/**
@@ -112,10 +134,20 @@ export default {
<div class="dropdown-content">
<ul>
<li v-for="(result, i) in filteredResults" :key="i" class="js-filtered-dropdown-result">
- <slot name="result" :result="result"> {{ result[filterKey] }} </slot>
+ <slot name="result" :result="result">{{ result[filterKey] }}</slot>
</li>
</ul>
</div>
+
+ <div v-if="shouldRenderCreateButton" class="dropdown-footer">
+ <slot name="footer" :filter="filter">
+ <gl-button
+ class="js-dropdown-create-button btn-transparent"
+ @click="$emit('createItem', filter)"
+ >{{ computedCreateButtonText }}</gl-button
+ >
+ </slot>
+ </div>
</div>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal.vue b/app/assets/javascripts/vue_shared/components/gl_modal.vue
index faf4181bbaf..438851e5ac7 100644
--- a/app/assets/javascripts/vue_shared/components/gl_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_modal.vue
@@ -81,7 +81,7 @@ export default {
type="button"
class="close js-modal-close-action"
data-dismiss="modal"
- @click="emitCancel($event);"
+ @click="emitCancel($event)"
>
<span aria-hidden="true">&times;</span>
</button>
@@ -96,7 +96,7 @@ export default {
type="button"
class="btn js-modal-cancel-action qa-modal-cancel-button"
data-dismiss="modal"
- @click="emitCancel($event);"
+ @click="emitCancel($event)"
>
{{ s__('Modal|Cancel') }}
</button>
@@ -105,7 +105,7 @@ export default {
type="button"
class="btn js-modal-primary-action qa-modal-primary-button"
data-dismiss="modal"
- @click="emitSubmit($event);"
+ @click="emitSubmit($event)"
>
{{ footerPrimaryButtonText }}
</button>
diff --git a/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
new file mode 100644
index 00000000000..df6fadf10cd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/gl_modal_vuex.vue
@@ -0,0 +1,69 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { GlModal } from '@gitlab/ui';
+
+/**
+ * This component keeps the GlModal's visibility in sync with the given vuex module.
+ */
+export default {
+ components: {
+ GlModal,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ modalModule: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState({
+ isVisible(state) {
+ return state[this.modalModule].isVisible;
+ },
+ }),
+ attrs() {
+ const { modalId, modalModule, ...attrs } = this.$attrs;
+
+ return attrs;
+ },
+ },
+ watch: {
+ isVisible(val) {
+ return val ? this.bsShow() : this.bsHide();
+ },
+ },
+ methods: {
+ ...mapActions({
+ syncShow(dispatch) {
+ return dispatch(`${this.modalModule}/show`);
+ },
+ syncHide(dispatch) {
+ return dispatch(`${this.modalModule}/hide`);
+ },
+ }),
+ bsShow() {
+ this.$root.$emit('bv::show::modal', this.modalId);
+ },
+ bsHide() {
+ // $root.$emit is a workaround because other b-modal approaches don't work yet with gl-modal
+ this.$root.$emit('bv::hide::modal', this.modalId);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ v-bind="attrs"
+ :modal-id="modalId"
+ v-on="$listeners"
+ @shown="syncShow"
+ @hidden="syncHide"
+ >
+ <slot></slot>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
index c830f5b49b6..3f45dc7853b 100644
--- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue
+++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue
@@ -148,7 +148,7 @@ export default {
:class="action.cssClass"
container-class="d-inline"
:label="action.label"
- @click="onClickAction(action);"
+ @click="onClickAction(action)"
/>
</template>
</section>
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
index d5d967e25bf..53e6efa6ea3 100644
--- a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -17,15 +17,13 @@ export default {
required: true,
},
},
- data() {
- return {
- milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
- milestoneStart: this.milestone.start_date
- ? parsePikadayDate(this.milestone.start_date)
- : null,
- };
- },
computed: {
+ milestoneDue() {
+ return this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null;
+ },
+ milestoneStart() {
+ return this.milestone.start_date ? parsePikadayDate(this.milestone.start_date) : null;
+ },
isMilestoneStarted() {
if (!this.milestoneStart) {
return false;
@@ -72,7 +70,7 @@ export default {
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<icon :size="16" class="inline icon" name="clock" />
- <span class="milestone-title">{{ milestone.title }}</span>
+ <span class="milestone-title d-inline-block">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
new file mode 100644
index 00000000000..b807a35b421
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue
@@ -0,0 +1,131 @@
+<script>
+import '~/commons/bootstrap';
+import { GlTooltipDirective } from '@gitlab/ui';
+import { sprintf } from '~/locale';
+import IssueMilestone from '../../components/issue/issue_milestone.vue';
+import IssueAssignees from '../../components/issue/issue_assignees.vue';
+import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
+import CiIcon from '../ci_icon.vue';
+
+export default {
+ name: 'IssueItem',
+ components: {
+ IssueMilestone,
+ IssueAssignees,
+ CiIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [relatedIssuableMixin],
+ props: {
+ canReorder: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ stateTitle() {
+ return sprintf(
+ '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
+ {
+ state: this.stateText,
+ timeInWords: this.stateTimeInWords,
+ timestamp: this.stateTimestamp,
+ },
+ );
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'issuable-info-container': !canReorder,
+ 'card-body': canReorder,
+ }"
+ class="item-body d-flex align-items-center p-2 p-lg-3 p-xl-2 pl-xl-3"
+ >
+ <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap">
+ <div class="item-title d-flex align-items-center mb-1 mb-xl-0">
+ <icon
+ v-if="hasState"
+ v-tooltip
+ :css-classes="iconClass"
+ :name="iconName"
+ :size="16"
+ :title="stateTitle"
+ :aria-label="state"
+ data-html="true"
+ />
+ <icon
+ v-if="confidential"
+ v-gl-tooltip
+ name="eye-slash"
+ :size="16"
+ :title="__('Confidential')"
+ class="confidential-icon append-right-4 align-self-baseline align-self-md-auto mt-xl-0"
+ :aria-label="__('Confidential')"
+ />
+ <a :href="computedPath" class="sortable-link">{{ title }}</a>
+ </div>
+ <div class="item-meta d-flex flex-wrap mt-xl-0 justify-content-xl-end flex-xl-nowrap">
+ <div
+ class="d-flex align-items-center item-path-id order-md-0 mt-md-0 mt-1 ml-xl-2 mr-xl-auto"
+ >
+ <icon
+ v-if="hasState"
+ v-tooltip
+ :css-classes="iconClass"
+ :name="iconName"
+ :size="16"
+ :title="stateTitle"
+ :aria-label="state"
+ data-html="true"
+ class="d-xl-none"
+ />
+ <span v-tooltip :title="itemPath" class="path-id-text d-inline-block">{{
+ itemPath
+ }}</span>
+ {{ pathIdSeparator }}{{ itemId }}
+ </div>
+ <div
+ class="item-meta-child d-flex align-items-center order-0 flex-wrap mr-md-1 ml-md-auto ml-xl-2 flex-xl-nowrap"
+ >
+ <span v-if="hasPipeline" class="mr-ci-status pr-2">
+ <a :href="pipelineStatus.details_path">
+ <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" />
+ </a>
+ </span>
+ <issue-milestone
+ v-if="hasMilestone"
+ :milestone="milestone"
+ class="d-flex align-items-center item-milestone"
+ />
+ <slot name="dueDate"></slot>
+ <slot name="weight"></slot>
+ </div>
+ <issue-assignees
+ v-if="assignees.length"
+ :assignees="assignees"
+ class="item-assignees d-inline-flex align-items-center align-self-end ml-auto ml-md-0 mb-md-0 order-2 flex-xl-grow-0 mt-xl-0 mr-xl-1"
+ />
+ </div>
+ </div>
+ <button
+ v-if="canRemove"
+ ref="removeButton"
+ v-tooltip
+ :disabled="removeDisabled"
+ type="button"
+ class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button mr-xl-0 align-self-xl-center"
+ title="Remove"
+ aria-label="Remove"
+ @click="onRemoveRequest"
+ >
+ <icon :size="16" class="btn-item-remove-icon" name="close" />
+ </button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
new file mode 100644
index 00000000000..d1aba99ac22
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js
@@ -0,0 +1,20 @@
+/* eslint-disable import/prefer-default-export */
+
+function trimFirstCharOfLineContent(text) {
+ if (!text) {
+ return text;
+ }
+
+ return text.replace(/^( |\+|-)/, '');
+}
+
+function cleanSuggestionLine(line = {}) {
+ return {
+ ...line,
+ text: trimFirstCharOfLineContent(line.text),
+ };
+}
+
+export function selectDiffLines(lines) {
+ return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line));
+}
diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue
index 7a53d053eec..216f6c62e69 100644
--- a/app/assets/javascripts/vue_shared/components/loading_button.vue
+++ b/app/assets/javascripts/vue_shared/components/loading_button.vue
@@ -53,7 +53,7 @@ export default {
<template>
<button :class="containerClass" :disabled="loading || disabled" type="button" @click="onClick">
- <transition name="fade">
+ <transition name="fade-in">
<gl-loading-icon
v-if="loading"
:inline="true"
@@ -63,7 +63,7 @@ export default {
class="js-loading-button-icon"
/>
</transition>
- <transition name="fade">
+ <transition name="fade-in">
<slot>
<span v-if="label" class="js-loading-button-label"> {{ label }} </span>
</slot>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 2f7ed4a982c..0f3b3568414 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -27,11 +27,6 @@ export default {
type: String,
required: true,
},
- markdownVersion: {
- type: Number,
- required: false,
- default: 0,
- },
addSpacingClasses: {
type: Boolean,
required: false,
@@ -81,6 +76,7 @@ export default {
hasSuggestion: false,
markdownPreviewLoading: false,
previewMarkdown: false,
+ suggestions: this.note.suggestions || [],
};
},
computed: {
@@ -89,7 +85,6 @@ export default {
return this.referencedUsers.length >= referencedUsersThreshold;
},
lineContent() {
- const FIRST_CHAR_REGEX = /^(\+|-)/;
const [firstSuggestion] = this.suggestions;
if (firstSuggestion) {
return firstSuggestion.from_content;
@@ -99,7 +94,7 @@ export default {
const { rich_text: richText, text } = this.line;
if (text) {
- return text.replace(FIRST_CHAR_REGEX, '');
+ return text;
}
return _.unescape(stripHtml(richText).replace(/\n/g, ''));
@@ -115,9 +110,6 @@ export default {
}
return lineNumber;
},
- suggestions() {
- return this.note.suggestions || [];
- },
lineType() {
return this.line ? this.line.type : '';
},
@@ -159,7 +151,7 @@ export default {
this.markdownPreviewLoading = true;
this.markdownPreview = __('Loading…');
this.$http
- .post(this.versionedPreviewPath(), { text })
+ .post(this.markdownPreviewPath, { text })
.then(resp => resp.json())
.then(data => this.renderMarkdown(data))
.catch(() => new Flash(__('Error loading markdown preview')));
@@ -181,18 +173,12 @@ export default {
this.referencedCommands = data.references.commands;
this.referencedUsers = data.references.users;
this.hasSuggestion = data.references.suggestions && data.references.suggestions.length;
+ this.suggestions = data.references.suggestions;
}
- this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
- });
- },
-
- versionedPreviewPath() {
- const { markdownPreviewPath, markdownVersion } = this;
- return `${markdownPreviewPath}${
- markdownPreviewPath.indexOf('?') === -1 ? '?' : '&'
- }markdown_version=${markdownVersion}`;
+ this.$nextTick()
+ .then(() => $(this.$refs['markdown-preview']).renderGFM())
+ .catch(() => new Flash(__('Error rendering markdown preview')));
},
},
};
@@ -202,7 +188,7 @@ export default {
<div
ref="gl-form"
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
- class="md-area js-vue-markdown-field"
+ class="js-vue-markdown-field md-area position-relative"
>
<markdown-header
:preview-markdown="previewMarkdown"
@@ -228,7 +214,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="md-preview js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md-preview-holder"
>
<suggestions
v-if="hasSuggestion"
@@ -246,7 +232,7 @@ export default {
<div
v-show="previewMarkdown"
ref="markdown-preview"
- class="md-preview js-vue-md-preview md md-preview-holder"
+ class="js-vue-md-preview md md-preview-holder"
v-html="markdownPreview"
></div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index bf4d42670ee..a5a5b2ef415 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -38,7 +38,7 @@ export default {
].join('\n');
},
mdSuggestion() {
- return ['```suggestion', `{text}`, '```'].join('\n');
+ return ['```suggestion:-0+0', `{text}`, '```'].join('\n');
},
},
mounted() {
@@ -78,13 +78,8 @@ export default {
<div class="md-header">
<ul class="nav-links clearfix">
<li :class="{ active: !previewMarkdown }" class="md-header-tab">
- <button
- class="js-write-link"
- tabindex="-1"
- type="button"
- @click="writeMarkdownTab($event);"
- >
- Write
+ <button class="js-write-link" tabindex="-1" type="button" @click="writeMarkdownTab($event)">
+ {{ __('Write') }}
</button>
</li>
<li :class="{ active: previewMarkdown }" class="md-header-tab">
@@ -92,38 +87,43 @@ export default {
class="js-preview-link js-md-preview-button"
tabindex="-1"
type="button"
- @click="previewMarkdownTab($event);"
+ @click="previewMarkdownTab($event)"
>
- Preview
+ {{ __('Preview') }}
</button>
</li>
<li :class="{ active: !previewMarkdown }" class="md-header-toolbar">
- <toolbar-button tag="**" button-title="Add bold text" icon="bold" />
- <toolbar-button tag="*" button-title="Add italic text" icon="italic" />
- <toolbar-button :prepend="true" tag="> " button-title="Insert a quote" icon="quote" />
- <toolbar-button tag="`" tag-block="```" button-title="Insert code" icon="code" />
+ <toolbar-button tag="**" :button-title="__('Add bold text')" icon="bold" />
+ <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" />
+ <toolbar-button
+ :prepend="true"
+ tag="> "
+ :button-title="__('Insert a quote')"
+ icon="quote"
+ />
+ <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" />
<toolbar-button
tag="[{text}](url)"
tag-select="url"
- button-title="Add a link"
+ :button-title="__('Add a link')"
icon="link"
/>
<toolbar-button
:prepend="true"
tag="* "
- button-title="Add a bullet list"
+ :button-title="__('Add a bullet list')"
icon="list-bulleted"
/>
<toolbar-button
:prepend="true"
tag="1. "
- button-title="Add a numbered list"
+ :button-title="__('Add a numbered list')"
icon="list-numbered"
/>
<toolbar-button
:prepend="true"
tag="* [ ] "
- button-title="Add a task list"
+ :button-title="__('Add a task list')"
icon="task-done"
/>
<toolbar-button
@@ -144,11 +144,11 @@ export default {
/>
<button
v-gl-tooltip
- aria-label="Go full screen"
+ :aria-label="__('Go full screen')"
class="toolbar-btn toolbar-fullscreen-btn js-zen-enter"
data-container="body"
tabindex="-1"
- title="Go full screen"
+ :title="__('Go full screen')"
type="button"
>
<icon name="screen-full" />
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
index f98560f7336..2eb4ec12a4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue
@@ -1,24 +1,14 @@
<script>
import SuggestionDiffHeader from './suggestion_diff_header.vue';
+import SuggestionDiffRow from './suggestion_diff_row.vue';
+import { selectDiffLines } from '../lib/utils/diff_utils';
export default {
components: {
SuggestionDiffHeader,
+ SuggestionDiffRow,
},
props: {
- newLines: {
- type: Array,
- required: true,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
- fromLine: {
- type: Number,
- required: true,
- },
suggestion: {
type: Object,
required: true,
@@ -33,6 +23,11 @@ export default {
required: true,
},
},
+ computed: {
+ lines() {
+ return selectDiffLines(this.suggestion.diff_lines);
+ },
+ },
methods: {
applySuggestion(callback) {
this.$emit('apply', { suggestionId: this.suggestion.id, callback });
@@ -42,7 +37,7 @@ export default {
</script>
<template>
- <div>
+ <div class="md-suggestion">
<suggestion-diff-header
class="qa-suggestion-diff-header"
:can-apply="suggestion.appliable && suggestion.current_user.can_apply && !disabled"
@@ -50,24 +45,13 @@ export default {
:help-page-path="helpPagePath"
@apply="applySuggestion"
/>
- <table class="mb-3 md-suggestion-diff">
+ <table class="mb-3 md-suggestion-diff js-syntax-highlight code">
<tbody>
- <!-- Old Line -->
- <tr class="line_holder old">
- <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td>
- <td class="diff-line-num new_line old"></td>
- <td class="line_content old">
- <span>{{ fromContent }}</span>
- </td>
- </tr>
- <!-- New Line(s) -->
- <tr v-for="(line, key) of newLines" :key="key" class="line_holder new">
- <td class="diff-line-num old_line new"></td>
- <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td>
- <td class="line_content new">
- <span>{{ line.content }}</span>
- </td>
- </tr>
+ <suggestion-diff-row
+ v-for="(line, index) of lines"
+ :key="`${index}-${line.text}`"
+ :line="line"
+ />
</tbody>
</table>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index 563e2f94fcc..32783b85df4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,8 +1,10 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
- components: { Icon },
+ components: { Icon, GlButton, GlLoadingIcon },
+ directives: { 'gl-tooltip': GlTooltipDirective },
props: {
canApply: {
type: Boolean,
@@ -21,7 +23,6 @@ export default {
},
data() {
return {
- isAppliedSuccessfully: false,
isApplying: false,
};
},
@@ -42,19 +43,24 @@ export default {
<div class="md-suggestion-header border-bottom-0 mt-2">
<div class="qa-suggestion-diff-header font-weight-bold">
{{ __('Suggested change') }}
- <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')">
+ <a v-if="helpPagePath" :href="helpPagePath" :aria-label="__('Help')" class="js-help-btn">
<icon name="question-o" css-classes="link-highlight" />
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
- <button
- v-if="canApply"
- type="button"
- class="btn qa-apply-btn"
+ <div v-if="isApplying" class="d-flex align-items-center text-secondary">
+ <gl-loading-icon class="d-flex-center mr-2" />
+ <span>{{ __('Applying suggestion') }}</span>
+ </div>
+ <gl-button
+ v-else-if="canApply"
+ v-gl-tooltip.viewport="__('This also resolves the discussion')"
+ class="btn-inverted qa-apply-btn"
:disabled="isApplying"
+ variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
new file mode 100644
index 00000000000..c09bdfec250
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue
@@ -0,0 +1,32 @@
+<script>
+export default {
+ name: 'SuggestionDiffRow',
+ props: {
+ line: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ lineType() {
+ return this.line.type;
+ },
+ },
+};
+</script>
+
+<template>
+ <tr class="line_holder" :class="lineType">
+ <td class="diff-line-num old_line border-top-0 border-bottom-0" :class="lineType">
+ {{ line.old_line }}
+ </td>
+ <td class="diff-line-num new_line border-top-0 border-bottom-0" :class="lineType">
+ {{ line.new_line }}
+ </td>
+ <td class="line_content" :class="lineType">
+ <span v-if="line.text">{{ line.text }}</span>
+ <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE -->
+ <span v-else>&#8203;</span>
+ </td>
+ </tr>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
index 7c6dbee3e19..8d3705e1e4a 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -6,16 +6,6 @@ import Flash from '~/flash';
export default {
components: { SuggestionDiff },
props: {
- fromLine: {
- type: Number,
- required: false,
- default: 0,
- },
- fromContent: {
- type: String,
- required: false,
- default: '',
- },
lineType: {
type: String,
required: false,
@@ -71,42 +61,19 @@ export default {
suggestionElements.forEach((suggestionEl, i) => {
const suggestionParentEl = suggestionEl.parentElement;
- const newLines = this.extractNewLines(suggestionParentEl);
- const diffComponent = this.generateDiff(newLines, i);
+ const diffComponent = this.generateDiff(i);
diffComponent.$mount(suggestionParentEl);
});
this.isRendered = true;
},
- extractNewLines(suggestionEl) {
- // extracts the suggested lines from the markdown
- // calculates a line number for each line
-
- const FIRST_CHAR_REGEX = /^(\+|-)/;
- const newLines = suggestionEl.querySelectorAll('.line');
- const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine;
- const lines = [];
-
- newLines.forEach((line, i) => {
- const content = `${line.innerText.replace(FIRST_CHAR_REGEX, '')}\n`;
- const lineNumber = fromLine + i;
- lines.push({ content, lineNumber });
- });
-
- return lines;
- },
- generateDiff(newLines, suggestionIndex) {
- // generates the diff <suggestion-diff /> component
- // all `suggestion` markdown will be swapped out by this component
-
+ generateDiff(suggestionIndex) {
const { suggestions, disabled, helpPagePath } = this;
const suggestion =
suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {};
- const fromContent = suggestion.from_content || this.fromContent;
- const fromLine = suggestion.from_line || this.fromLine;
const SuggestionDiffComponent = Vue.extend(SuggestionDiff);
const suggestionDiff = new SuggestionDiffComponent({
- propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath },
+ propsData: { disabled, suggestion, helpPagePath },
});
suggestionDiff.$on('apply', ({ suggestionId, callback }) => {
@@ -130,7 +97,7 @@ export default {
<template>
<div>
- <div class="flash-container mt-3"></div>
- <div v-show="isRendered" ref="container" v-html="noteHtml"></div>
+ <div class="flash-container js-suggestions-flash"></div>
+ <div v-show="isRendered" ref="container" class="md" v-html="noteHtml"></div>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
index 09a64502819..f8983a3d29a 100644
--- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
+++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue
@@ -58,7 +58,7 @@ export default {
active: tab.isActive,
}"
>
- <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab);">
+ <a :class="`js-${scope}-tab-${tab.scope}`" role="button" @click="onTabClick(tab)">
{{ tab.name }}
<span v-if="shouldRenderBadge(tab.count)" class="badge badge-pill"> {{ tab.count }} </span>
diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
index 8d3a3009c55..a50f49c1279 100644
--- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue
@@ -57,7 +57,7 @@ export default {
</div>
</div>
<div class="note-body">
- <div class="note-text">
+ <div class="note-text md">
<p>{{ note.body }}</p>
</div>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
index 31df26f7b05..3c86b7e4c61 100644
--- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue
@@ -22,6 +22,7 @@ import noteHeader from '~/notes/components/note_header.vue';
import Icon from '~/vue_shared/components/icon.vue';
import TimelineEntryItem from './timeline_entry_item.vue';
import { spriteIcon } from '../../../lib/utils/common_utils';
+import initMRPopovers from '~/mr_popover/';
const MAX_VISIBLE_COMMIT_LIST_COUNT = 3;
@@ -71,6 +72,9 @@ export default {
);
},
},
+ mounted() {
+ initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request'));
+ },
};
</script>
@@ -93,11 +97,11 @@ export default {
'system-note-commit-list': hasMoreCommits,
'hide-shade': expanded,
}"
- class="note-text"
+ class="note-text md"
v-html="note.note_html"
></div>
<div v-if="hasMoreCommits" class="flex-list">
- <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded;">
+ <div class="system-note-commit-list-toggler flex-row" @click="expanded = !expanded">
<icon :name="toggleIcon" :size="8" class="append-right-5" />
<span>Toggle commit list</span>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
index 06974a12aed..f316c4fe112 100644
--- a/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
+++ b/app/assets/javascripts/vue_shared/components/notes/timeline_entry_item.vue
@@ -1,9 +1,3 @@
-<script>
-export default {
- name: 'TimelineEntryItem',
-};
-</script>
-
<template>
<li class="timeline-entry">
<div class="timeline-entry-inner"><slot></slot></div>
diff --git a/app/assets/javascripts/vue_shared/components/panel_resizer.vue b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
index bf736a378dd..8d81940eb91 100644
--- a/app/assets/javascripts/vue_shared/components/panel_resizer.vue
+++ b/app/assets/javascripts/vue_shared/components/panel_resizer.vue
@@ -28,11 +28,12 @@ export default {
data() {
return {
size: this.startSize,
+ isDragging: false,
};
},
computed: {
className() {
- return `drag-${this.side}`;
+ return [`position-${this.side}-0`, { 'is-dragging': this.isDragging }];
},
cursorStyle() {
if (this.enabled) {
@@ -57,6 +58,7 @@ export default {
startDrag(e) {
if (this.enabled) {
e.preventDefault();
+ this.isDragging = true;
this.startPos = e.clientX;
this.currentStartSize = this.size;
document.addEventListener('mousemove', this.drag);
@@ -80,6 +82,7 @@ export default {
},
endDrag(e) {
e.preventDefault();
+ this.isDragging = false;
document.removeEventListener('mousemove', this.drag);
this.$emit('resize-end', this.size);
},
@@ -91,7 +94,7 @@ export default {
<div
:class="className"
:style="cursorStyle"
- class="drag-handle"
+ class="position-absolute position-top-0 position-bottom-0 drag-handle"
@mousedown="startDrag"
@dblclick="resetSize"
></div>
diff --git a/app/assets/javascripts/vue_shared/components/pikaday.vue b/app/assets/javascripts/vue_shared/components/pikaday.vue
index 8bdb5bf22c2..fa502b9beb9 100644
--- a/app/assets/javascripts/vue_shared/components/pikaday.vue
+++ b/app/assets/javascripts/vue_shared/components/pikaday.vue
@@ -1,6 +1,7 @@
<script>
import Pikaday from 'pikaday';
import { parsePikadayDate, pikadayToString } from '~/lib/utils/datetime_utility';
+import { __ } from '~/locale';
export default {
name: 'DatePicker',
@@ -8,7 +9,7 @@ export default {
label: {
type: String,
required: false,
- default: 'Date picker',
+ default: __('Date picker'),
},
selectedDate: {
type: Date,
@@ -40,6 +41,7 @@ export default {
toString: date => pikadayToString(date),
onSelect: this.selected.bind(this),
onClose: this.toggled.bind(this),
+ firstDay: gon.first_day_of_week,
});
this.$el.append(this.calendar.el);
diff --git a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
index b399c232937..881b5059d2a 100644
--- a/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
+++ b/app/assets/javascripts/vue_shared/components/project_avatar/default.vue
@@ -26,7 +26,7 @@ export default {
</script>
<template>
- <span :class="sizeClass" class="avatar-container project-avatar">
+ <span :class="sizeClass" class="avatar-container rect-avatar project-avatar">
<project-avatar-image
v-if="project.avatar_url"
:link-href="project.path"
@@ -34,6 +34,12 @@ export default {
:img-alt="project.name"
:img-size="size"
/>
- <identicon v-else :entity-id="project.id" :entity-name="project.name" :size-class="sizeClass" />
+ <identicon
+ v-else
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :size-class="sizeClass"
+ class="rect-avatar"
+ />
</span>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
new file mode 100644
index 00000000000..071bae7f665
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_list_item.vue
@@ -0,0 +1,74 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import Icon from '~/vue_shared/components/icon.vue';
+import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
+import highlight from '~/lib/utils/highlight';
+import { truncateNamespace } from '~/lib/utils/text_utility';
+import _ from 'underscore';
+
+export default {
+ name: 'ProjectListItem',
+ components: {
+ Icon,
+ ProjectAvatar,
+ GlButton,
+ },
+ props: {
+ project: {
+ type: Object,
+ required: true,
+ validator: p => _.isFinite(p.id) && _.isString(p.name) && _.isString(p.name_with_namespace),
+ },
+ selected: {
+ type: Boolean,
+ required: true,
+ },
+ matcher: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ truncatedNamespace() {
+ return truncateNamespace(this.project.name_with_namespace);
+ },
+ highlightedProjectName() {
+ return highlight(this.project.name, this.matcher);
+ },
+ },
+ methods: {
+ onClick() {
+ this.$emit('click');
+ },
+ },
+};
+</script>
+<template>
+ <gl-button
+ class="d-flex align-items-center btn pt-1 pb-1 border-0 project-list-item"
+ @click="onClick"
+ >
+ <icon
+ class="prepend-left-10 append-right-10 flex-shrink-0 position-top-0 js-selected-icon"
+ :class="{ 'js-selected visible': selected, 'js-unselected invisible': !selected }"
+ name="mobile-issue-close"
+ />
+ <project-avatar class="flex-shrink-0 js-project-avatar" :project="project" :size="32" />
+ <div class="d-flex flex-wrap project-namespace-name-container">
+ <div
+ v-if="truncatedNamespace"
+ :title="project.name_with_namespace"
+ class="text-secondary text-truncate js-project-namespace"
+ >
+ {{ truncatedNamespace }}
+ <span v-if="truncatedNamespace" class="text-secondary">/&nbsp;</span>
+ </div>
+ <div
+ :title="project.name"
+ class="js-project-name text-truncate"
+ v-html="highlightedProjectName"
+ ></div>
+ </div>
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
new file mode 100644
index 00000000000..596fd48f96a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue
@@ -0,0 +1,103 @@
+<script>
+import _ from 'underscore';
+import { GlLoadingIcon } from '@gitlab/ui';
+import ProjectListItem from './project_list_item.vue';
+
+const SEARCH_INPUT_TIMEOUT_MS = 500;
+
+export default {
+ name: 'ProjectSelector',
+ components: {
+ GlLoadingIcon,
+ ProjectListItem,
+ },
+ props: {
+ projectSearchResults: {
+ type: Array,
+ required: true,
+ },
+ selectedProjects: {
+ type: Array,
+ required: true,
+ },
+ showNoResultsMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showMinimumSearchQueryMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showLoadingIndicator: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ showSearchErrorMessage: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ searchQuery: '',
+ };
+ },
+ methods: {
+ projectClicked(project) {
+ this.$emit('projectClicked', project);
+ },
+ isSelected(project) {
+ return Boolean(_.findWhere(this.selectedProjects, { id: project.id }));
+ },
+ focusSearchInput() {
+ this.$refs.searchInput.focus();
+ },
+ onInput: _.debounce(function debouncedOnInput() {
+ this.$emit('searched', this.searchQuery);
+ }, SEARCH_INPUT_TIMEOUT_MS),
+ },
+};
+</script>
+<template>
+ <div>
+ <input
+ ref="searchInput"
+ v-model="searchQuery"
+ :placeholder="__('Search your projects')"
+ type="search"
+ class="form-control mb-3 js-project-selector-input"
+ autofocus
+ @input="onInput"
+ />
+ <div class="d-flex flex-column">
+ <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" />
+ <div v-if="!showLoadingIndicator" class="d-flex flex-column">
+ <project-list-item
+ v-for="project in projectSearchResults"
+ :key="project.id"
+ :selected="isSelected(project)"
+ :project="project"
+ :matcher="searchQuery"
+ class="js-project-list-item"
+ @click="projectClicked(project)"
+ />
+ </div>
+ <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message">
+ {{ __('Sorry, no projects matched your search') }}
+ </div>
+ <div
+ v-if="showMinimumSearchQueryMessage"
+ class="text-muted ml-2 js-minimum-search-query-message"
+ >
+ {{ __('Enter at least three characters to search') }}
+ </div>
+ <div v-if="showSearchErrorMessage" class="text-danger ml-2 js-search-error-message">
+ {{ __('Something went wrong, unable to search projects') }}
+ </div>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
index 1c6c3fc4734..df19906309c 100644
--- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
+++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue
@@ -19,7 +19,7 @@ export default {
data() {
return {
script: {},
- scriptSrc: 'https://www.google.com/recaptcha/api.js',
+ scriptSrc: 'https://www.recaptcha.net/recaptcha/api.js',
};
},
diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
new file mode 100644
index 00000000000..1f3d248e991
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue
@@ -0,0 +1,40 @@
+<script>
+import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
+import $ from 'jquery';
+
+export default {
+ data() {
+ return {
+ width: 0,
+ height: 0,
+ };
+ },
+ beforeDestroy() {
+ this.contentResizeHandler.off('content.resize', this.debouncedResize);
+ window.removeEventListener('resize', this.debouncedResize);
+ },
+ created() {
+ this.debouncedResize = debounceByAnimationFrame(this.onResize);
+
+ // Handle when we explicictly trigger a custom resize event
+ this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize);
+
+ // Handle window resize
+ window.addEventListener('resize', this.debouncedResize);
+ },
+ methods: {
+ onResize() {
+ // Slot dimensions
+ const { clientWidth, clientHeight } = this.$refs.chartWrapper;
+ this.width = clientWidth;
+ this.height = clientHeight;
+ },
+ },
+};
+</script>
+
+<template>
+ <div ref="chartWrapper">
+ <slot :width="width" :height="height"> </slot>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/select2_select.vue b/app/assets/javascripts/vue_shared/components/select2_select.vue
new file mode 100644
index 00000000000..6d2612556ff
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/select2_select.vue
@@ -0,0 +1,35 @@
+<script>
+import $ from 'jquery';
+import 'select2';
+
+export default {
+ name: 'Select2Select',
+ props: {
+ options: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ value: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+
+ mounted() {
+ $(this.$refs.dropdownInput)
+ .val(this.value)
+ .select2(this.options)
+ .on('change', event => this.$emit('input', event.target.value));
+ },
+
+ beforeDestroy() {
+ $(this.$refs.dropdownInput).select2('destroy');
+ },
+};
+</script>
+
+<template>
+ <input ref="dropdownInput" type="hidden" />
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
index 82067129c57..45f01a6fced 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/date_picker.vue
@@ -4,6 +4,7 @@ import datePicker from '../pikaday.vue';
import toggleSidebar from './toggle_sidebar.vue';
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
import { dateInWords } from '../../../lib/utils/datetime_utility';
+import { __ } from '~/locale';
export default {
name: 'SidebarDatePicker',
@@ -42,7 +43,7 @@ export default {
label: {
type: String,
required: false,
- default: 'Date picker',
+ default: __('Date picker'),
},
selectedDate: {
type: Date,
@@ -134,7 +135,7 @@ export default {
<button
type="button"
class="btn-blank btn-link btn-secondary-hover-link"
- @click="newDateSelected(null);"
+ @click="newDateSelected(null)"
>
remove
</button>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
index f66e81b1e08..9c258c4651f 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
@@ -75,6 +75,16 @@ export default {
required: false,
default: false,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
hiddenInputName() {
@@ -123,7 +133,12 @@ export default {
@onValueClick="handleCollapsedValueClick"
/>
<dropdown-title :can-edit="canEdit" />
- <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath">
+ <dropdown-value
+ :labels="context.labels"
+ :label-filter-base-path="labelFilterBasePath"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
+ >
<slot></slot>
</dropdown-value>
<div v-if="canEdit" class="selectbox js-selectbox" style="display: none;">
@@ -142,6 +157,8 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ :enable-scoped-labels="enableScopedLabels"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
index 498b507d11d..1eed8907bb7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
@@ -31,6 +31,16 @@ export default {
type: Boolean,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ require: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ require: false,
+ default: '#',
+ },
},
computed: {
dropdownToggleText() {
@@ -61,6 +71,8 @@ export default {
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
+ :data-scoped-labels="enableScopedLabels"
+ :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
index 6faf3fafad1..4abf7c478ee 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
@@ -1,9 +1,12 @@
<script>
-import tooltip from '~/vue_shared/directives/tooltip';
+import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue';
+import DropdownValueRegularLabel from './dropdown_value_regular_label.vue';
+import { isScopedLabel } from '~/lib/utils/common_utils';
export default {
- directives: {
- tooltip,
+ components: {
+ DropdownValueScopedLabel,
+ DropdownValueRegularLabel,
},
props: {
labels: {
@@ -14,6 +17,16 @@ export default {
type: String,
required: true,
},
+ enableScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: false,
+ default: '#',
+ },
},
computed: {
isEmpty() {
@@ -30,6 +43,12 @@ export default {
backgroundColor: label.color,
};
},
+ scopedLabelsDescription({ description = '' }) {
+ return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
+ },
+ showScopedLabels(label) {
+ return this.enableScopedLabels && isScopedLabel(label);
+ },
},
};
</script>
@@ -44,17 +63,24 @@ export default {
<span v-if="isEmpty" class="text-secondary">
<slot>{{ __('None') }}</slot>
</span>
- <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)">
- <span
- v-tooltip
- :style="labelStyle(label)"
- :title="label.description"
- class="badge color-label"
- data-placement="bottom"
- data-container="body"
- >
- {{ label.title }}
- </span>
- </a>
+
+ <template v-for="label in labels" v-else>
+ <dropdown-value-scoped-label
+ v-if="showScopedLabels(label)"
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
+ />
+
+ <dropdown-value-regular-label
+ v-else
+ :key="label.id"
+ :label="label"
+ :label-filter-url="labelFilterUrl(label)"
+ :label-style="labelStyle(label)"
+ />
+ </template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
index 373794fb1f2..05446903286 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
@@ -14,10 +14,12 @@ export default {
},
computed: {
labelsList() {
- const labelsString = this.labels
- .slice(0, 5)
- .map(label => label.title)
- .join(', ');
+ const labelsString = this.labels.length
+ ? this.labels
+ .slice(0, 5)
+ .map(label => label.title)
+ .join(', ')
+ : s__('LabelSelect|Labels');
if (this.labels.length > 5) {
return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
new file mode 100644
index 00000000000..282b181f11e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <a ref="regularLabelRef" :href="labelFilterUrl">
+ <span :style="labelStyle" class="badge color-label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport">
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
new file mode 100644
index 00000000000..ad5a86de166
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue
@@ -0,0 +1,47 @@
+<script>
+import { GlLink, GlTooltip } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlTooltip,
+ GlLink,
+ },
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ labelStyle: {
+ type: Object,
+ required: true,
+ },
+ scopedLabelsDocumentationLink: {
+ type: String,
+ required: true,
+ },
+ labelFilterUrl: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <span class="d-inline-block position-relative scoped-label-wrapper">
+ <a :href="labelFilterUrl">
+ <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
+ {{ label.title }}
+ </span>
+ <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
+ <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
+ ><br />
+ {{ label.description }}
+ </gl-tooltip>
+ </a>
+
+ <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
+ ><i class="fa fa-question-circle" :style="labelStyle"></i
+ ></gl-link>
+ </span>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/svg_gradient.vue b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
index cca90af275e..5ce45d492f9 100644
--- a/app/assets/javascripts/vue_shared/components/svg_gradient.vue
+++ b/app/assets/javascripts/vue_shared/components/svg_gradient.vue
@@ -4,10 +4,16 @@ export default {
colors: {
type: Array,
required: true,
+ validator(value) {
+ return value.length === 2;
+ },
},
opacity: {
type: Array,
required: true,
+ validator(value) {
+ return value.length === 2;
+ },
},
identifierName: {
type: String,
diff --git a/app/assets/javascripts/vue_shared/components/table_pagination.vue b/app/assets/javascripts/vue_shared/components/table_pagination.vue
index 01e655d27e5..8e0b08032f7 100644
--- a/app/assets/javascripts/vue_shared/components/table_pagination.vue
+++ b/app/assets/javascripts/vue_shared/components/table_pagination.vue
@@ -54,15 +54,14 @@ export default {
return this.pageInfo.nextPage;
},
getItems() {
- const total = this.pageInfo.totalPages;
- const { page } = this.pageInfo;
+ const { totalPages, nextPage, previousPage, page } = this.pageInfo;
const items = [];
if (page > 1) {
items.push({ title: FIRST, first: true });
}
- if (page > 1) {
+ if (previousPage) {
items.push({ title: PREV, prev: true });
} else {
items.push({ title: PREV, disabled: true, prev: true });
@@ -70,32 +69,34 @@ export default {
if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true });
- const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
- const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total);
+ if (totalPages) {
+ const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1);
+ const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, totalPages);
- for (let i = start; i <= end; i += 1) {
- const isActive = i === page;
- items.push({ title: i, active: isActive, page: true });
- }
+ for (let i = start; i <= end; i += 1) {
+ const isActive = i === page;
+ items.push({ title: i, active: isActive, page: true });
+ }
- if (total - page > PAGINATION_UI_BUTTON_LIMIT) {
- items.push({ title: SPREAD, separator: true, page: true });
+ if (totalPages - page > PAGINATION_UI_BUTTON_LIMIT) {
+ items.push({ title: SPREAD, separator: true, page: true });
+ }
}
- if (page === total) {
- items.push({ title: NEXT, disabled: true, next: true });
- } else if (total - page >= 1) {
+ if (nextPage) {
items.push({ title: NEXT, next: true });
+ } else {
+ items.push({ title: NEXT, disabled: true, next: true });
}
- if (total - page >= 1) {
+ if (totalPages && totalPages - page >= 1) {
items.push({ title: LAST, last: true });
}
return items;
},
showPagination() {
- return this.pageInfo.totalPages > 1;
+ return this.pageInfo.nextPage || this.pageInfo.previousPage;
},
},
methods: {
@@ -149,9 +150,9 @@ export default {
}"
class="page-item"
>
- <a class="page-link" @click.prevent="changePage(item.title, item.disabled);">
+ <button type="button" class="page-link" @click="changePage(item.title, item.disabled)">
{{ item.title }}
- </a>
+ </button>
</li>
</ul>
</div>
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
index e833a8e0483..a6c1737dcab 100644
--- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
@@ -67,7 +67,9 @@ export default {
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
- if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`;
+ // Only adds the width to the URL if its not a base64 data image
+ if (!(baseSrc.indexOf('data:') === 0) && !baseSrc.includes('?'))
+ baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
new file mode 100644
index 00000000000..8eaf8386b99
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue
@@ -0,0 +1,89 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { sprintf, __ } from '~/locale';
+import UserAvatarLink from './user_avatar_link.vue';
+
+export default {
+ components: {
+ UserAvatarLink,
+ GlButton,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: true,
+ },
+ breakpoint: {
+ type: Number,
+ required: false,
+ default: 10,
+ },
+ imgSize: {
+ type: Number,
+ required: false,
+ default: 20,
+ },
+ emptyText: {
+ type: String,
+ required: false,
+ default: __('None'),
+ },
+ },
+ data() {
+ return {
+ isExpanded: false,
+ };
+ },
+ computed: {
+ visibleItems() {
+ if (!this.hasHiddenItems) {
+ return this.items;
+ }
+
+ return this.items.slice(0, this.breakpoint);
+ },
+ hasHiddenItems() {
+ return this.hasBreakpoint && !this.isExpanded && this.items.length > this.breakpoint;
+ },
+ hasBreakpoint() {
+ return this.breakpoint > 0 && this.items.length > this.breakpoint;
+ },
+ expandText() {
+ if (!this.hasHiddenItems) {
+ return '';
+ }
+
+ const count = this.items.length - this.breakpoint;
+
+ return sprintf(__('%{count} more'), { count });
+ },
+ },
+ methods: {
+ expand() {
+ this.isExpanded = true;
+ },
+ collapse() {
+ this.isExpanded = false;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="!items.length">{{ emptyText }}</div>
+ <div v-else>
+ <user-avatar-link
+ v-for="item in visibleItems"
+ :key="item.id"
+ :link-href="item.web_url"
+ :img-src="item.avatar_url"
+ :img-alt="item.name"
+ :tooltip-text="item.name"
+ :img-size="imgSize"
+ />
+ <template v-if="hasBreakpoint">
+ <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> {{ expandText }} </gl-button>
+ <gl-button v-else variant="link" @click="collapse"> {{ __('show less') }} </gl-button>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
index 7fbadcc0111..a60d5eb491e 100644
--- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
+++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue
@@ -1,12 +1,13 @@
<script>
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
-import { __, sprintf } from '~/locale';
+import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
export default {
name: 'UserPopover',
components: {
+ Icon,
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
@@ -28,24 +29,11 @@ export default {
},
},
computed: {
- jobLine() {
- if (this.user.bio && this.user.organization) {
- return sprintf(__('%{bio} at %{organization}'), {
- bio: this.user.bio,
- organization: this.user.organization,
- });
- } else if (this.user.bio) {
- return this.user.bio;
- } else if (this.user.organization) {
- return this.user.organization;
- }
- return null;
- },
statusHtml() {
- if (this.user.status.emoji && this.user.status.message) {
- return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`;
- } else if (this.user.status.message) {
- return this.user.status.message;
+ if (this.user.status.emoji && this.user.status.message_html) {
+ return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message_html}`;
+ } else if (this.user.status.message_html) {
+ return this.user.status.message_html;
}
return '';
},
@@ -82,15 +70,31 @@ export default {
<gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
</div>
<div class="text-secondary">
- {{ jobLine }}
+ <div v-if="user.bio" class="js-bio d-flex mb-1">
+ <icon name="profile" css-classes="category-icon flex-shrink-0" />
+ <span class="ml-1">{{ user.bio }}</span>
+ </div>
+ <div v-if="user.organization" class="js-organization d-flex mb-1">
+ <icon
+ v-show="!jobInfoIsLoading"
+ name="work"
+ css-classes="category-icon flex-shrink-0"
+ />
+ <span class="ml-1">{{ user.organization }}</span>
+ </div>
<gl-skeleton-loading
v-if="jobInfoIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</div>
- <div class="text-secondary">
- {{ user.location }}
+ <div class="js-location text-secondary d-flex">
+ <icon
+ v-show="!locationIsLoading && user.location"
+ name="location"
+ css-classes="category-icon flex-shrink-0"
+ />
+ <span class="ml-1">{{ user.location }}</span>
<gl-skeleton-loading
v-if="locationIsLoading"
:lines="1"
diff --git a/app/assets/javascripts/vue_shared/directives/tooltip.js b/app/assets/javascripts/vue_shared/directives/tooltip.js
index 549d27e96d9..2d1f7a1cfd0 100644
--- a/app/assets/javascripts/vue_shared/directives/tooltip.js
+++ b/app/assets/javascripts/vue_shared/directives/tooltip.js
@@ -1,4 +1,5 @@
import $ from 'jquery';
+import '~/commons/bootstrap';
export default {
bind(el) {
diff --git a/app/assets/javascripts/vue_shared/mixins/is_ee.js b/app/assets/javascripts/vue_shared/mixins/is_ee.js
new file mode 100644
index 00000000000..8e00d93ef18
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/is_ee.js
@@ -0,0 +1,10 @@
+import Vue from 'vue';
+import { isEE } from '~/lib/utils/common_utils';
+
+Vue.mixin({
+ computed: {
+ isEE() {
+ return isEE();
+ },
+ },
+});
diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
new file mode 100644
index 00000000000..8e0e4baa75a
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js
@@ -0,0 +1,217 @@
+import _ from 'underscore';
+import { sprintf, __ } from '~/locale';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import tooltip from '~/vue_shared/directives/tooltip';
+import icon from '~/vue_shared/components/icon.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+const mixins = {
+ data() {
+ return {
+ removeDisabled: false,
+ };
+ },
+ props: {
+ idKey: {
+ type: Number,
+ required: true,
+ },
+ displayReference: {
+ type: String,
+ required: true,
+ },
+ pathIdSeparator: {
+ type: String,
+ required: true,
+ },
+ eventNamespace: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ confidential: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ title: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ path: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ state: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ createdAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ closedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ mergedAt: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ milestone: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ dueDate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ assignees: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ weight: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ canRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ isMergeRequest: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pipelineStatus: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ components: {
+ icon,
+ },
+ directives: {
+ tooltip,
+ },
+ mixins: [timeagoMixin],
+ computed: {
+ hasState() {
+ return this.state && this.state.length > 0;
+ },
+ hasPipeline() {
+ return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
+ },
+ isOpen() {
+ return this.state === 'opened';
+ },
+ isClosed() {
+ return this.state === 'closed';
+ },
+ isMerged() {
+ return this.state === 'merged';
+ },
+ hasTitle() {
+ return this.title.length > 0;
+ },
+ hasMilestone() {
+ return !_.isEmpty(this.milestone);
+ },
+ iconName() {
+ if (this.isMergeRequest && this.isMerged) {
+ return 'merge';
+ }
+
+ return this.isOpen ? 'issue-open-m' : 'issue-close';
+ },
+ iconClass() {
+ if (this.isMergeRequest && this.isClosed) {
+ return 'merge-request-status closed issue-token-state-icon-closed';
+ }
+
+ return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
+ },
+ computedLinkElementType() {
+ return this.path.length > 0 ? 'a' : 'span';
+ },
+ computedPath() {
+ return this.path.length ? this.path : null;
+ },
+ itemPath() {
+ return this.displayReference.split(this.pathIdSeparator)[0];
+ },
+ itemId() {
+ return this.displayReference.split(this.pathIdSeparator).pop();
+ },
+ createdAtInWords() {
+ return this.createdAt ? this.timeFormated(this.createdAt) : '';
+ },
+ createdAtTimestamp() {
+ return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
+ },
+ mergedAtTimestamp() {
+ return this.mergedAt ? formatDate(new Date(this.mergedAt)) : '';
+ },
+ mergedAtInWords() {
+ return this.mergedAt ? this.timeFormated(this.mergedAt) : '';
+ },
+ closedAtInWords() {
+ return this.closedAt ? this.timeFormated(this.closedAt) : '';
+ },
+ closedAtTimestamp() {
+ return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
+ },
+ stateText() {
+ if (this.isMerged) {
+ return __('Merged');
+ }
+
+ return this.isOpen ? __('Opened') : __('Closed');
+ },
+ stateTimeInWords() {
+ if (this.isMerged) {
+ return this.mergedAtInWords;
+ }
+
+ return this.isOpen ? this.createdAtInWords : this.closedAtInWords;
+ },
+ stateTimestamp() {
+ if (this.isMerged) {
+ return this.mergedAtTimestamp;
+ }
+
+ return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp;
+ },
+ pipelineStatusTooltip() {
+ return this.hasPipeline
+ ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label })
+ : '';
+ },
+ },
+ methods: {
+ onRemoveRequest() {
+ let namespacePrefix = '';
+ if (this.eventNamespace && this.eventNamespace.length > 0) {
+ namespacePrefix = `${this.eventNamespace}`;
+ }
+
+ this.$emit(`${namespacePrefix}RemoveRequest`, this.idKey);
+
+ this.removeDisabled = true;
+ },
+ },
+};
+
+export default mixins;
diff --git a/app/assets/javascripts/vuex_shared/modules/modal/actions.js b/app/assets/javascripts/vuex_shared/modules/modal/actions.js
new file mode 100644
index 00000000000..7b209909f69
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/modal/actions.js
@@ -0,0 +1,20 @@
+import * as types from './mutation_types';
+
+export const open = ({ commit }, data) => {
+ commit(types.OPEN, data);
+};
+
+export const close = ({ commit }) => {
+ commit(types.CLOSE);
+};
+
+export const show = ({ commit }) => {
+ commit(types.SHOW);
+};
+
+export const hide = ({ commit }) => {
+ commit(types.HIDE);
+};
+
+// prevent babel-plugin-rewire from generating an invalid default during karma tests
+export default () => {};
diff --git a/app/assets/javascripts/vuex_shared/modules/modal/index.js b/app/assets/javascripts/vuex_shared/modules/modal/index.js
new file mode 100644
index 00000000000..c349d875c24
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/modal/index.js
@@ -0,0 +1,10 @@
+import state from './state';
+import mutations from './mutations';
+import * as actions from './actions';
+
+export default () => ({
+ namespaced: true,
+ state: state(),
+ mutations,
+ actions,
+});
diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js
new file mode 100644
index 00000000000..f8259736009
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/modal/mutation_types.js
@@ -0,0 +1,4 @@
+export const HIDE = 'HIDE';
+export const SHOW = 'SHOW';
+export const OPEN = 'OPEN';
+export const CLOSE = 'CLOSE';
diff --git a/app/assets/javascripts/vuex_shared/modules/modal/mutations.js b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js
new file mode 100644
index 00000000000..9e96ae8b5a9
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/modal/mutations.js
@@ -0,0 +1,18 @@
+import * as types from './mutation_types';
+
+export default {
+ [types.SHOW](state) {
+ state.isVisible = true;
+ },
+ [types.HIDE](state) {
+ state.isVisible = false;
+ },
+ [types.OPEN](state, data) {
+ state.data = data;
+ state.isVisible = true;
+ },
+ [types.CLOSE](state) {
+ state.data = null;
+ state.isVisible = false;
+ },
+};
diff --git a/app/assets/javascripts/vuex_shared/modules/modal/state.js b/app/assets/javascripts/vuex_shared/modules/modal/state.js
new file mode 100644
index 00000000000..5d0955aa9b0
--- /dev/null
+++ b/app/assets/javascripts/vuex_shared/modules/modal/state.js
@@ -0,0 +1,4 @@
+export default () => ({
+ isVisible: false,
+ data: null,
+});
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index 985fac11c87..a2f518cd24e 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -2,53 +2,36 @@
* This is a manifest file that'll automatically include all the stylesheets available in this directory
* and any sub-directories. You're free to add application-wide styles to this file and they'll appear at
* the top of the compiled file, but it's generally better to create a new file per style scope.
- *= require jquery.atwho
- *= require select2
*= require_self
*= require cropper.css
*/
-/*
- * Welcome to GitLab css!
- * If you need to add or modify UI component that is common for many pages
- * like a table or typography then make changes in the framework/ directory.
- * If you need to add unique style that should affect only one page - use pages/
- * directory.
- */
-
+// Welcome to GitLab css!
+// If you need to add or modify UI component that is common for many pages
+// like a table or typography then make changes in the framework/ directory.
+// If you need to add unique style that should affect only one page - use pages/
+// directory.
+@import "../../../node_modules/at.js/dist/css/jquery.atwho";
@import "../../../node_modules/pikaday/scss/pikaday";
@import "../../../node_modules/dropzone/dist/basic";
+@import "../../../node_modules/select2/select2";
-/*
- * GitLab UI framework
- */
+// GitLab UI framework
@import "framework";
-/*
- * Font icons
- */
+// Font icons
@import "font-awesome";
-/*
- * Page specific styles (issues, projects etc):
- */
+// Page specific styles (issues, projects etc):
@import "pages/**/*";
-/*
- * Component specific styles, will be moved to gitlab-ui
- */
+// Component specific styles, will be moved to gitlab-ui
@import "components/**/*";
-/*
- * Code highlight
- */
-@import "highlight/dark";
-@import "highlight/monokai";
-@import "highlight/solarized_dark";
-@import "highlight/solarized_light";
-@import "highlight/white";
+// Vendors specific styles
+@import "vendors/**/*";
-/*
- * Styles for JS behaviors.
- */
+// Styles for JS behaviors.
@import "behaviors";
+
+@import "utilities";
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index f0671e36130..93377b8dd91 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -70,6 +70,17 @@ h6,
margin-bottom: 10px;
}
+/* Our adjustments to hx & .hx above add unnecessary margins to modal-title
+ and page-title in modals, so we set them to 0 in order to have properly
+ formatted modal headers. */
+.modal-header {
+ .modal-title,
+ .page-title {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+}
+
h5,
.h5 {
font-size: $gl-font-size;
@@ -134,7 +145,8 @@ table {
pointer-events: none;
}
-.popover {
+.popover,
+.popover-header {
font-size: 14px;
}
@@ -142,7 +154,9 @@ table {
@include media-breakpoint-up($breakpoint) {
$infix: breakpoint-infix($breakpoint, $grid-breakpoints);
- .d#{$infix}-table-header-group { display: table-header-group !important; }
+ .d#{$infix}-table-header-group {
+ display: table-header-group !important;
+ }
}
}
@@ -211,7 +225,7 @@ h3.popover-header {
}
.info-well {
- background: $theme-gray-50;
+ background: $gray-50;
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 4px;
@@ -329,16 +343,6 @@ input[type=color].form-control {
}
}
-// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
-.input-group-btn:first-child {
- @extend .input-group-prepend;
-}
-
-// Bootstrap 3 compatibility because bootstrap_form Gem is not updated yet
-.input-group-btn:last-child {
- @extend .input-group-append;
-}
-
/*
Bootstrap 4.1.2 introduced a new default vertical alignment which breaks our icons,
so we need to reset the vertical alignment to the default value. See:
diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss
new file mode 100644
index 00000000000..25ee3ca944d
--- /dev/null
+++ b/app/assets/stylesheets/components/avatar.scss
@@ -0,0 +1,232 @@
+$avatar-sizes: (
+ 16: (
+ font-size: 10px,
+ line-height: 16px,
+ border-radius: $border-radius-small
+ ),
+ 18: (
+ border-radius: $border-radius-small
+ ),
+ 19: (
+ border-radius: $border-radius-small
+ ),
+ 20: (
+ border-radius: $border-radius-small
+ ),
+ 24: (
+ font-size: 12px,
+ line-height: 24px,
+ border-radius: $border-radius-default
+ ),
+ 26: (
+ font-size: 20px,
+ line-height: 1.33,
+ border-radius: $border-radius-default
+ ),
+ 32: (
+ font-size: 14px,
+ line-height: 32px,
+ border-radius: $border-radius-default
+ ),
+ 36: (
+ border-radius: $border-radius-default
+ ),
+ 40: (
+ font-size: 16px,
+ line-height: 38px,
+ border-radius: $border-radius-default
+ ),
+ 46: (
+ border-radius: $border-radius-default
+ ),
+ 48: (
+ font-size: 20px,
+ line-height: 48px,
+ border-radius: $border-radius-large
+ ),
+ 60: (
+ font-size: 32px,
+ line-height: 58px,
+ border-radius: $border-radius-large
+ ),
+ 64: (
+ font-size: 28px,
+ line-height: 64px,
+ border-radius: $border-radius-large
+ ),
+ 70: (
+ font-size: 34px,
+ line-height: 70px,
+ border-radius: $border-radius-large
+ ),
+ 90: (
+ font-size: 36px,
+ line-height: 88px,
+ border-radius: $border-radius-large
+ ),
+ 96: (
+ font-size: 48px,
+ line-height: 96px,
+ border-radius: $border-radius-large
+ ),
+ 100: (
+ font-size: 36px,
+ line-height: 98px,
+ border-radius: $border-radius-large
+ ),
+ 110: (
+ font-size: 40px,
+ line-height: 108px,
+ font-weight: $gl-font-weight-normal,
+ border-radius: $border-radius-large
+ ),
+ 140: (
+ font-size: 72px,
+ line-height: 138px,
+ border-radius: $border-radius-large
+ ),
+ 160: (
+ font-size: 96px,
+ line-height: 158px,
+ border-radius: $border-radius-large
+ )
+);
+
+$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
+ $identicon-orange, $gray-darker;
+
+.avatar-circle {
+ float: left;
+ margin-right: 15px;
+ border-radius: $avatar-radius;
+ border: 1px solid $gray-normal;
+
+ @each $size, $size-config in $avatar-sizes {
+ &.s#{$size} {
+ @include avatar-size(#{$size}px, if($size < 36, 8px, 16px));
+ }
+ }
+}
+
+.avatar {
+ @extend .avatar-circle;
+ transition-property: none;
+
+ width: 40px;
+ height: 40px;
+ padding: 0;
+ background: $gray-lightest;
+ overflow: hidden;
+ border-color: rgba($black, $gl-avatar-border-opacity);
+
+ &.avatar-inline {
+ float: none;
+ display: inline-block;
+ margin-left: 2px;
+ flex-shrink: 0;
+
+ &.s16 {
+ margin-right: 4px;
+ }
+
+ &.s24 {
+ margin-right: 4px;
+ }
+ }
+
+ &.center {
+ font-size: 14px;
+ line-height: 1.8em;
+ text-align: center;
+ }
+
+ &.avatar-tile {
+ border-radius: 0;
+ border: 0;
+ }
+
+ &.avatar-placeholder {
+ border: 0;
+ }
+}
+
+.identicon {
+ text-align: center;
+ vertical-align: top;
+ color: $gray-800;
+ background-color: $gray-darker;
+
+ // Sizes
+ @each $size, $size-config in $avatar-sizes {
+ $keys: map-keys($size-config);
+
+ &.s#{$size} {
+ @each $key in $keys {
+ // We don't want `border-radius` to be included here.
+ @if ($key != 'border-radius') {
+ #{$key}: map-get($size-config, #{$key});
+ }
+ }
+ }
+ }
+
+ // Background colors
+ @for $i from 1 through length($identicon-backgrounds) {
+ &.bg#{$i} {
+ background-color: nth($identicon-backgrounds, $i);
+ }
+ }
+}
+
+.avatar-container {
+ @extend .avatar-circle;
+ overflow: hidden;
+ display: flex;
+
+ a {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ text-decoration: none;
+ }
+
+ .avatar {
+ border-radius: 0;
+ border: 0;
+ height: auto;
+ width: 100%;
+ margin: 0;
+ align-self: center;
+ }
+
+ &.s40 {
+ min-width: 40px;
+ min-height: 40px;
+ }
+
+ &.s64 {
+ min-width: 64px;
+ min-height: 64px;
+ }
+}
+
+.rect-avatar {
+ border-radius: $border-radius-small;
+
+ @each $size, $size-config in $avatar-sizes {
+ &.s#{$size} {
+ border-radius: map-get($size-config, 'border-radius');
+ }
+ }
+}
+
+.avatar-counter {
+ background-color: $gray-darkest;
+ color: $white-light;
+ border: 1px solid $gray-normal;
+ border-radius: 1em;
+ font-family: $regular-font;
+ font-size: 9px;
+ line-height: 16px;
+ text-align: center;
+}
diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss
new file mode 100644
index 00000000000..a104d035a9a
--- /dev/null
+++ b/app/assets/stylesheets/components/dashboard_skeleton.scss
@@ -0,0 +1,77 @@
+.dashboard-cards {
+ margin-right: -$gl-padding-8;
+ margin-left: -$gl-padding-8;
+}
+
+.dashboard-card {
+ &-header {
+ &-warning {
+ background-color: $orange-100;
+ }
+ }
+
+ &-body {
+ min-height: 120px;
+
+ &-warning {
+ background-color: $orange-50;
+ }
+
+ &-failed {
+ background-color: $red-50;
+ }
+ }
+
+ &-icon {
+ color: $gray-500;
+ }
+
+ &-footer {
+ border-radius: $gl-padding;
+ height: $gl-padding-32;
+
+ &-arrow {
+ color: $gray-300;
+ }
+
+ &-downstream {
+ margin-right: -$gl-padding-8;
+ }
+
+ &-extra {
+ background-color: $gray-400;
+ font-size: 10px;
+ line-height: $gl-line-height;
+ width: $gl-padding;
+ }
+ }
+
+ &-header,
+ &-footer {
+ &-failed {
+ background-color: $red-100;
+ }
+ }
+
+ &-skeleton-info {
+ border-radius: $gl-padding;
+ height: $gl-padding;
+ overflow: hidden;
+
+ &::after {
+ content: ' ';
+ display: block;
+ animation: blockTextShine 1s linear infinite forwards;
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-image: linear-gradient(to right,
+ $gray-100 0%,
+ $gray-50 20%,
+ $gray-100 40%,
+ $gray-100 100%);
+ border-radius: $gl-padding;
+ height: $gl-padding;
+ margin-top: -$gl-padding-8;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
index 2f4d30fe923..d0aa6ec78aa 100644
--- a/app/assets/stylesheets/components/popover.scss
+++ b/app/assets/stylesheets/components/popover.scss
@@ -5,5 +5,49 @@
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
+
+ .category-icon {
+ color: $gray-600;
+ }
+ }
+
+ &.blue {
+ background-color: $blue-600;
+
+ .popover-body {
+ color: $white-light;
+ }
+
+ &.bs-popover-bottom {
+ .arrow::after {
+ border-bottom-color: $blue-600;
+ }
+ }
+
+ &.bs-popover-top {
+ .arrow::after {
+ border-top-color: $blue-600;
+ }
+ }
+ }
+}
+
+.mr-popover {
+ .text-secondary {
+ font-size: 12px;
+ line-height: 1.33;
+ }
+}
+
+.onboarding-welcome-page {
+ .popover {
+ min-width: auto;
+ max-width: 40%;
+
+ .popover-body {
+ padding-top: $gl-padding;
+ padding-bottom: $gl-padding;
+ font-size: $gl-font-size-small;
+ }
}
}
diff --git a/app/assets/stylesheets/components/project_list_item.scss b/app/assets/stylesheets/components/project_list_item.scss
new file mode 100644
index 00000000000..8e7c2c4398c
--- /dev/null
+++ b/app/assets/stylesheets/components/project_list_item.scss
@@ -0,0 +1,24 @@
+.project-list-item {
+ &:not(:disabled):not(.disabled) {
+ &:focus,
+ &:active,
+ &:focus:active {
+ outline: none;
+ box-shadow: none;
+ }
+ }
+}
+
+// When housed inside a modal, the edge of each item
+// should extend to the edge of the modal.
+.modal-body {
+ .project-list-item {
+ border-radius: 0;
+ margin-left: -$gl-padding;
+ margin-right: -$gl-padding;
+
+ .project-namespace-name-container {
+ overflow: hidden;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss
new file mode 100644
index 00000000000..7f9cf1266b1
--- /dev/null
+++ b/app/assets/stylesheets/components/related_items_list.scss
@@ -0,0 +1,291 @@
+$item-path-max-width: 160px;
+$item-milestone-max-width: 120px;
+$item-weight-max-width: 48px;
+
+.related-items-list {
+ padding: $gl-padding-4;
+
+ &,
+ .list-item:last-child {
+ margin-bottom: 0;
+ }
+}
+
+.sortable-link {
+ max-width: 85%;
+}
+
+.item-body {
+ position: relative;
+ line-height: $gl-line-height;
+
+ .issue-token-state-icon-open {
+ color: $green-500;
+ }
+
+ .issue-token-state-icon-closed {
+ color: $blue-500;
+ }
+
+ .merge-request-status.closed {
+ color: $red-500;
+ }
+
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed,
+ .confidential-icon,
+ .item-milestone .icon,
+ .item-weight .board-card-info-icon {
+ min-width: $gl-padding;
+ cursor: help;
+ }
+
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ margin-right: $gl-padding-4;
+ }
+
+ .confidential-icon {
+ color: $orange-600;
+ }
+
+ .item-title {
+ flex-basis: 100%;
+ font-size: $gl-font-size-small;
+
+ &.mr-title {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ display: none;
+ }
+ }
+
+ .item-path-id .path-id-text,
+ .item-milestone .milestone-title,
+ .item-due-date,
+ .item-weight .board-card-info-text {
+ color: $gl-text-color-secondary;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+}
+
+.item-meta {
+ flex-basis: 100%;
+ font-size: $gl-font-size-small;
+ color: $gl-text-color-secondary;
+
+ .item-meta-child {
+ flex-basis: 100%;
+ }
+
+ .item-milestone,
+ .item-weight {
+ cursor: help;
+ }
+
+ .item-milestone {
+ text-decoration: none;
+ max-width: $item-milestone-max-width;
+
+ .ic-clock {
+ color: $gl-text-color-tertiary;
+ margin-right: $gl-padding-4;
+ }
+ }
+
+ .item-weight {
+ max-width: $item-weight-max-width;
+ }
+
+ .item-assignees {
+ .user-avatar-link {
+ margin-right: -$gl-padding-4;
+
+ &:nth-of-type(1) {
+ z-index: 2;
+ }
+
+ &:nth-of-type(2) {
+ z-index: 1;
+ }
+
+ &:last-child {
+ margin-right: 0;
+ }
+ }
+
+ .avatar {
+ height: $gl-padding;
+ width: $gl-padding;
+ margin-right: 0;
+ vertical-align: bottom;
+ }
+
+ .avatar-counter {
+ height: $gl-padding;
+ border: 1px solid transparent;
+ background-color: $gl-text-color-tertiary;
+ font-weight: $gl-font-weight-bold;
+ padding: 0 $gl-padding-4;
+ line-height: $gl-padding;
+ }
+ }
+}
+
+.item-path-id {
+ font-size: $gl-font-size-xs;
+ white-space: nowrap;
+
+ .path-id-text {
+ font-weight: $gl-font-weight-bold;
+ max-width: $item-path-max-width;
+ }
+
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ display: block;
+ }
+
+ @include media-breakpoint-down(sm) {
+ &:not(.mr-item-path) {
+ order: 1;
+ }
+ }
+}
+
+.btn-item-remove {
+ position: absolute;
+ right: 0;
+ top: $gl-padding-4 / 2;
+ padding: $gl-padding-4;
+ margin-right: $gl-padding-4 / 2;
+ line-height: 0;
+ border-color: transparent;
+ color: $gl-text-color-secondary;
+
+ &:hover {
+ color: $gl-text-color;
+ }
+}
+
+.mr-status-wrapper,
+.mr-ci-status {
+ line-height: 0;
+}
+
+@include media-breakpoint-up(sm) {
+ .sortable-link {
+ max-width: 90%;
+ }
+}
+
+/* Small devices (landscape phones, 768px and up) */
+@include media-breakpoint-up(md) {
+ .sortable-link {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ max-width: 100%;
+ }
+
+ .item-body {
+ .item-contents {
+ min-width: 0;
+ }
+
+ .item-title {
+ flex-basis: unset;
+ // 95% because we compensate
+ // for remove button which is
+ // positioned absolutely
+ width: 95%;
+ }
+
+ .btn-item-remove {
+ order: 1;
+ }
+ }
+
+ .item-meta {
+ .item-meta-child {
+ flex-basis: unset;
+
+ ~ .item-assignees {
+ margin-left: $gl-padding-4;
+ }
+ }
+ }
+}
+
+/* Medium devices (desktops, 992px and up) */
+@include media-breakpoint-up(lg) {
+ .item-body {
+ .item-title {
+ font-size: $gl-font-size;
+ }
+
+ .item-meta .item-path-id {
+ font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
+ }
+ }
+}
+
+/* Large devices (large desktops, 1200px and up) */
+@include media-breakpoint-up(xl) {
+ .item-body {
+ .item-title {
+ min-width: 0;
+ width: auto;
+ flex-basis: unset;
+ font-weight: $gl-font-weight-normal;
+
+ .issue-token-state-icon-open,
+ .issue-token-state-icon-closed {
+ display: block;
+ margin-right: $gl-padding-8;
+ }
+ }
+ }
+
+ .item-contents {
+ overflow: hidden;
+ }
+
+ .item-meta {
+ flex: 1;
+ }
+
+ .item-assignees {
+ .avatar {
+ height: $gl-padding-24;
+ width: $gl-padding-24;
+ }
+
+ .avatar-counter {
+ height: $gl-padding-24;
+ min-width: $gl-padding-24;
+ line-height: $gl-padding-24;
+ border-radius: $gl-padding-24;
+ }
+ }
+
+ .btn-item-remove {
+ position: relative;
+ top: initial;
+ right: 0;
+ padding: $btn-sm-side-margin;
+
+ &:hover {
+ border-color: $border-color;
+ }
+ }
+
+ .sortable-link {
+ line-height: 1.3;
+ }
+}
diff --git a/app/assets/stylesheets/components/toast.scss b/app/assets/stylesheets/components/toast.scss
new file mode 100644
index 00000000000..33e1c4e5349
--- /dev/null
+++ b/app/assets/stylesheets/components/toast.scss
@@ -0,0 +1,53 @@
+/*
+* These styles are specific to the gl-toast component.
+* Documentation: https://design.gitlab.com/components/toasts
+* Note: Styles below are nested in order to override some of vue-toasted's default styling
+*/
+.toasted-container {
+
+ max-width: $toast-max-width;
+
+ @include media-breakpoint-down(xs) {
+ width: 100%;
+ padding-right: $toast-padding-right;
+ }
+
+ .toasted.gl-toast {
+ border-radius: $border-radius-default;
+ font-size: $gl-font-size;
+ padding: $gl-padding-8 $gl-padding-24;
+ margin-top: $toast-default-margin;
+ line-height: $gl-line-height;
+ background-color: rgba($gray-900, $toast-background-opacity);
+
+ @include media-breakpoint-down(xs) {
+ .action:first-child {
+ // Ensures actions buttons are right aligned on mobile
+ margin-left: auto;
+ }
+ }
+
+ .action {
+ color: $blue-300;
+ margin: 0 0 0 $toast-action-margin-left;
+ text-transform: none;
+ font-size: $gl-font-size;
+
+ &:first-child {
+ padding-right: 0;
+ }
+ }
+
+ .toast-close {
+ font-size: $default-icon-size;
+ margin-left: $toast-default-margin;
+ padding-left: $gl-padding;
+ }
+ }
+}
+
+// Overrides the default positioning of toasts
+body .toasted-container.bottom-left {
+ bottom: $toast-offset;
+ left: $toast-offset;
+}
diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss
index 834e7ffce81..9b0d19b0ef0 100644
--- a/app/assets/stylesheets/framework.scss
+++ b/app/assets/stylesheets/framework.scss
@@ -8,7 +8,6 @@
@import 'framework/animations';
@import 'framework/vue_transitions';
-@import 'framework/avatar';
@import 'framework/asciidoctor';
@import 'framework/banner';
@import 'framework/blocks';
@@ -31,7 +30,6 @@
@import 'framework/logo';
@import 'framework/markdown_area';
@import 'framework/media_object';
-@import 'framework/mobile';
@import 'framework/modal';
@import 'framework/pagination';
@import 'framework/panels';
@@ -61,8 +59,11 @@
@import 'framework/memory_graph';
@import 'framework/responsive_tables';
@import 'framework/stacked_progress_bar';
+@import 'framework/sortable';
@import 'framework/ci_variable_list';
@import 'framework/feature_highlight';
@import 'framework/terms';
@import 'framework/read_more';
@import 'framework/flex_grid';
+@import 'framework/system_messages';
+@import "framework/spinner";
diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss
index 549a8730301..257d788873c 100644
--- a/app/assets/stylesheets/framework/animations.scss
+++ b/app/assets/stylesheets/framework/animations.scss
@@ -27,7 +27,7 @@
&.flipOutY,
&.bounceIn,
&.bounceOut {
- @include webkit-prefix(animation-duration, .75s);
+ @include webkit-prefix(animation-duration, 0.75s);
}
&.short {
@@ -63,56 +63,44 @@
//
// Pass in any number of transitions
@mixin transition($transitions...) {
- $unfoldedTransitions: ();
+ $unfolded-transitions: ();
@each $transition in $transitions {
- $unfoldedTransitions: append($unfoldedTransitions, unfoldTransition($transition), comma);
+ $unfolded-transitions: append($unfolded-transitions, unfold-transition($transition), comma);
}
- transition: $unfoldedTransitions;
+ transition: $unfolded-transitions;
}
-@mixin disableAllAnimation {
+@mixin disable-all-animation {
/*CSS transitions*/
- -o-transition-property: none !important;
- -moz-transition-property: none !important;
- -ms-transition-property: none !important;
- -webkit-transition-property: none !important;
transition-property: none !important;
/*CSS transforms*/
- -o-transform: none !important;
- -moz-transform: none !important;
- -ms-transform: none !important;
- -webkit-transform: none !important;
transform: none !important;
/*CSS animations*/
- -webkit-animation: none !important;
- -moz-animation: none !important;
- -o-animation: none !important;
- -ms-animation: none !important;
animation: none !important;
}
-@function unfoldTransition ($transition) {
+@function unfold-transition ($transition) {
// Default values
$property: all;
$duration: $general-hover-transition-duration;
$easing: $general-hover-transition-curve; // Browser default is ease, which is what we want
$delay: null; // Browser default is 0, which is what we want
- $defaultProperties: ($property, $duration, $easing, $delay);
+ $default-properties: ($property, $duration, $easing, $delay);
// Grab transition properties if they exist
- $unfoldedTransition: ();
- @for $i from 1 through length($defaultProperties) {
+ $unfolded-transition: ();
+ @for $i from 1 through length($default-properties) {
$p: null;
@if $i <= length($transition) {
$p: nth($transition, $i);
} @else {
- $p: nth($defaultProperties, $i);
+ $p: nth($default-properties, $i);
}
- $unfoldedTransition: append($unfoldedTransition, $p);
+ $unfolded-transition: append($unfolded-transition, $p);
}
- @return $unfoldedTransition;
+ @return $unfolded-transition;
}
.btn {
@@ -202,9 +190,9 @@ a {
}
}
- [class^="skeleton-line-"] {
+ [class^='skeleton-line-'] {
position: relative;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
height: 10px;
overflow: hidden;
@@ -218,13 +206,11 @@ a {
animation: blockTextShine 1s linear infinite forwards;
background-repeat: no-repeat;
background-size: cover;
- background-image: linear-gradient(
- to right,
- $theme-gray-100 0%,
- $theme-gray-50 20%,
- $theme-gray-100 40%,
- $theme-gray-100 100%
- );
+ background-image: linear-gradient(to right,
+ $gray-100 0%,
+ $gray-50 20%,
+ $gray-100 40%,
+ $gray-100 100%);
height: 10px;
}
}
@@ -260,3 +246,25 @@ $skeleton-line-widths: (
.slide-down-leave-to {
transform: translateY(-30%);
}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg);}
+ 100% { transform: rotate(360deg);}
+}
+
+/** COMMON ANIMATION CLASSES **/
+.transform-origin-center { @include webkit-prefix(transform-origin, 50% 50%); }
+.animate-n-spin { @include webkit-prefix(animation-name, spin); }
+.animate-c-infinite { @include webkit-prefix(animation-iteration-count, infinite); }
+.animate-t-linear { @include webkit-prefix(animation-timing-function, linear); }
+.animate-d-1 { @include webkit-prefix(animation-duration, 1s); }
+.animate-d-2 { @include webkit-prefix(animation-duration, 2s); }
+
+/** COMPOSITE ANIMATION CLASSES **/
+.gl-spinner {
+ @include webkit-prefix(animation-name, spin);
+ @include webkit-prefix(animation-iteration-count, infinite);
+ @include webkit-prefix(animation-timing-function, linear);
+ @include webkit-prefix(animation-duration, 1s);
+ transform-origin: 50% 50%;
+}
diff --git a/app/assets/stylesheets/framework/asciidoctor.scss b/app/assets/stylesheets/framework/asciidoctor.scss
index 62493c32833..1586265d40e 100644
--- a/app/assets/stylesheets/framework/asciidoctor.scss
+++ b/app/assets/stylesheets/framework/asciidoctor.scss
@@ -1,7 +1,7 @@
.admonitionblock td.icon {
width: 1%;
- [class^="fa icon-"] {
+ [class^='fa icon-'] {
@extend .fa-2x;
}
diff --git a/app/assets/stylesheets/framework/avatar.scss b/app/assets/stylesheets/framework/avatar.scss
deleted file mode 100644
index e132aa4c216..00000000000
--- a/app/assets/stylesheets/framework/avatar.scss
+++ /dev/null
@@ -1,136 +0,0 @@
-@mixin avatar-size($size, $margin-right) {
- width: $size;
- height: $size;
- margin-right: $margin-right;
-}
-
-.avatar-circle {
- float: left;
- margin-right: 15px;
- border-radius: $avatar-radius;
- border: 1px solid $gray-normal;
- &.s16 { @include avatar-size(16px, 6px); }
- &.s18 { @include avatar-size(18px, 6px); }
- &.s19 { @include avatar-size(19px, 6px); }
- &.s20 { @include avatar-size(20px, 7px); }
- &.s24 { @include avatar-size(24px, 8px); }
- &.s26 { @include avatar-size(26px, 8px); }
- &.s32 { @include avatar-size(32px, 10px); }
- &.s36 { @include avatar-size(36px, 10px); }
- &.s40 { @include avatar-size(40px, 10px); }
- &.s46 { @include avatar-size(46px, 15px); }
- &.s48 { @include avatar-size(48px, 10px); }
- &.s60 { @include avatar-size(60px, 12px); }
- &.s64 { @include avatar-size(64px, 14px); }
- &.s70 { @include avatar-size(70px, 14px); }
- &.s90 { @include avatar-size(90px, 15px); }
- &.s100 { @include avatar-size(100px, 15px); }
- &.s110 { @include avatar-size(110px, 15px); }
- &.s140 { @include avatar-size(140px, 15px); }
- &.s160 { @include avatar-size(160px, 20px); }
-}
-
-.avatar {
- @extend .avatar-circle;
- transition-property: none;
-
- width: 40px;
- height: 40px;
- padding: 0;
- background: $gray-lightest;
- overflow: hidden;
-
- &.avatar-inline {
- float: none;
- display: inline-block;
- margin-left: 2px;
- flex-shrink: 0;
- -webkit-flex-shrink: 0;
-
- &.s16 { margin-right: 4px; }
- &.s24 { margin-right: 4px; }
- }
-
- &.center {
- font-size: 14px;
- line-height: 1.8em;
- text-align: center;
- }
-
- &.avatar-tile {
- border-radius: 0;
- border: 0;
- }
-
- &:not([href]):hover {
- border-color: darken($gray-normal, 10%);
- }
-}
-
-.identicon {
- text-align: center;
- vertical-align: top;
- color: $gl-gray-700;
- background-color: $gray-darker;
-
- // Sizes
- &.s16 { font-size: 12px; line-height: 1.33; }
- &.s24 { font-size: 13px; line-height: 1.8; }
- &.s26 { font-size: 20px; line-height: 1.33; }
- &.s32 { font-size: 20px; line-height: 30px; }
- &.s40 { font-size: 16px; line-height: 38px; }
- &.s48 { font-size: 20px; line-height: 46px; }
- &.s60 { font-size: 32px; line-height: 58px; }
- &.s64 { font-size: 32px; line-height: 64px; }
- &.s70 { font-size: 34px; line-height: 70px; }
- &.s90 { font-size: 36px; line-height: 88px; }
- &.s100 { font-size: 36px; line-height: 98px; }
- &.s110 { font-size: 40px; line-height: 108px; font-weight: $gl-font-weight-normal; }
- &.s140 { font-size: 72px; line-height: 138px; }
- &.s160 { font-size: 96px; line-height: 158px; }
-
- // Background colors
- &.bg1 { background-color: $identicon-red; }
- &.bg2 { background-color: $identicon-purple; }
- &.bg3 { background-color: $identicon-indigo; }
- &.bg4 { background-color: $identicon-blue; }
- &.bg5 { background-color: $identicon-teal; }
- &.bg6 { background-color: $identicon-orange; }
- &.bg7 { background-color: $gray-darker; }
-}
-
-.avatar-container {
- @extend .avatar-circle;
- overflow: hidden;
- display: flex;
-
- a {
- width: 100%;
- height: 100%;
- display: flex;
- text-decoration: none;
- }
-
- .avatar {
- border-radius: 0;
- border: 0;
- height: auto;
- width: 100%;
- margin: 0;
- align-self: center;
- }
-
- &.s40 { min-width: 40px; min-height: 40px; }
- &.s64 { min-width: 64px; min-height: 64px; }
-}
-
-.avatar-counter {
- background-color: $gray-darkest;
- color: $white-light;
- border: 1px solid $gray-normal;
- border-radius: 1em;
- font-family: $regular-font;
- font-size: 9px;
- line-height: 16px;
- text-align: center;
-}
diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss
index 7a95db5976d..a8df7e1bfad 100644
--- a/app/assets/stylesheets/framework/awards.scss
+++ b/app/assets/stylesheets/framework/awards.scss
@@ -15,7 +15,7 @@
margin-top: 3px;
padding: $gl-padding;
z-index: 300;
- width: 300px;
+ width: $award-emoji-width;
font-size: 14px;
background-color: $white-light;
border: 1px solid $border-white-light;
@@ -23,9 +23,9 @@
box-shadow: 0 6px 12px $award-emoji-menu-shadow;
pointer-events: none;
opacity: 0;
- transform: scale(.2);
+ transform: scale(0.2);
transform-origin: 0 -45px;
- transition: .3s cubic-bezier(.67, .06, .19, 1.44);
+ transition: 0.3s cubic-bezier(0.67, 0.06, 0.19, 1.44);
transition-property: transform, opacity;
&.is-rendered {
@@ -55,10 +55,14 @@
transform: none;
}
}
+
+ @include media-breakpoint-down(xs) {
+ width: $award-emoji-width-xs;
+ }
}
.emoji-search {
- background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC");
+ background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC');
background-repeat: no-repeat;
background-position: right 5px center;
background-size: 16px;
@@ -86,7 +90,7 @@
background: none;
border: 0;
border-radius: $border-radius-base;
- transition: transform .15s cubic-bezier(.3, 0, .2, 2);
+ transition: transform 0.15s cubic-bezier(0.3, 0, 0.2, 2);
&:hover {
background-color: transparent;
@@ -147,8 +151,7 @@
outline: 0;
.award-control-icon svg {
- background: $award-emoji-positive-add-bg;
- fill: $award-emoji-positive-add-lines;
+ fill: $blue-500;
}
.award-control-icon-neutral {
@@ -176,7 +179,7 @@
&.user-authored {
cursor: default;
background-color: $gray-light;
- border-color: $theme-gray-200;
+ border-color: $gray-200;
color: $gl-text-color-disabled;
gl-emoji {
@@ -229,10 +232,10 @@
height: $default-icon-size;
width: $default-icon-size;
border-radius: 50%;
+ }
- path {
- fill: $border-gray-normal;
- }
+ path {
+ fill: $gray-700;
}
}
@@ -243,6 +246,10 @@
left: 10px;
bottom: 6px;
opacity: 0;
+
+ path {
+ fill: $award-emoji-positive-add-lines;
+ }
}
.award-control-text {
diff --git a/app/assets/stylesheets/framework/blank.scss b/app/assets/stylesheets/framework/blank.scss
index 91dbb2a6365..cbd390e7145 100644
--- a/app/assets/stylesheets/framework/blank.scss
+++ b/app/assets/stylesheets/framework/blank.scss
@@ -69,6 +69,7 @@
@include media-breakpoint-up(sm) {
display: flex;
+ height: 100%;
align-items: center;
padding: 50px 30px;
}
@@ -99,3 +100,30 @@
}
}
}
+
+@include media-breakpoint-up(lg) {
+ .column-large {
+ flex: 2;
+ }
+
+ .column-small {
+ flex: 1;
+ margin-bottom: 15px;
+
+ .blank-state {
+ max-width: 400px;
+ flex-wrap: wrap;
+ margin-left: 15px;
+ }
+
+ .blank-state-icon {
+ margin-bottom: 30px;
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .blank-state-icon svg {
+ width: 315px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss
index 43b7c26b272..3aabb66f7a6 100644
--- a/app/assets/stylesheets/framework/blocks.scss
+++ b/app/assets/stylesheets/framework/blocks.scss
@@ -22,6 +22,10 @@
}
}
+.oneline {
+ line-height: 35px;
+}
+
.row-content-block {
margin-top: 0;
background-color: $gray-light;
@@ -77,20 +81,13 @@
color: $gl-text-color;
}
- .oneline {
- line-height: 35px;
- }
-
> p:last-child {
margin-bottom: 0;
}
.block-controls {
- display: -webkit-flex;
display: flex;
- -webkit-justify-content: flex-end;
justify-content: flex-end;
- -webkit-flex: 1;
flex: 1;
.control {
@@ -111,10 +108,6 @@
padding: 11px 0;
margin-bottom: 11px;
- .oneline {
- line-height: 35px;
- }
-
&.no-bottom-space {
border-bottom: 0;
margin-bottom: 0;
@@ -153,7 +146,7 @@
display: inline-block;
margin-left: 5px;
font-size: 18px;
- color: color("gray");
+ color: color('gray');
}
p {
@@ -163,8 +156,6 @@
}
.cover-desc {
- color: $gl-text-color;
-
&.username:last-child {
padding-bottom: $gl-padding;
}
@@ -228,7 +219,6 @@
}
.group-info {
-
h1 {
display: inline;
font-weight: $gl-font-weight-normal;
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index a4a9276c580..2c720703822 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -21,7 +21,7 @@
}
@mixin btn-default {
- border-radius: 3px;
+ border-radius: $border-radius-default;
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
padding: $gl-vert-padding $gl-btn-padding;
@@ -37,11 +37,15 @@
@include btn-default;
}
-@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border) {
+@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border, $active-text) {
background-color: $background;
color: $text;
border-color: $border;
+ &.btn-border-color {
+ border-color: $border-color;
+ }
+
> .icon {
color: $text;
}
@@ -57,13 +61,22 @@
}
}
+ &:focus {
+ box-shadow: 0 0 4px 1px $blue-300;
+ }
+
&:active {
background-color: $active-background;
border-color: $active-border;
- color: $hover-text;
+ box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
+ color: $active-text;
> .icon {
- color: $hover-text;
+ color: $active-text;
+ }
+
+ &:focus {
+ box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
}
}
}
@@ -135,6 +148,7 @@
@include btn-white;
color: $gl-text-color;
+ white-space: nowrap;
&:focus:active {
outline: 0;
@@ -159,20 +173,21 @@
&.btn-inverted {
&.btn-success {
- @include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700);
+ @include btn-outline($white-light, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800);
}
- &.btn-remove {
- @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
+ &.btn-remove,
+ &.btn-danger {
+ @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-warning {
- @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
+ @include btn-outline($white-light, $orange-500, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-primary,
&.btn-info {
- @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
@@ -187,11 +202,11 @@
&.btn-close,
&.btn-close-color {
- @include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
+ @include btn-outline($white-light, $orange-600, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-spam {
- @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
+ @include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-danger,
@@ -390,15 +405,13 @@
cursor: default;
&:active {
- -moz-box-shadow: inset 0 0 0 $white-light;
- -webkit-box-shadow: inset 0 0 0 $white-light;
box-shadow: inset 0 0 0 $white-light;
}
}
.btn-inverted {
&-secondary {
- @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
+ @include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
@@ -440,7 +453,8 @@
border-color: transparent;
}
- &.btn-secondary-hover-link {
+ &.btn-secondary-hover-link,
+ &.btn-default-hover-link {
color: $gl-text-color-secondary;
&:hover,
@@ -483,7 +497,7 @@
// All disabled buttons, regardless of color, type, etc
%disabled {
background-color: $gray-light !important;
- border-color: $theme-gray-200 !important;
+ border-color: $gray-200 !important;
color: $gl-text-color-disabled !important;
opacity: 1 !important;
cursor: default !important;
diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss
index 0d8e4afa76f..643b20c56bc 100644
--- a/app/assets/stylesheets/framework/callout.scss
+++ b/app/assets/stylesheets/framework/callout.scss
@@ -28,6 +28,10 @@
background-color: $red-100;
border-color: $red-200;
color: $red-700;
+
+ a {
+ color: $red-700;
+ }
}
.bs-callout-warning {
diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss
index 7207e5119ce..28d7492b99c 100644
--- a/app/assets/stylesheets/framework/ci_variable_list.scss
+++ b/app/assets/stylesheets/framework/ci_variable_list.scss
@@ -47,6 +47,7 @@
display: flex;
align-items: flex-start;
width: 100%;
+ padding-bottom: $gl-padding;
@include media-breakpoint-down(xs) {
display: block;
@@ -66,6 +67,7 @@
}
}
+.ci-variable-masked-item,
.ci-variable-protected-item {
flex: 0 1 auto;
display: flex;
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index e037b02a30c..fc488b85138 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -5,6 +5,9 @@
.cgreen { color: $green-600; }
.cdark { color: $common-gray-dark; }
+.fwhite { fill: $white-light; }
+.fgray { fill: $gray-700; }
+
.text-plain,
.text-plain:hover {
color: $gl-text-color;
@@ -48,7 +51,24 @@
color: $brand-info;
}
-.hint { font-style: italic; color: $gl-gray-400; }
+.bg-gray-light {
+ background-color: $gray-light;
+}
+
+.text-break-word {
+ word-break: break-all;
+}
+
+.text-underline,
+.text-underline:hover {
+ text-decoration: underline;
+}
+
+.hint {
+ font-style: italic;
+ color: $gl-gray-400;
+}
+
.light { color: $gl-text-color; }
.slead {
@@ -89,6 +109,10 @@ hr {
.str-truncated {
@include str-truncated;
+ &-30 {
+ @include str-truncated(30%);
+ }
+
&-60 {
@include str-truncated(60%);
}
@@ -103,7 +127,7 @@ hr {
text-overflow: ellipsis;
white-space: nowrap;
- > div,
+ > div:not(.block),
.str-truncated {
display: inline;
}
@@ -145,13 +169,14 @@ p.time {
text-shadow: none;
}
-.thin_area {
+.thin-area {
height: 150px;
}
// Fix issue with notes & lists creating a bunch of bottom borders.
li.note {
img { max-width: 100%; }
+
.note-title {
li {
border-bottom: 0 !important;
@@ -170,11 +195,6 @@ li.note {
background-color: inherit;
}
-.show-suppressed-diff,
-.show-all-commits {
- cursor: pointer;
-}
-
.error-message {
padding: 10px;
background: $red-400;
@@ -187,12 +207,12 @@ li.note {
}
}
-.warning_message {
- border-left: 4px solid $orange-200;
- color: $orange-700;
+@mixin message($background-color, $border-color, $text-color) {
+ border-left: 4px solid $border-color;
+ color: $text-color;
padding: 10px;
margin-bottom: 10px;
- background: $orange-100;
+ background: $background-color;
padding-left: 20px;
&.centered {
@@ -200,6 +220,14 @@ li.note {
}
}
+.warning_message {
+ @include message($orange-100, $orange-200, $orange-700);
+}
+
+.danger_message {
+ @include message($red-100, $red-200, $red-900);
+}
+
.gitlab-promo {
a {
color: $gl-gray-350;
@@ -322,7 +350,7 @@ img.emoji {
.disabled-content {
pointer-events: none;
- opacity: .5;
+ opacity: 0.5;
}
.break-word {
@@ -358,18 +386,23 @@ img.emoji {
.prepend-top-default { margin-top: $gl-padding !important; }
.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
+.prepend-top-32 { margin-top: 32px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-8 { margin-left: 8px; }
.prepend-left-10 { margin-left: 10px; }
+.prepend-left-15 { margin-left: 15px; }
.prepend-left-default { margin-left: $gl-padding; }
.prepend-left-20 { margin-left: 20px; }
+.prepend-left-32 { margin-left: 32px; }
.append-right-4 { margin-right: 4px; }
.append-right-5 { margin-right: 5px; }
.append-right-8 { margin-right: 8px; }
.append-right-10 { margin-right: 10px; }
+.append-right-15 { margin-right: 15px; }
.append-right-default { margin-right: $gl-padding; }
.append-right-20 { margin-right: 20px; }
+.prepend-right-32 { margin-right: 32px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-4 { margin-bottom: $gl-padding-4; }
.append-bottom-5 { margin-bottom: 5px; }
@@ -378,26 +411,81 @@ img.emoji {
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
.append-bottom-default { margin-bottom: $gl-padding; }
+.prepend-bottom-32 { margin-bottom: 32px; }
.inline { display: inline-block; }
.center { text-align: center; }
+.block { display: block; }
+.flex { display: flex; }
.vertical-align-middle { vertical-align: middle; }
+.vertical-align-sub { vertical-align: sub; }
.flex-align-self-center { align-self: center; }
.flex-grow { flex-grow: 1; }
.flex-no-shrink { flex-shrink: 0; }
-.mw-460 { max-width: 460px; }
.ws-initial { white-space: initial; }
+.ws-normal { white-space: normal; }
+.overflow-auto { overflow: auto; }
+
+.d-flex-center {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/** COMMON SIZING CLASSES **/
+.w-0 { width: 0; }
+
+.h-12em { height: 12em; }
+
+.mw-460 { max-width: 460px; }
+.mw-6em { max-width: 6em; }
+.mw-70p { max-width: 70%; }
+
.min-height-0 { min-height: 0; }
-.gl-pl-0 { padding-left: 0; }
-.gl-pl-1 { padding-left: #{0.5 * $grid-size}; }
-.gl-pl-2 { padding-left: $grid-size; }
-.gl-pl-3 { padding-left: #{2 * $grid-size}; }
-.gl-pl-4 { padding-left: #{3 * $grid-size}; }
-.gl-pl-5 { padding-left: #{4 * $grid-size}; }
-
-.gl-pr-0 { padding-right: 0; }
-.gl-pr-1 { padding-right: #{0.5 * $grid-size}; }
-.gl-pr-2 { padding-right: $grid-size; }
-.gl-pr-3 { padding-right: #{2 * $grid-size}; }
-.gl-pr-4 { padding-right: #{3 * $grid-size}; }
-.gl-pr-5 { padding-right: #{4 * $grid-size}; }
+.svg-w-100 {
+ svg {
+ width: 100%;
+ }
+}
+
+/** COMMON SPACING CLASSES **/
+@each $index, $padding in $spacing-scale {
+ #{'.gl-p-#{$index}'} { padding: $padding; }
+ #{'.gl-pl-#{$index}'} { padding-left: $padding; }
+ #{'.gl-pr-#{$index}'} { padding-right: $padding; }
+ #{'.gl-pt-#{$index}'} { padding-top: $padding; }
+ #{'.gl-pb-#{$index}'} { padding-bottom: $padding; }
+}
+
+/**
+ * Removes browser specific clear icon from input fields in
+ * Internet Explorer 10, Internet Explorer 11, and Microsoft Edge.
+ * This is intended for elements which add a customized clear icon.
+ *
+ * see also https://developer.mozilla.org/en-US/docs/Web/CSS/::-ms-clear
+ */
+.ms-no-clear ::-ms-clear {
+ display: none;
+}
+
+/** COMMON POSITIONING CLASSES */
+.position-bottom-0 { bottom: 0 !important; }
+.position-left-0 { left: 0 !important; }
+.position-right-0 { right: 0 !important; }
+.position-top-0 { top: 0 !important; }
+
+.drag-handle {
+ width: 4px;
+
+ &:hover {
+ background-color: $white-normal;
+ }
+
+ &.is-dragging {
+ background-color: $gray-600;
+ }
+}
+
+.cursor-pointer {
+ cursor: pointer;
+}
diff --git a/app/assets/stylesheets/framework/contextual_sidebar.scss b/app/assets/stylesheets/framework/contextual_sidebar.scss
index 8b6a7017c47..3238b01c6c0 100644
--- a/app/assets/stylesheets/framework/contextual_sidebar.scss
+++ b/app/assets/stylesheets/framework/contextual_sidebar.scss
@@ -5,7 +5,7 @@
padding-left: $contextual-sidebar-collapsed-width;
}
- @include media-breakpoint-up(lg) {
+ @include media-breakpoint-up(xl) {
padding-left: $contextual-sidebar-width;
}
@@ -15,7 +15,7 @@
}
.page-with-icon-sidebar {
- @include media-breakpoint-up(sm) {
+ @include media-breakpoint-up(md) {
padding-left: $contextual-sidebar-collapsed-width;
}
}
@@ -71,6 +71,44 @@
}
}
+@mixin collapse-contextual-sidebar-content {
+ .context-header {
+ height: 60px;
+ width: $contextual-sidebar-collapsed-width;
+
+ a {
+ padding: 10px 4px;
+ }
+ }
+
+ .sidebar-top-level-items > li {
+ .sidebar-sub-level-items {
+ &:not(.flyout-list) {
+ display: none;
+ }
+ }
+ }
+
+ .nav-icon-container {
+ margin-right: 0;
+ }
+
+ .toggle-sidebar-button {
+ padding: 16px;
+ width: $contextual-sidebar-collapsed-width - 1px;
+
+ .collapse-text,
+ .icon-angle-double-left {
+ display: none;
+ }
+
+ .icon-angle-double-right {
+ display: block;
+ margin: 0;
+ }
+ }
+}
+
.nav-sidebar {
transition: width $sidebar-transition-duration, left $sidebar-transition-duration;
position: fixed;
@@ -89,7 +127,7 @@
}
}
- &.sidebar-collapsed-desktop {
+ @mixin collapse-contextual-sidebar {
width: $contextual-sidebar-collapsed-width;
.nav-sidebar-inner-scroll {
@@ -115,6 +153,10 @@
}
}
+ &.sidebar-collapsed-desktop {
+ @include collapse-contextual-sidebar;
+ }
+
&.sidebar-expanded-mobile {
left: 0;
}
@@ -150,7 +192,7 @@
}
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
left: (-$contextual-sidebar-width);
}
@@ -167,16 +209,19 @@
height: 16px;
width: 16px;
}
+
+ @media (min-width: map-get($grid-breakpoints, md)) and (max-width: map-get($grid-breakpoints, xl) - 1px) {
+ &:not(.sidebar-expanded-mobile) {
+ @include collapse-contextual-sidebar;
+ @include collapse-contextual-sidebar-content;
+ }
+ }
}
.nav-sidebar-inner-scroll {
height: 100%;
width: 100%;
overflow: auto;
-
- @include media-breakpoint-up(sm) {
- overflow: hidden;
- }
}
.with-performance-bar .nav-sidebar {
@@ -346,53 +391,13 @@
}
}
-.toggle-sidebar-button {
- @include media-breakpoint-down(xs) {
- display: none;
- }
-}
-
.collapse-text {
white-space: nowrap;
overflow: hidden;
}
.sidebar-collapsed-desktop {
- .context-header {
- height: 60px;
- width: $contextual-sidebar-collapsed-width;
-
- a {
- padding: 10px 4px;
- }
- }
-
- .sidebar-top-level-items > li {
- .sidebar-sub-level-items {
- &:not(.flyout-list) {
- display: none;
- }
- }
- }
-
- .nav-icon-container {
- margin-right: 0;
- }
-
- .toggle-sidebar-button {
- padding: 16px;
- width: $contextual-sidebar-collapsed-width - 1px;
-
- .collapse-text,
- .icon-angle-double-left {
- display: none;
- }
-
- .icon-angle-double-right {
- display: block;
- margin: 0;
- }
- }
+ @include collapse-contextual-sidebar-content;
}
.fly-out-top-item {
@@ -428,16 +433,14 @@
color: $gl-text-color-secondary;
}
- @include media-breakpoint-down(xs) {
+ @include media-breakpoint-down(sm) {
display: flex;
align-items: center;
i {
font-size: 18px;
}
- }
- @include media-breakpoint-down(xs) {
+ .breadcrumbs-links {
padding-left: $gl-padding;
border-left: 1px solid $gl-text-color-quaternary;
@@ -445,21 +448,25 @@
}
}
-@include media-breakpoint-down(xs) {
+@include media-breakpoint-down(sm) {
.close-nav-button {
display: flex;
}
-}
-.mobile-overlay {
- display: none;
+ .toggle-sidebar-button {
+ display: none;
+ }
- &.mobile-nav-open {
- display: block;
- position: fixed;
- background-color: $black-transparent;
- height: 100%;
- width: 100%;
- z-index: 300;
+ .mobile-overlay {
+ display: none;
+
+ &.mobile-nav-open {
+ display: block;
+ position: fixed;
+ background-color: $black-transparent;
+ height: 100%;
+ width: 100%;
+ z-index: 300;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index afcb230797a..8fb4027bf97 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -32,6 +32,10 @@
max-height: $dropdown-max-height;
overflow-y: auto;
+ &.dropdown-extended-height {
+ max-height: 400px;
+ }
+
@include media-breakpoint-down(xs) {
width: 100%;
}
@@ -125,7 +129,7 @@
@extend .dropdown-toggle;
padding-right: 25px;
position: relative;
- width: 163px;
+ width: 160px;
text-overflow: ellipsis;
overflow: hidden;
@@ -347,6 +351,10 @@
// Expects up to 3 digits on the badge
margin-right: 40px;
}
+
+ .dropdown-menu-content {
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
+ }
}
.droplab-dropdown {
diff --git a/app/assets/stylesheets/framework/emojis.scss b/app/assets/stylesheets/framework/emojis.scss
index be85e03430e..13c5541da92 100644
--- a/app/assets/stylesheets/framework/emojis.scss
+++ b/app/assets/stylesheets/framework/emojis.scss
@@ -2,7 +2,7 @@ gl-emoji {
font-style: normal;
display: inline-flex;
vertical-align: middle;
- font-family: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 1.4em;
line-height: 1em;
}
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 3ac7b6b704b..17c117188b3 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -4,6 +4,7 @@
*/
.file-holder {
border: 1px solid $border-color;
+ border-top: 0;
border-radius: $border-radius-default;
&.file-holder-no-border {
@@ -24,10 +25,6 @@
}
}
- &:not(.use-csslab) table {
- @extend .table;
- }
-
.file-title {
position: relative;
background-color: $gray-light;
@@ -51,6 +48,7 @@
position: absolute;
top: 5px;
right: 15px;
+ margin-left: auto;
.btn {
padding: 0 10px;
@@ -121,7 +119,7 @@
}
}
- &.wiki {
+ &.md {
padding: $gl-padding;
@include media-breakpoint-up(md) {
@@ -243,6 +241,7 @@
*/
&.code {
padding: 0;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
}
.list-inline.previews {
@@ -324,13 +323,19 @@ span.idiff {
&,
.file-holder & {
display: flex;
+ flex-wrap: wrap;
align-items: center;
justify-content: space-between;
background-color: $gray-light;
border-bottom: 1px solid $border-color;
+ border-top: 1px solid $border-color;
padding: 5px $gl-padding;
margin: 0;
border-radius: $border-radius-default $border-radius-default 0 0;
+
+ &.is-stuck {
+ border-radius: 0;
+ }
}
.file-header-content {
@@ -365,16 +370,12 @@ span.idiff {
margin: 0 10px 0 0;
}
- .file-actions {
- white-space: nowrap;
-
- .btn {
- padding: 0 10px;
- font-size: 13px;
- line-height: 28px;
- display: inline-block;
- float: none;
- }
+ .file-actions .btn {
+ padding: 0 10px;
+ font-size: 13px;
+ line-height: 28px;
+ display: inline-block;
+ float: none;
}
@include media-breakpoint-down(xs) {
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index f48b3ddc912..26cbb7f5c13 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -50,19 +50,15 @@
}
.filtered-search-wrapper {
- display: -webkit-flex;
display: flex;
@include media-breakpoint-down(xs) {
- -webkit-flex-direction: column;
flex-direction: column;
}
.tokens-container {
- display: -webkit-flex;
display: flex;
flex: 1;
- -webkit-flex: 1;
padding-left: 12px;
position: relative;
margin-bottom: 0;
@@ -82,21 +78,18 @@
.input-token:only-child,
.input-token:last-child {
flex: 1;
- -webkit-flex: 1;
max-width: inherit;
}
}
.filtered-search-token,
.filtered-search-term {
- display: -webkit-flex;
display: flex;
flex-shrink: 0;
margin-top: 4px;
margin-bottom: 4px;
.selectable {
- display: -webkit-flex;
display: flex;
}
@@ -115,6 +108,8 @@
}
.value-container {
+ display: flex;
+ align-items: center;
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
@@ -128,7 +123,7 @@
.remove-token {
display: inline-block;
- padding-left: 4px;
+ padding-left: 8px;
padding-right: 0;
.fa-close {
@@ -176,7 +171,6 @@
}
.scroll-container {
- display: -webkit-flex;
display: flex;
overflow-x: auto;
white-space: nowrap;
@@ -186,7 +180,6 @@
.filtered-search-box {
position: relative;
flex: 1;
- display: -webkit-flex;
display: flex;
width: 100%;
min-width: 0;
@@ -194,7 +187,6 @@
background-color: $white-light;
@include media-breakpoint-down(xs) {
- -webkit-flex: 1 1 auto;
flex: 1 1 auto;
margin-bottom: 10px;
}
@@ -226,7 +218,7 @@
min-width: 200px;
padding-right: 25px;
padding-left: 0;
- height: $input-height;
+ height: $input-height - 2;
line-height: inherit;
border-color: transparent;
@@ -349,7 +341,6 @@
}
.filter-dropdown-container {
- display: -webkit-flex;
display: flex;
.dropdown-toggle {
@@ -423,3 +414,10 @@
padding: 8px 16px;
text-align: center;
}
+
+.search-token-target-branch {
+ .value {
+ font-family: $monospace-font;
+ font-size: 13px;
+ }
+}
diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss
index afd888af672..4a9c73a1bc9 100644
--- a/app/assets/stylesheets/framework/forms.scss
+++ b/app/assets/stylesheets/framework/forms.scss
@@ -36,6 +36,15 @@ label {
}
}
+.label-wrapper {
+ display: block;
+ margin: 0;
+}
+
+.form-label {
+ @extend label;
+}
+
.form-control-label {
@extend .col-md-2;
}
@@ -119,7 +128,7 @@ label {
.form-control {
@include box-shadow(none);
- border-radius: 2px;
+ border-radius: $border-radius-default;
padding: $gl-vert-padding $gl-input-padding;
&.input-short {
@@ -147,9 +156,11 @@ label {
.select-control {
padding-left: 10px;
padding-right: 10px;
+ appearance: none;
+ /* stylelint-disable property-no-vendor-prefix */
-webkit-appearance: none;
-moz-appearance: none;
- appearance: none;
+ /* stylelint-enable property-no-vendor-prefix */
&::-ms-expand {
display: none;
@@ -169,7 +180,8 @@ label {
font-weight: $gl-font-weight-normal;
}
-.form-control::-webkit-input-placeholder {
+
+.form-control::placeholder {
color: $gl-text-color-tertiary;
}
@@ -194,10 +206,13 @@ label {
.form-text.text-muted {
margin-bottom: 0;
margin-top: #{$grid-size / 2};
+ font-size: $gl-font-size;
}
-.gl-field-error {
+.gl-field-error,
+.invalid-feedback {
color: $red-500;
+ font-size: $gl-font-size;
}
.gl-show-field-errors {
@@ -252,11 +267,20 @@ label {
right: 0.8em;
top: 50%;
transform: translateY(-50%);
- color: $theme-gray-600;
+ color: $gray-600;
}
}
+.input-md {
+ max-width: $input-md-width;
+ width: 100%;
+}
+
.input-lg {
- max-width: 320px;
+ max-width: $input-lg-width;
width: 100%;
}
+
+.input-group-text {
+ max-height: $input-height;
+}
diff --git a/app/assets/stylesheets/framework/gfm.scss b/app/assets/stylesheets/framework/gfm.scss
index 50d4298d418..6943bfbc3d0 100644
--- a/app/assets/stylesheets/framework/gfm.scss
+++ b/app/assets/stylesheets/framework/gfm.scss
@@ -32,7 +32,7 @@
height: $chip-size;
background: $white-light;
background-image: linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%),
- linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
+ linear-gradient(135deg, $gray-dark 25%, transparent 0%, transparent 75%, $gray-dark 0%);
background-size: $bg-size $bg-size;
background-position: 0 0, $bg-pos $bg-pos;
diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss
index 0ef50e139f2..418eafa153c 100644
--- a/app/assets/stylesheets/framework/gitlab_theme.scss
+++ b/app/assets/stylesheets/framework/gitlab_theme.scss
@@ -282,31 +282,31 @@ body {
&.ui-dark {
@include gitlab-theme(
- $theme-gray-200,
- $theme-gray-500,
- $theme-gray-700,
- $theme-gray-800,
- $theme-gray-900,
+ $gray-200,
+ $gray-500,
+ $gray-700,
+ $gray-800,
+ $gray-900,
$white-light
);
}
&.ui-light {
@include gitlab-theme(
- $theme-gray-700,
- $theme-gray-800,
- $theme-gray-700,
- $theme-gray-700,
- $theme-gray-100,
- $theme-gray-700
+ $gray-700,
+ $gray-800,
+ $gray-700,
+ $gray-700,
+ $gray-100,
+ $gray-700
);
.navbar-gitlab {
- background-color: $theme-gray-100;
+ background-color: $gray-100;
box-shadow: 0 1px 0 0 $border-color;
.logo-text svg {
- fill: $theme-gray-900;
+ fill: $gray-900;
}
.navbar-sub-nav,
@@ -315,7 +315,7 @@ body {
> a:hover,
> a:focus,
> button:hover {
- color: $theme-gray-900;
+ color: $gray-900;
}
&.active > a,
@@ -329,8 +329,8 @@ body {
.container-fluid {
.navbar-toggler,
.navbar-toggler:hover {
- color: $theme-gray-700;
- border-left: 1px solid $theme-gray-200;
+ color: $gray-700;
+ border-left: 1px solid $gray-200;
}
}
}
@@ -348,7 +348,7 @@ body {
.search-input-wrap {
.search-icon {
- fill: $theme-gray-200;
+ fill: $gray-200;
}
.search-input {
@@ -359,16 +359,16 @@ body {
.nav-sidebar li.active {
> a {
- color: $theme-gray-900;
+ color: $gray-900;
}
svg {
- fill: $theme-gray-900;
+ fill: $gray-900;
}
}
.sidebar-top-level-items > li.active .badge.badge-pill {
- color: $theme-gray-900;
+ color: $gray-900;
}
}
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index 7d283dcfb71..1bc597bd4ae 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -39,7 +39,6 @@
.header-content {
width: 100%;
- display: -webkit-flex;
display: flex;
justify-content: space-between;
position: relative;
@@ -47,11 +46,8 @@
padding-left: 0;
.title-container {
- display: -webkit-flex;
display: flex;
- -webkit-align-items: stretch;
align-items: stretch;
- -webkit-flex: 1 1 auto;
flex: 1 1 auto;
padding-top: 0;
overflow: visible;
@@ -60,7 +56,6 @@
.title {
padding-right: 0;
color: currentColor;
- display: -webkit-flex;
display: flex;
position: relative;
margin: 0;
@@ -85,7 +80,6 @@
}
a {
- display: -webkit-flex;
display: flex;
align-items: center;
padding: 2px 8px;
@@ -173,7 +167,6 @@
.navbar-nav {
@include media-breakpoint-down(xs) {
- display: -webkit-flex;
display: flex;
padding-right: 10px;
flex-direction: row;
@@ -258,7 +251,6 @@
> li {
> a,
> button {
- display: -webkit-flex;
display: flex;
align-items: center;
justify-content: center;
@@ -294,7 +286,6 @@
}
.navbar-sub-nav {
- display: -webkit-flex;
display: flex;
margin: 0 0 0 6px;
@@ -313,7 +304,9 @@
}
}
-.caret-down {
+.caret-down,
+.btn .caret-down {
+ top: 0;
height: 11px;
width: 11px;
margin-left: 4px;
@@ -326,14 +319,12 @@
}
.breadcrumbs {
- display: -webkit-flex;
display: flex;
min-height: $breadcrumb-min-height;
color: $gl-text-color;
}
.breadcrumbs-container {
- display: -webkit-flex;
display: flex;
width: 100%;
position: relative;
@@ -344,7 +335,6 @@
}
.breadcrumbs-links {
- -webkit-flex: 1;
flex: 1;
min-width: 0;
align-self: center;
@@ -379,7 +369,6 @@
}
.breadcrumbs-list {
- display: -webkit-flex;
display: flex;
margin-bottom: 0;
line-height: 16px;
@@ -430,7 +419,6 @@
}
.breadcrumbs-extra {
- display: -webkit-flex;
display: flex;
flex: 0 0 auto;
margin-left: auto;
@@ -459,29 +447,44 @@
}
}
+.title-container,
.navbar-nav {
- li {
- .badge.badge-pill {
- position: inherit;
- font-weight: $gl-font-weight-normal;
- margin-left: -6px;
- font-size: 11px;
- color: $white-light;
- padding: 0 5px;
- line-height: 12px;
- border-radius: 7px;
- box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
-
- &.issues-count {
- background-color: $green-500;
- }
+ .badge.badge-pill {
+ position: inherit;
+ font-weight: $gl-font-weight-normal;
+ margin-left: -6px;
+ font-size: 11px;
+ color: $white-light;
+ padding: 0 5px;
+ line-height: 12px;
+ border-radius: 7px;
+ box-shadow: 0 1px 0 rgba($gl-header-color, 0.2);
+
+ &.green-badge {
+ background-color: $green-500;
+ }
- &.merge-requests-count {
- background-color: $orange-600;
- }
+ &.merge-requests-count {
+ background-color: $orange-600;
+ }
+
+ &.todos-count {
+ background-color: $blue-500;
+ }
+ }
+
+ .canary-badge {
+ .badge {
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ padding: 0 $grid-size;
+ }
- &.todos-count {
- background-color: $blue-500;
+ &:hover {
+ text-decoration: none;
+
+ .badge {
+ text-decoration: none;
}
}
}
@@ -565,15 +568,14 @@
}
.navbar-empty {
+ justify-content: center;
height: $header-height;
background: $white-light;
border-bottom: 1px solid $white-normal;
- .mx-auto {
- .tanuki-logo,
- img {
- height: 36px;
- }
+ .tanuki-logo,
+ .brand-header-logo {
+ max-height: 100%;
}
}
@@ -595,5 +597,22 @@
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
+ padding: $gl-vert-padding $gl-btn-padding;
+ }
+
+ .input-group {
+ &,
+ .input-group-prepend,
+ .input-group-append {
+ height: $input-height;
+ }
}
}
+
+.nav-links > li > a {
+ .badge.badge-pill {
+ @include media-breakpoint-down(xs) { display: none; }
+ }
+
+ @include media-breakpoint-down(xs) { margin-right: 3px; }
+}
diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss
index 73533571a2f..741f92110c3 100644
--- a/app/assets/stylesheets/framework/highlight.scss
+++ b/app/assets/stylesheets/framework/highlight.scss
@@ -8,7 +8,7 @@
pre {
padding: 10px 0;
border: 0;
- border-radius: 0;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
font-family: $monospace-font;
font-size: $code-font-size;
line-height: 19px;
@@ -42,7 +42,7 @@
padding: 10px;
text-align: right;
float: left;
- line-height: 1;
+ border-bottom-left-radius: $border-radius-default;
a {
font-family: $monospace-font;
@@ -69,3 +69,9 @@
}
}
}
+
+// Vertically aligns <table> line numbers (eg. blame view)
+// see https://gitlab.com/gitlab-org/gitlab-ce/issues/54048
+td.line-numbers {
+ line-height: 1;
+}
diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss
index 8db7d63266e..1be5ef276fd 100644
--- a/app/assets/stylesheets/framework/icons.scss
+++ b/app/assets/stylesheets/framework/icons.scss
@@ -20,8 +20,8 @@
}
.ci-status-icon-pending,
-.ci-status-icon-failed_with_warnings,
-.ci-status-icon-success_with_warnings {
+.ci-status-icon-failed-with-warnings,
+.ci-status-icon-success-with-warnings {
svg {
fill: $orange-500;
}
@@ -31,6 +31,7 @@
}
}
+.ci-status-icon-preparing,
.ci-status-icon-running {
svg {
fill: $blue-400;
@@ -87,8 +88,8 @@
display: flex;
align-items: center;
justify-content: center;
- border: $border-size solid $theme-gray-400;
+ border: $border-size solid $gray-400;
border-radius: 50%;
padding: $gl-padding-8 - $border-size;
- color: $theme-gray-700;
+ color: $gray-700;
}
diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss
index a20920e2503..d78c707192f 100644
--- a/app/assets/stylesheets/framework/images.scss
+++ b/app/assets/stylesheets/framework/images.scss
@@ -38,7 +38,10 @@
svg {
fill: currentColor;
+}
+.square,
+svg {
$svg-sizes: 8 10 12 14 16 18 24 32 48 72;
@each $svg-size in $svg-sizes {
&.s#{$svg-size} {
diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss
index a66604e56ff..e51f230a680 100644
--- a/app/assets/stylesheets/framework/issue_box.scss
+++ b/app/assets/stylesheets/framework/issue_box.scss
@@ -45,9 +45,4 @@
&.status-box-upcoming {
background: $gl-text-color-secondary;
}
-
- &.status-box-milestone {
- color: $gl-text-color;
- background: $gray-darker;
- }
}
diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss
index 9218df9b40f..97cb9d90ff0 100644
--- a/app/assets/stylesheets/framework/layout.scss
+++ b/app/assets/stylesheets/framework/layout.scss
@@ -40,6 +40,14 @@ body {
.content {
margin: 0;
+
+ @include media-breakpoint-down(xs) { margin-top: 20px; }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .container .title {
+ padding-left: 15px !important;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss
index d9d4a210f5f..298610a0631 100644
--- a/app/assets/stylesheets/framework/lists.scss
+++ b/app/assets/stylesheets/framework/lists.scss
@@ -15,7 +15,7 @@
word-wrap: break-word;
&::after {
- content: " ";
+ content: ' ';
display: table;
clear: both;
}
@@ -156,6 +156,12 @@ ul.content-list {
margin-top: 3px;
margin-bottom: 4px;
+ &.btn-ldap-override {
+ @include media-breakpoint-up(sm) {
+ margin-bottom: 0;
+ }
+ }
+
&.has-tooltip,
&:last-child {
margin-right: 0;
@@ -167,7 +173,7 @@ ul.content-list {
}
.no-comments {
- opacity: .5;
+ opacity: 0.5;
}
}
@@ -196,8 +202,6 @@ ul.content-list {
// Content list using flexbox
.flex-list {
.flex-row {
- display: -webkit-flex;
- display: -ms-flexbox;
display: flex;
align-items: center;
white-space: nowrap;
diff --git a/app/assets/stylesheets/framework/logo.scss b/app/assets/stylesheets/framework/logo.scss
index 429cfbe7235..c5feefb8c54 100644
--- a/app/assets/stylesheets/framework/logo.scss
+++ b/app/assets/stylesheets/framework/logo.scss
@@ -9,7 +9,6 @@
}
.tanuki-logo {
-
.tanuki-left-ear,
.tanuki-right-ear,
.tanuki-nose {
@@ -34,7 +33,9 @@
.tanuki-left-cheek {
@include include-keyframes(animate-tanuki-left-cheek) {
- 0%, 10%, 100% {
+ 0%,
+ 10%,
+ 100% {
fill: lighten($tanuki-yellow, 25%);
}
@@ -46,11 +47,13 @@
.tanuki-left-eye {
@include include-keyframes(animate-tanuki-left-eye) {
- 10%, 80% {
+ 10%,
+ 80% {
fill: $tanuki-orange;
}
- 20%, 90% {
+ 20%,
+ 90% {
fill: lighten($tanuki-orange, 25%);
}
}
@@ -58,11 +61,13 @@
.tanuki-left-ear {
@include include-keyframes(animate-tanuki-left-ear) {
- 10%, 80% {
+ 10%,
+ 80% {
fill: $tanuki-red;
}
- 20%, 90% {
+ 20%,
+ 90% {
fill: lighten($tanuki-red, 25%);
}
}
@@ -70,11 +75,13 @@
.tanuki-nose {
@include include-keyframes(animate-tanuki-nose) {
- 20%, 70% {
+ 20%,
+ 70% {
fill: $tanuki-red;
}
- 30%, 80% {
+ 30%,
+ 80% {
fill: lighten($tanuki-red, 25%);
}
}
@@ -82,11 +89,13 @@
.tanuki-right-eye {
@include include-keyframes(animate-tanuki-right-eye) {
- 30%, 60% {
+ 30%,
+ 60% {
fill: $tanuki-orange;
}
- 40%, 70% {
+ 40%,
+ 70% {
fill: lighten($tanuki-orange, 25%);
}
}
@@ -94,11 +103,13 @@
.tanuki-right-ear {
@include include-keyframes(animate-tanuki-right-ear) {
- 30%, 60% {
+ 30%,
+ 60% {
fill: $tanuki-red;
}
- 40%, 70% {
+ 40%,
+ 70% {
fill: lighten($tanuki-red, 25%);
}
}
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 5609a2086e6..bfd96a4bc05 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -50,10 +50,6 @@
transition: opacity 200ms ease-in-out;
}
-.md-area {
- position: relative;
-}
-
.md-header {
.nav-links {
a {
@@ -61,6 +57,10 @@
padding-top: 0;
line-height: 19px;
+ &.btn.btn-sm {
+ padding: 2px 5px;
+ }
+
&:focus {
margin-top: -10px;
padding-top: 10px;
@@ -131,38 +131,6 @@
width: 100%;
}
-.md:not(.use-csslab) {
- &.md-preview-holder {
- // Reset ul style types since we're nested inside a ul already
- @include bulleted-list;
- }
-
- // On diffs code should wrap nicely and not overflow
- code {
- white-space: pre-wrap;
- word-break: keep-all;
- }
-
- hr {
- // Darken 'whitesmoke' a bit to make it more visible in note bodies
- border-color: darken($gray-normal, 8%);
- margin: 10px 0;
- }
-
- // Border around images in issue and MR comments.
- img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- margin: 5px 0;
- // Ensure that image does not exceed viewport
- max-height: calc(100vh - 100px);
- }
-
- table {
- @include markdown-table;
- }
-}
-
.toolbar-btn {
float: left;
padding: 0 7px;
@@ -173,7 +141,7 @@
svg {
width: 14px;
height: 14px;
- margin-top: 3px;
+ vertical-align: middle;
fill: $gl-text-color-secondary;
}
@@ -195,91 +163,11 @@
}
}
-.atwho-view {
- overflow-y: auto;
- overflow-x: hidden;
-
- .name,
- small.aliases,
- small.params {
- float: left;
- }
-
- small.aliases,
- small.params {
- padding: 2px 5px;
- }
-
- small.description {
- float: right;
- padding: 3px 5px;
- }
-
- .avatar-inline {
- margin-bottom: 0;
- }
-
- .has-warning {
- .name,
- .description {
- color: $orange-700;
- }
- }
-
- .cur {
- .avatar {
- @include disableAllAnimation;
- border: 1px solid $white-light;
- }
- }
-
- ul > li {
- @include clearfix;
- white-space: nowrap;
- }
-
- // TODO: fallback to global style
- .atwho-view-ul {
- padding: 8px 1px;
-
- li {
- padding: 8px 16px;
- border: 0;
-
- &.cur {
- background-color: $gray-darker;
- color: $gl-text-color;
-
- small {
- color: inherit;
- }
-
- &.has-warning {
- color: $orange-700;
- background-color: $orange-100;
- }
- }
-
- div.avatar {
- display: inline-flex;
- justify-content: center;
- align-items: center;
-
- .center {
- line-height: 14px;
- }
- }
-
- strong {
- color: $gl-text-color;
- }
- }
- }
-}
-
.md-suggestion-diff {
display: table !important;
border: 1px solid $border-color !important;
+ width: 100% !important;
+ font-family: $monospace-font !important;
}
.md-suggestion-header {
@@ -299,12 +187,7 @@
}
@include media-breakpoint-down(xs) {
- .atwho-view-ul {
- width: 350px;
- }
-
- .atwho-view ul li {
- overflow: hidden;
- text-overflow: ellipsis;
+ .referenced-users {
+ margin-right: 0;
}
}
diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss
index 9837b1a6bd0..18671f7c4d8 100644
--- a/app/assets/stylesheets/framework/mixins.scss
+++ b/app/assets/stylesheets/framework/mixins.scss
@@ -22,36 +22,6 @@
}
/*
- * Mixin for markdown tables
- */
-@mixin markdown-table {
- width: auto;
- display: inline-block;
- overflow-x: auto;
- border: 0;
- border-color: $gl-gray-100;
-
- @supports (width: fit-content) {
- display: block;
- width: fit-content;
- }
-
- tbody {
- background-color: $white-light;
- }
-
- tr {
- th {
- border-bottom: solid 2px $gl-gray-100;
- }
-
- td {
- border-color: $gl-gray-100;
- }
- }
-}
-
-/*
* Base mixin for lists in GitLab
*/
@mixin basic-list {
@@ -103,25 +73,6 @@
}
}
-@mixin bulleted-list {
- > ul {
- list-style-type: disc;
-
- ul {
- list-style-type: circle;
-
- ul {
- list-style-type: square;
- }
- }
- }
-}
-
-@mixin dark-diff-match-line {
- color: $dark-diff-match-bg;
- background: $dark-diff-match-color;
-}
-
@mixin webkit-prefix($property, $value) {
#{'-webkit-' + $property}: $value;
#{$property}: $value;
@@ -129,16 +80,13 @@
/* http://phrappe.com/css/conditional-css-for-webkit-based-browsers/ */
@mixin on-webkit-only {
+ /* stylelint-disable-next-line media-feature-name-no-vendor-prefix */
@media screen and (-webkit-min-device-pixel-ratio: 0) {
@content;
}
}
@mixin keyframes($animation-name) {
- @-webkit-keyframes #{$animation-name} {
- @content;
- }
-
@keyframes #{$animation-name} {
@content;
}
@@ -178,12 +126,10 @@
width: 43px;
height: 30px;
transition-duration: 0.3s;
- -webkit-transform: translateZ(0);
- background: linear-gradient(
- to $gradient-direction,
- $gradient-color 45%,
- rgba($gradient-color, 0.4)
- );
+ transform: translateZ(0);
+ background: linear-gradient(to $gradient-direction,
+ $gradient-color 45%,
+ rgba($gradient-color, 0.4));
&.scrolling {
visibility: visible;
@@ -278,8 +224,8 @@
background: $gray-light;
border: 1px solid $border-color;
color: $gl-text-color;
- position: sticky;
position: -webkit-sticky;
+ position: sticky;
top: $header-height;
padding: $grid-size;
@@ -379,8 +325,8 @@
line-height: 1;
padding: 0;
min-width: 16px;
- color: $gray-darkest;
- fill: $gray-darkest;
+ color: $gray-600;
+ fill: $gray-600;
.fa {
position: relative;
@@ -430,3 +376,12 @@
}
}
}
+
+/*
+* Mixin that handles the size and right margin of avatars.
+*/
+@mixin avatar-size($size, $margin-right) {
+ width: $size;
+ height: $size;
+ margin-right: $margin-right;
+}
diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss
deleted file mode 100644
index 3bb046d0e51..00000000000
--- a/app/assets/stylesheets/framework/mobile.scss
+++ /dev/null
@@ -1,88 +0,0 @@
-/** Common mobile (screen XS, SM) styles **/
-@include media-breakpoint-down(xs) {
- .container .content {
- margin-top: 20px;
- }
-
- .nav-links > li > a {
- padding: 10px;
- font-size: 12px;
- margin-right: 3px;
-
- .badge.badge-pill {
- display: none;
- }
- }
-
- .referenced-users {
- margin-right: 0;
- }
-
- .issues-details-filters:not(.filtered-search-block),
- .dash-projects-filters,
- .check-all-holder {
- display: none;
- }
-
- .rss-btn {
- display: none;
- }
-
- .project-home-links {
- display: none;
- }
-
- .project-home-panel {
- padding-left: 0 !important;
-
- .project-repo-buttons,
- .git-clone-holder {
- display: none;
- }
- }
-
- .group-buttons {
- display: none;
- }
-
- .container .title {
- padding-left: 15px !important;
- }
-
- .nav-links,
- .nav-links {
- li a {
- font-size: 14px;
- padding: 19px 10px;
- }
- }
-
- .activity-filter-block {
- display: none;
- }
-
- .projects-search-form {
- .btn {
- display: none;
- }
- }
-}
-
-@include media-breakpoint-down(sm) {
- .issues-filters {
- .milestone-filter {
- display: none;
- }
- }
-
- .page-title {
- .note-created-ago,
- .new-issue-link {
- display: none;
- }
- }
-
- aside:not(.right-sidebar) {
- display: none;
- }
-}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 7e30747963a..f75e5b55506 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -25,23 +25,19 @@
&.w-100 {
// after upgrading to Bootstrap 4.2 we can use $modal-header-padding-x here
// https://github.com/twbs/bootstrap/pull/26976
- margin-right: -2rem;
- padding-right: 2rem;
+ margin-right: -28px;
+ padding-right: 28px;
}
}
-
- .page-title {
- margin-top: 0;
- }
}
.modal-body {
background-color: $modal-body-bg;
line-height: $line-height-base;
- min-height: $modal-body-height;
position: relative;
padding: #{3 * $grid-size} #{2 * $grid-size};
text-align: left;
+ white-space: normal;
.form-actions {
margin: #{2 * $grid-size} #{-2 * $grid-size} #{-2 * $grid-size};
@@ -56,24 +52,22 @@
display: flex;
flex-direction: row;
- .btn + .btn {
+ .btn + .btn:not(.dropdown-toggle-split),
+ .btn + .btn-group,
+ .btn-group + .btn {
margin-left: $grid-size;
}
@include media-breakpoint-down(xs) {
flex-direction: column;
- .btn + .btn {
+ .btn + .btn:not(.dropdown-toggle-split),
+ .btn + .btn-group,
+ .btn-group + .btn {
margin-left: 0;
margin-top: $grid-size;
}
}
-
- @include media-breakpoint-up(sm) {
- .btn:nth-child(1) {
- margin-left: auto;
- }
- }
}
body.modal-open {
@@ -105,3 +99,42 @@ body.modal-open {
margin: 0;
}
}
+
+.issues-import-modal,
+.issues-export-modal {
+ .modal-header {
+ justify-content: flex-start;
+
+ .import-export-svg-container {
+ flex-grow: 1;
+ height: 56px;
+ padding: $gl-btn-padding $gl-btn-padding 0;
+ text-align: right;
+
+ .illustration {
+ height: inherit;
+ width: initial;
+ }
+ }
+ }
+
+ .modal-body {
+ padding: 0;
+
+ .modal-subheader {
+ justify-content: flex-start;
+ align-items: center;
+ border-bottom: 1px solid $modal-border-color;
+ padding: 14px;
+ }
+
+ .modal-text {
+ padding: $gl-padding-24 $gl-padding;
+ min-height: $modal-body-height;
+ }
+ }
+
+ .checkmark {
+ color: $green-400;
+ }
+}
diff --git a/app/assets/stylesheets/framework/notes.scss b/app/assets/stylesheets/framework/notes.scss
index d349e3fad9c..85ddf11d6fe 100644
--- a/app/assets/stylesheets/framework/notes.scss
+++ b/app/assets/stylesheets/framework/notes.scss
@@ -4,7 +4,7 @@
}
// Diff is side by side
- .notes_content.parallel & {
+ .notes-content.parallel & {
// We hide at double what we normally hide at because
// there are two columns of notes
@media (#{$condition}-width: (2 * $breakpoint-width)) {
diff --git a/app/assets/stylesheets/framework/page_title.scss b/app/assets/stylesheets/framework/page_title.scss
index e8302953a63..c77e2be8e5a 100644
--- a/app/assets/stylesheets/framework/page_title.scss
+++ b/app/assets/stylesheets/framework/page_title.scss
@@ -1,8 +1,4 @@
.page-title-holder {
- @extend .d-flex;
- @extend .align-items-center;
-
- padding-top: $gl-padding-top;
border-bottom: 1px solid $border-color;
.page-title {
diff --git a/app/assets/stylesheets/framework/panels.scss b/app/assets/stylesheets/framework/panels.scss
index 3a117106cff..cd3d6f8297e 100644
--- a/app/assets/stylesheets/framework/panels.scss
+++ b/app/assets/stylesheets/framework/panels.scss
@@ -7,7 +7,6 @@
margin-bottom: $gl-vert-padding;
}
-
.card-header {
padding: $gl-vert-padding $gl-padding;
line-height: 36px;
diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss
index 29a9c076cdf..6bd44ee19bd 100644
--- a/app/assets/stylesheets/framework/responsive_tables.scss
+++ b/app/assets/stylesheets/framework/responsive_tables.scss
@@ -39,7 +39,7 @@
.table-section {
white-space: nowrap;
- $section-widths: 5 10 15 20 25 30 40 50 60 100;
+ $section-widths: 5 10 15 20 25 30 40 50 60 70 80 100;
@each $width in $section-widths {
&.section-#{$width} {
flex: 0 0 #{$width + '%'};
diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
index 19640ab5986..31297b9d20c 100644
--- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss
+++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss
@@ -181,6 +181,33 @@
margin: 0;
width: 100%;
}
+
+ &.inline {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-between;
+
+ > .btn,
+ > .btn-container,
+ > .dropdown,
+ > input,
+ > form {
+ flex: 1 1 auto;
+ margin: 0 0 10px;
+ margin-left: $gl-padding-top;
+ width: auto;
+
+ &:first-child {
+ margin-left: 0;
+ float: none;
+ }
+ }
+
+ .btn-full {
+ flex: 1 1 100%;
+ margin-left: 0;
+ }
+ }
}
}
diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss
index 7f0edd88dfb..81ccea1e01f 100644
--- a/app/assets/stylesheets/framework/selects.scss
+++ b/app/assets/stylesheets/framework/selects.scss
@@ -1,6 +1,11 @@
/** Select2 selectbox style override **/
.select2-container {
width: 100% !important;
+
+ &.input-md,
+ &.input-lg {
+ display: block;
+ }
}
.select2-container,
@@ -27,7 +32,7 @@
}
&::after {
- content: "\f078";
+ content: '\f078';
position: absolute;
z-index: 1;
text-align: center;
@@ -52,6 +57,16 @@
color: $gl-text-color;
}
}
+
+ &.is-invalid {
+ ~ .invalid-feedback {
+ display: block;
+ }
+
+ .select2-choices {
+ border-color: $red-500;
+ }
+ }
}
.select2-drop,
@@ -62,10 +77,18 @@
min-width: 175px;
color: $gl-text-color;
z-index: 999;
+
+ .modal-open & {
+ z-index: $zindex-modal + 200;
+ }
}
.select2-drop-mask {
z-index: 998;
+
+ .modal-open & {
+ z-index: $zindex-modal + 100;
+ }
}
.select2-drop.select2-drop-above.select2-drop-active {
@@ -241,6 +264,16 @@
}
}
+.project-result {
+ .project-name {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .project-path {
+ color: $gl-gray-400;
+ }
+}
+
.user-result {
min-height: 24px;
display: flex;
diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss
index c4dbcf2ddc9..43d0e51e4c9 100644
--- a/app/assets/stylesheets/framework/sidebar.scss
+++ b/app/assets/stylesheets/framework/sidebar.scss
@@ -157,3 +157,55 @@
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}
+
+.ancestor-tree {
+ .vertical-timeline {
+ position: relative;
+ list-style: none;
+ margin: 0;
+ padding: 0;
+
+ &::before {
+ content: '';
+ border-left: 1px solid $gray-500;
+ position: absolute;
+ top: $gl-padding;
+ bottom: $gl-padding;
+ left: map-get($spacers, 2) - 1px;
+ }
+
+ &-row {
+ margin-top: map-get($spacers, 3);
+
+ &:nth-child(1) {
+ margin-top: 0;
+ }
+ }
+
+ &-icon {
+ /**
+ * 2px extra is to give a little more height than needed
+ * to hide timeline line before/after the element starts/ends
+ */
+ height: map-get($spacers, 4) + 2px;
+ z-index: 1;
+ position: relative;
+ top: -3px;
+ padding: $gl-padding-4 0;
+ background-color: $gray-light;
+
+ &.opened {
+ color: $green-500;
+ }
+
+ &.closed {
+ color: $blue-500;
+ }
+ }
+
+ &-content {
+ line-height: initial;
+ margin-left: $gl-padding-8;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/sortable.scss b/app/assets/stylesheets/framework/sortable.scss
new file mode 100644
index 00000000000..8c070200135
--- /dev/null
+++ b/app/assets/stylesheets/framework/sortable.scss
@@ -0,0 +1,92 @@
+.sortable-container {
+ background-color: $gray-light;
+
+ .flex-list {
+ padding: 5px;
+ margin-bottom: 0;
+ }
+}
+
+.sortable-row {
+ .flex-row {
+ display: flex;
+
+ &.issuable-info-container {
+ padding-right: 0;
+ }
+ }
+
+ .sortable-link {
+ color: $black;
+ }
+}
+
+.gl-sortable {
+ .header {
+ user-select: none;
+
+ &:hover {
+ cursor: pointer;
+ background-color: $gray-100;
+ }
+
+ &:focus {
+ outline: 1px solid $blue-300;
+ }
+ }
+}
+
+.related-issues-list-item {
+ .card-body,
+ .issuable-info-container {
+ padding: $gl-padding-4 $gl-padding-4 $gl-padding-4 $gl-padding;
+
+ .block-truncated {
+ padding: $gl-padding-8 0;
+ line-height: $gl-btn-line-height;
+ }
+
+ @include media-breakpoint-down(md) {
+ padding-left: $gl-padding;
+
+ .block-truncated {
+ flex-direction: column-reverse;
+ padding: $gl-padding-4 0;
+
+ .text-secondary {
+ margin-top: $gl-padding-4;
+ }
+
+ .issue-token-title-text {
+ display: block;
+ }
+ }
+
+ .issue-item-remove-button {
+ align-self: baseline;
+ }
+ }
+
+ @include media-breakpoint-only(md) {
+ .block-truncated .issue-token-title-text {
+ white-space: nowrap;
+ }
+
+ .issue-item-remove-button {
+ align-self: center;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ padding-left: $gl-padding-8;
+
+ .block-truncated .issue-token-title-text {
+ white-space: normal;
+ }
+ }
+ }
+
+ &.is-dragging {
+ padding: 0;
+ }
+}
diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss
new file mode 100644
index 00000000000..91fe75075dc
--- /dev/null
+++ b/app/assets/stylesheets/framework/spinner.scss
@@ -0,0 +1,51 @@
+@mixin spinner-color($color) {
+ border-color: rgba($color, 0.25);
+ border-top-color: $color;
+}
+
+@mixin spinner-size($size, $border-width) {
+ width: $size;
+ height: $size;
+ border-width: $border-width;
+ @include webkit-prefix(transform-origin, 50% 50% calc((#{$size} / 2) + #{$border-width}));
+}
+
+@keyframes spinner-rotate {
+ 0% {
+ transform: rotate(0);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.spinner {
+ border-radius: 50%;
+ position: relative;
+ margin: 0 auto;
+ animation-name: spinner-rotate;
+ animation-duration: 0.6s;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ border-style: solid;
+ display: inline-flex;
+ @include spinner-size(16px, 2px);
+ @include spinner-color($orange-600);
+
+ &.spinner-md {
+ @include spinner-size(32px, 3px);
+ }
+
+ &.spinner-lg {
+ @include spinner-size(64px, 4px);
+ }
+
+ &.spinner-dark {
+ @include spinner-color($gray-700);
+ }
+
+ &.spinner-light {
+ @include spinner-color($white);
+ }
+}
diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss
index 29a2d5881f7..2255d3be75b 100644
--- a/app/assets/stylesheets/framework/stacked_progress_bar.scss
+++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss
@@ -3,7 +3,7 @@
height: 16px;
border-radius: 10px;
overflow: hidden;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
.status-unavailable,
.status-green,
@@ -24,7 +24,7 @@
.status-unavailable {
padding: 0 10px;
- color: $theme-gray-700;
+ color: $gray-700;
}
.status-green {
@@ -36,11 +36,11 @@
}
.status-neutral {
- background-color: $theme-gray-200;
+ background-color: $gray-200;
color: $gl-gray-dark;
&:hover {
- background-color: $theme-gray-300;
+ background-color: $gray-300;
}
}
diff --git a/app/assets/stylesheets/framework/system_messages.scss b/app/assets/stylesheets/framework/system_messages.scss
new file mode 100644
index 00000000000..6205ccaa52f
--- /dev/null
+++ b/app/assets/stylesheets/framework/system_messages.scss
@@ -0,0 +1,111 @@
+.header-message,
+.footer-message {
+ padding: 0 15px;
+ border: 1px solid transparent;
+ border-radius: 0;
+ position: fixed;
+ left: 0;
+ width: 100%;
+ text-align: center;
+ margin: 0;
+ z-index: 1000;
+
+ p {
+ @include str-truncated(100%);
+ margin-top: -1px;
+ margin-bottom: 0;
+ font-size: $gl-font-size-small;
+ }
+}
+
+.header-message {
+ top: 0;
+ height: $system-header-height;
+ line-height: $system-header-height;
+}
+
+.footer-message {
+ bottom: 0;
+ height: $system-footer-height;
+ line-height: $system-footer-height;
+}
+
+.with-performance-bar {
+ .header-message {
+ top: $performance-bar-height;
+ }
+}
+
+// System Header
+.with-system-header {
+ // main navigation
+ // login page
+ .navbar-gitlab,
+ .fixed-top {
+ top: $system-header-height;
+ }
+
+ // left sidebar eg: project page
+ // right sidebar eg: MR page
+ .nav-sidebar,
+ .right-sidebar {
+ top: $system-header-height + $header-height;
+ }
+
+ .content-wrapper {
+ margin-top: $system-header-height + $header-height;
+ }
+
+ // Performance Bar
+ // System Header
+ &.with-performance-bar {
+ // main navigation
+ header.navbar-gitlab {
+ top: $performance-bar-height + $system-header-height;
+ }
+
+ .layout-page {
+ margin-top: $header-height + $performance-bar-height + $system-header-height;
+ }
+
+ // left sidebar eg: project page
+ // right sidebar eg: MR page
+ .nav-sidebar,
+ .right-sidebar {
+ top: $header-height + $performance-bar-height + $system-header-height;
+ }
+ }
+}
+
+// System Footer
+.with-system-footer {
+ // left sidebar eg: project page
+ // right sidebar eg: mr page
+ .nav-sidebar,
+ .right-sidebar,
+ // navless pages' footer eg: login page
+ // navless pages' footer border eg: login page
+ &.devise-layout-html body .footer-container,
+ &.devise-layout-html body hr.footer-fixed {
+ bottom: $system-footer-height;
+ }
+}
+
+.fullscreen-layout {
+ .header-message,
+ .footer-message {
+ position: static;
+ top: auto;
+ bottom: auto;
+ }
+
+ .content-wrapper {
+ .with-system-header & {
+ margin-top: 0;
+ }
+
+ .with-system-footer & {
+ margin-top: 0;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss
index 6954e6599b1..ba406bac50b 100644
--- a/app/assets/stylesheets/framework/tables.scss
+++ b/app/assets/stylesheets/framework/tables.scss
@@ -50,6 +50,14 @@ table {
border-color: $white-normal;
}
}
+
+ .thead-white {
+ th {
+ background-color: $white-light;
+ color: $gl-text-color-secondary;
+ border-top: 0;
+ }
+ }
}
&.responsive-table {
diff --git a/app/assets/stylesheets/framework/terms.scss b/app/assets/stylesheets/framework/terms.scss
index 3f4be8829d7..b07d6023127 100644
--- a/app/assets/stylesheets/framework/terms.scss
+++ b/app/assets/stylesheets/framework/terms.scss
@@ -13,7 +13,6 @@
.card {
.card-header {
- display: -webkit-flex;
display: flex;
align-items: center;
justify-content: space-between;
diff --git a/app/assets/stylesheets/framework/timeline.scss b/app/assets/stylesheets/framework/timeline.scss
index 3d5208c3db5..e8176e59c19 100644
--- a/app/assets/stylesheets/framework/timeline.scss
+++ b/app/assets/stylesheets/framework/timeline.scss
@@ -55,4 +55,5 @@
.discussion .timeline-entry {
margin: 0;
border-right: 0;
+ border-radius: $border-radius-default $border-radius-default 0 0;
}
diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss
index 8258da07e4d..5f8ac3b7e37 100644
--- a/app/assets/stylesheets/framework/toggle.scss
+++ b/app/assets/stylesheets/framework/toggle.scss
@@ -34,7 +34,7 @@
background: $gl-gray-400;
border-radius: 12px;
padding: 3px;
- transition: all .4s ease;
+ transition: all 0.4s ease;
&::selection,
&::before::selection,
@@ -52,7 +52,7 @@
left: 0;
border-radius: 9px;
background: $feature-toggle-color;
- transition: all .2s ease;
+ transition: all 0.2s ease;
&,
.toggle-icon-svg {
@@ -135,12 +135,18 @@
}
@keyframes animate-enabled {
- 0%, 35% { opacity: 0; }
+ 0%,
+
+ 35% { opacity: 0; }
+
100% { opacity: 1; }
}
@keyframes animate-disabled {
- 0%, 35% { opacity: 0; }
+ 0%,
+
+ 35% { opacity: 0; }
+
100% { opacity: 1; }
}
}
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 0c81dc2e156..7c152efd9c7 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -1,13 +1,44 @@
-@mixin md-typography {
+/**
+ * Apply Markdown typography
+ *
+ */
+.md:not(.use-csslab) {
color: $gl-text-color;
word-wrap: break-word;
- [dir="auto"] {
+ [dir='auto'] {
text-align: initial;
}
+ *:first-child {
+ margin-top: 0;
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+
+ p {
+ color: $gl-text-color;
+ margin: 0 0 16px;
+
+ > code {
+ font-weight: inherit;
+ }
+
+ a:not(.no-attachment-icon) img {
+ // Remove bottom padding because
+ // <p> already has $gl-padding bottom
+ margin-bottom: 0;
+ }
+ }
+
a {
color: $blue-600;
+
+ > code {
+ color: $blue-600;
+ }
}
img:not(.emoji) {
@@ -28,18 +59,12 @@
max-width: 100%;
}
- p a:not(.no-attachment-icon) img {
- // Remove bottom padding because
- // <p> already has $gl-padding bottom
- margin-bottom: 0;
- }
-
- *:first-child {
- margin-top: 0;
- }
-
- > :last-child {
- margin-bottom: 0;
+ &:not(.md-file) img:not(.emoji) {
+ border: 1px solid $white-normal;
+ padding: 5px;
+ margin: 5px 0;
+ // Ensure that image does not exceed viewport
+ max-height: calc(100vh - 100px);
}
// Single code lines should wrap
@@ -47,13 +72,7 @@
font-family: $monospace-font;
white-space: pre-wrap;
word-wrap: normal;
- }
-
- // Multi-line code blocks should scroll horizontally
- pre {
- code {
- white-space: pre;
- }
+ word-break: keep-all;
}
kbd {
@@ -138,19 +157,34 @@
}
}
- p {
- color: $gl-text-color;
- margin: 0 0 16px;
+ hr {
+ // Darken 'whitesmoke' a bit to make it more visible in note bodies
+ border-color: darken($gray-normal, 8%);
+ margin: 10px 0;
}
- table {
+ table:not(.code) {
@extend .table;
@extend .table-bordered;
margin: 16px 0;
color: $gl-text-color;
+ border: 0;
+ width: auto;
+ display: block;
+ overflow-x: auto;
+
+ tbody {
+ background-color: $white-light;
+ }
+
+ tr {
+ th {
+ border-bottom: solid 2px $gl-gray-200;
+ }
- th {
- background: $label-gray-bg;
+ td {
+ border-color: $gl-gray-200;
+ }
}
}
@@ -165,6 +199,10 @@
overflow-x: auto;
border-radius: 2px;
+ // Multi-line code blocks should scroll horizontally
+ code {
+ white-space: pre;
+ }
&.plain-readme {
background: none;
@@ -175,14 +213,6 @@
}
}
- p > code {
- font-weight: inherit;
- }
-
- a > code {
- color: $blue-600;
- }
-
dd {
margin-left: $gl-padding;
}
@@ -198,6 +228,18 @@
margin: 3px 28px 3px 0 !important;
}
+ > ul {
+ list-style-type: disc;
+
+ ul {
+ list-style-type: circle;
+
+ ul {
+ list-style-type: square;
+ }
+ }
+ }
+
li {
line-height: 1.6em;
margin-left: 25px;
@@ -226,8 +268,8 @@
}
}
- a[href*="/uploads/"],
- a[href*="storage.googleapis.com/google-code-attachments/"] {
+ a[href*='/uploads/'],
+ a[href*='storage.googleapis.com/google-code-attachments/'] {
&::before {
margin-right: 4px;
@@ -235,17 +277,17 @@
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
- content: "\f0c6";
+ content: '\f0c6';
}
&:hover::before {
text-decoration: none;
}
- }
- a.no-attachment-icon {
- &::before {
- display: none;
+ &.no-attachment-icon {
+ &::before {
+ display: none;
+ }
}
}
@@ -302,11 +344,10 @@ body {
}
.page-title-empty {
- margin-top: 0;
+ margin: 12px 0;
line-height: 1.3;
font-size: 1.25em;
font-weight: $gl-font-weight-bold;
- margin: 12px 0;
}
h1,
@@ -365,18 +406,6 @@ code {
}
/**
- * Apply Markdown typography
- *
- */
-.wiki:not(.use-csslab) {
- @include md-typography;
-}
-
-.md:not(.use-csslab) {
- @include md-typography;
-}
-
-/**
* Textareas intended for GFM
*
*/
@@ -416,6 +445,7 @@ h4 {
/**
* form text input i.e. search bar, comments, forms, etc.
*/
+/* stylelint-disable selector-no-vendor-prefix */
input,
textarea {
&::-webkit-input-placeholder {
@@ -440,5 +470,10 @@ textarea {
color: $gl-text-color-tertiary;
}
}
+/* stylelint-enable */
.lh-100 { line-height: 1; }
+
+wbr {
+ display: inline-block;
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index c0bba30944a..28768bdf88f 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -11,6 +11,14 @@ $default-transition-duration: 0.15s;
$contextual-sidebar-width: 220px;
$contextual-sidebar-collapsed-width: 50px;
$toggle-sidebar-height: 48px;
+$spacing-scale: (
+ 0: 0,
+ 1: #{0.5 * $grid-size},
+ 2: $grid-size,
+ 3: #{2 * $grid-size},
+ 4: #{3 * $grid-size},
+ 5: #{4 * $grid-size}
+);
/*
* Color schema
@@ -23,6 +31,7 @@ $darken-border-dashed-factor: 25%;
$white-light: #fff;
$white-normal: #f0f0f0;
$white-dark: #eaeaea;
+$white-transparent: rgba(255, 255, 255, 0.8);
$gray-lightest: #fdfdfd;
$gray-light: #fafafa;
@@ -32,13 +41,22 @@ $gray-dark: darken($gray-light, $darken-dark-factor);
$gray-darker: #eee;
$gray-darkest: #c4c4c4;
-$gl-gray-100: #dddddd;
-$gl-gray-200: #cccccc;
-$gl-gray-350: #aaaaaa;
-$gl-gray-400: #999999;
-$gl-gray-500: #777777;
-$gl-gray-600: #666666;
-$gl-gray-700: #555555;
+$black: #000;
+$black-transparent: rgba(0, 0, 0, 0.3);
+$almost-black: #242424;
+
+$t-gray-a-02: rgba($black, 0.02);
+$t-gray-a-04: rgba($black, 0.04);
+$t-gray-a-06: rgba($black, 0.06);
+$t-gray-a-08: rgba($black, 0.08);
+
+$gl-gray-100: #ddd;
+$gl-gray-200: #ccc;
+$gl-gray-350: #aaa;
+$gl-gray-400: #999;
+$gl-gray-500: #777;
+$gl-gray-600: #666;
+$gl-gray-700: #555;
$green-50: #f1fdf6;
$green-100: #dcf5e7;
@@ -88,6 +106,96 @@ $red-800: #8b2615;
$red-900: #711e11;
$red-950: #4b140b;
+$gray-50: #fafafa;
+$gray-100: #f2f2f2;
+$gray-200: #dfdfdf;
+$gray-300: #ccc;
+$gray-400: #bababa;
+$gray-500: #a7a7a7;
+$gray-600: #919191;
+$gray-700: #707070;
+$gray-800: #4f4f4f;
+$gray-900: #2e2e2e;
+$gray-950: #1f1f1f;
+
+$greens: (
+ '50': $green-50,
+ '100': $green-100,
+ '200': $green-200,
+ '300': $green-300,
+ '400': $green-400,
+ '500': $green-500,
+ '600': $green-600,
+ '700': $green-700,
+ '800': $green-800,
+ '900': $green-900,
+ '950': $green-950
+);
+
+$blues: (
+ '50': $blue-50,
+ '100': $blue-100,
+ '200': $blue-200,
+ '300': $blue-300,
+ '400': $blue-400,
+ '500': $blue-500,
+ '600': $blue-600,
+ '700': $blue-700,
+ '800': $blue-800,
+ '900': $blue-900,
+ '950': $blue-950
+);
+
+$oranges: (
+ '50': $orange-50,
+ '100': $orange-100,
+ '200': $orange-200,
+ '300': $orange-300,
+ '400': $orange-400,
+ '500': $orange-500,
+ '600': $orange-600,
+ '700': $orange-700,
+ '800': $orange-800,
+ '900': $orange-900,
+ '950': $orange-950
+);
+
+$reds: (
+ '50': $red-50,
+ '100': $red-100,
+ '200': $red-200,
+ '300': $red-300,
+ '400': $red-400,
+ '500': $red-500,
+ '600': $red-600,
+ '700': $red-700,
+ '800': $red-800,
+ '900': $red-900,
+ '950': $red-950
+);
+
+$grays: (
+ '50': $gray-50,
+ '100': $gray-100,
+ '200': $gray-200,
+ '300': $gray-300,
+ '400': $gray-400,
+ '500': $gray-500,
+ '600': $gray-600,
+ '700': $gray-700,
+ '800': $gray-800,
+ '900': $gray-900,
+ '950': $gray-950
+);
+
+$color-ranges: (
+ 'primary': $blues,
+ 'secondary': $grays,
+ 'success': $greens,
+ 'warning': $oranges,
+ 'danger': $reds
+);
+
// GitLab themes
$indigo-50: #f7f7ff;
@@ -102,18 +210,6 @@ $indigo-800: #393982;
$indigo-900: #292961;
$indigo-950: #1a1a40;
-$theme-gray-50: #fafafa;
-$theme-gray-100: #f2f2f2;
-$theme-gray-200: #dfdfdf;
-$theme-gray-300: #cccccc;
-$theme-gray-400: #bababa;
-$theme-gray-500: #a7a7a7;
-$theme-gray-600: #919191;
-$theme-gray-700: #707070;
-$theme-gray-800: #4f4f4f;
-$theme-gray-900: #2e2e2e;
-$theme-gray-950: #1f1f1f;
-
$theme-blue-50: #f4f8fc;
$theme-blue-100: #e6edf5;
$theme-blue-200: #c8d7e6;
@@ -170,11 +266,6 @@ $theme-light-red-500: #c24b38;
$theme-light-red-600: #b03927;
$theme-light-red-700: #a62e21;
-$black: #000;
-$black-transparent: rgba(0, 0, 0, 0.3);
-$shadow-color: rgba($black, 0.1);
-$almost-black: #242424;
-
$border-white-light: darken($white-light, $darken-border-factor);
$border-white-normal: darken($white-normal, $darken-border-factor);
@@ -187,6 +278,7 @@ $border-gray-dark: darken($white-normal, $darken-border-factor);
* UI elements
*/
$border-color: #e5e5e5;
+$shadow-color: $t-gray-a-08;
$well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2;
$well-light-border: #f1f1f1;
@@ -198,7 +290,6 @@ $well-light-text-color: #5b6169;
$gl-font-size: 14px;
$gl-font-size-xs: 11px;
$gl-font-size-small: 12px;
-$gl-font-size-medium: 1.43rem;
$gl-font-size-large: 16px;
$gl-font-weight-normal: 400;
$gl-font-weight-bold: 600;
@@ -214,6 +305,15 @@ $gl-gray-dark: #313236;
$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54;
+$type-scale: (
+ 1: 12px,
+ 2: 14px,
+ 3: 16px,
+ 4: 20px,
+ 5: 28px,
+ 6: 42px
+);
+
/*
* Lists
*/
@@ -239,6 +339,7 @@ $gl-padding-8: 8px;
$gl-padding: 16px;
$gl-padding-24: 24px;
$gl-padding-32: 32px;
+$gl-padding-50: 50px;
$gl-col-padding: 15px;
$gl-input-padding: 10px;
$gl-vert-padding: 6px;
@@ -246,7 +347,7 @@ $gl-padding-top: 10px;
$gl-sidebar-padding: 22px;
$gl-bar-padding: 3px;
$input-horizontal-padding: 12px;
-$browserScrollbarSize: 10px;
+$browser-scrollbar-size: 10px;
/*
* Misc
@@ -260,6 +361,7 @@ $container-text-max-width: 540px;
$gl-avatar-size: 40px;
$border-radius-default: 4px;
$border-radius-small: 2px;
+$border-radius-large: 8px;
$default-icon-size: 18px;
$layout-link-gray: #7e7c7c;
$btn-side-margin: 10px;
@@ -271,15 +373,20 @@ $general-hover-transition-duration: 100ms;
$general-hover-transition-curve: linear;
$highlight-changes-color: rgb(235, 255, 232);
$performance-bar-height: 35px;
+$system-header-height: 16px;
+$system-footer-height: $system-header-height;
$flash-height: 52px;
$context-header-height: 60px;
$breadcrumb-min-height: 48px;
-$project-title-row-height: 64px;
-$project-avatar-mobile-size: 24px;
+$home-panel-title-row-height: 64px;
+$home-panel-avatar-mobile-size: 24px;
$gl-line-height: 16px;
$gl-line-height-24: 24px;
$gl-line-height-14: 14px;
+$issue-box-upcoming-bg: #8f8f8f;
+$pages-group-name-color: #4c4e54;
+
/*
* Common component specific colors
*/
@@ -315,8 +422,8 @@ $line-select-yellow: #fcf8e7;
$line-select-yellow-dark: #f0e2bd;
$dark-diff-match-bg: rgba(255, 255, 255, 0.3);
$dark-diff-match-color: rgba(255, 255, 255, 0.1);
-$diff-image-info-color: gray;
-$diff-view-modes-color: gray;
+$diff-image-info-color: #808080;
+$diff-view-modes-color: #808080;
$diff-view-modes-border: #c1c1c1;
$diff-jagged-border-gradient-color: darken($white-normal, 8%);
@@ -325,7 +432,7 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
*/
$monospace-font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono',
'Courier New', 'andale mono', 'lucida console', monospace;
-$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
+$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans', Ubuntu, Cantarell,
'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
@@ -335,6 +442,7 @@ $regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-San
$dropdown-width: 300px;
$dropdown-min-height: 40px;
$dropdown-max-height: 312px;
+$dropdown-max-height-lg: 445px;
$dropdown-vertical-offset: 4px;
$dropdown-empty-row-bg: rgba(#000, 0.04);
$dropdown-shadow-color: rgba(#000, 0.1);
@@ -390,6 +498,17 @@ $pagination-line-height: 20px;
$pagination-disabled-color: #cdcdcd;
/*
+* Toasts
+*/
+$toast-offset: 24px;
+$toast-height: 48px;
+$toast-max-width: 586px;
+$toast-padding-right: 42px;
+$toast-default-margin: 8px;
+$toast-action-margin-left: 16px;
+$toast-background-opacity: 0.95;
+
+/*
* Status icons
*/
$status-icon-size: 22px;
@@ -400,6 +519,8 @@ $status-icon-size: 22px;
$award-emoji-menu-shadow: rgba(0, 0, 0, 0.175);
$award-emoji-positive-add-bg: #fed159;
$award-emoji-positive-add-lines: #bb9c13;
+$award-emoji-width: 376px;
+$award-emoji-width-xs: 90%;
/*
* Search Box
@@ -468,6 +589,7 @@ $issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards
*/
$avatar-radius: 50%;
$gl-avatar-size: 40px;
+$gl-avatar-border-opacity: 0.1;
/*
* Blame
@@ -486,6 +608,7 @@ $builds-trace-bg: #111;
*/
$commit-max-width-marker-color: rgba(0, 0, 0, 0);
$commit-message-text-area-bg: rgba(0, 0, 0, 0);
+$commit-stat-summary-height: 36px;
/*
* Common
@@ -509,6 +632,8 @@ $gl-field-focus-shadow: rgba(0, 0, 0, 0.075);
$gl-field-focus-shadow-error: rgba($red-500, 0.6);
$input-short-width: 200px;
$input-short-md-width: 280px;
+$input-md-width: 240px;
+$input-lg-width: 320px;
/*
* Help
@@ -557,6 +682,11 @@ $feature-toggle-text-color: #fff;
$feature-toggle-color-enabled: #4a8bee;
/*
+ * Monitor Charts
+ */
+$chart-tooltip-max-width: 512px;
+
+/*
Stat Graph
*/
$stat-graph-common-bg: #f3f3f3;
@@ -612,6 +742,18 @@ Animation Functions
$dropdown-animation-timing: cubic-bezier(0.23, 1, 0.32, 1);
/*
+GitLab Plans
+*/
+$gl-gold-plan: #d4af37;
+$gl-silver-plan: #91a1ab;
+$gl-bronze-plan: #cd7f32;
+
+/*
+Cross-project Pipelines
+ */
+$linked-project-column-margin: 60px;
+
+/*
Performance Bar
*/
$perf-bar-production: #222;
@@ -635,6 +777,17 @@ $image-comment-cursor-left-offset: 12;
$image-comment-cursor-top-offset: 12;
/*
+Add GitLab Slack Application
+*/
+$add-to-slack-popup-max-width: 400px;
+$add-to-slack-gif-max-width: 850px;
+$add-to-slack-well-max-width: 750px;
+$add-to-slack-logo-size: 100px;
+$double-headed-arrow-width: 100px;
+$double-headed-arrow-height: 25px;
+$right-arrow-size: 16px;
+
+/*
Popup
*/
$popup-triangle-size: 15px;
@@ -650,6 +803,7 @@ $border-color-settings: #e1e1e1;
Modals
*/
$modal-body-height: 134px;
+$modal-border-color: #e9ecef;
$priority-label-empty-state-width: 114px;
@@ -657,3 +811,27 @@ $priority-label-empty-state-width: 114px;
Issues Analytics
*/
$issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15);
+
+/*
+Merge Requests
+*/
+$mr-tabs-height: 51px;
+$mr-version-controls-height: 56px;
+
+/*
+Compare Branches
+*/
+$compare-branches-sticky-header-height: 68px;
+
+/**
+ Bootstrap 4.2.0 introduced new icons for validating forms.
+ Our design system does not use those, so we are disabling them for now:
+ - Docs: https://getbootstrap.com/docs/4.3/components/forms/#server-side
+ - Issue: https://gitlab.com/gitlab-org/design.gitlab.com/issues/242
+ */
+$enable-validation-icons: false;
+
+/*
+Licenses
+*/
+$license-header-cell-width: 150px;
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index 5ca76bb6c5a..ea96381a098 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -5,11 +5,11 @@
$secondary: $gray-light;
$input-disabled-bg: $gray-light;
-$input-border-color: $theme-gray-200;
+$input-border-color: $gray-200;
$input-color: $gl-text-color;
+$input-font-size: $gl-font-size;
$font-family-sans-serif: $regular-font;
$font-family-monospace: $monospace-font;
-$input-line-height: 20px;
$btn-line-height: 20px;
$table-accent-bg: $gray-light;
$card-border-color: $border-color;
@@ -20,7 +20,7 @@ $warning: $orange-500;
$danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
-$dropdown-divider-bg: $theme-gray-200;
+$dropdown-divider-bg: $gray-200;
$dropdown-item-padding-y: 8px;
$dropdown-item-padding-x: 12px;
$popover-max-width: 300px;
@@ -28,3 +28,23 @@ $popover-border-width: 1px;
$popover-border-color: $border-color;
$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color;
$popover-arrow-outer-color: $shadow-color;
+$h1-font-size: 14px * 2.5;
+$h2-font-size: 14px * 2;
+$h3-font-size: 14px * 1.75;
+$h4-font-size: 14px * 1.5;
+$h5-font-size: 14px * 1.25;
+$h6-font-size: 14px;
+$spacer: $grid-size;
+$spacers: (
+ 0: 0,
+ 1: ($spacer * 0.5),
+ 2: ($spacer),
+ 3: ($spacer * 2),
+ 4: ($spacer * 3),
+ 5: ($spacer * 4),
+ 6: ($spacer * 5),
+ 7: ($spacer * 6),
+ 8: ($spacer * 7),
+ 9: ($spacer * 8)
+);
+$pagination-color: $gl-text-color;
diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss
index e07a177e153..e3bdc0b0199 100644
--- a/app/assets/stylesheets/framework/vue_transitions.scss
+++ b/app/assets/stylesheets/framework/vue_transitions.scss
@@ -1,9 +1,13 @@
.fade-enter-active,
-.fade-leave-active {
+.fade-leave-active,
+.fade-in-enter-active,
+.fade-out-leave-active {
transition: opacity $sidebar-transition-duration $general-hover-transition-curve;
}
.fade-enter,
+.fade-in-enter,
+.fade-out-leave-to,
.fade-leave-to {
opacity: 0;
}
diff --git a/app/assets/stylesheets/framework/wells.scss b/app/assets/stylesheets/framework/wells.scss
index 161943766d4..434cbd6d21c 100644
--- a/app/assets/stylesheets/framework/wells.scss
+++ b/app/assets/stylesheets/framework/wells.scss
@@ -12,6 +12,10 @@
border-bottom: 1px solid $well-inner-border;
}
+ &.borderless {
+ border-bottom: 0;
+ }
+
&.branch-info {
.commit-sha,
.commit-info {
diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss
new file mode 100644
index 00000000000..ac3214a07d9
--- /dev/null
+++ b/app/assets/stylesheets/highlight/common.scss
@@ -0,0 +1,18 @@
+@import '../framework/variables';
+
+@mixin diff-background($background, $idiff, $border) {
+ background: $background;
+
+ &.line_content span.idiff {
+ background: $idiff;
+ }
+
+ &.diff-line-num {
+ border-color: $border;
+ }
+}
+
+@mixin dark-diff-match-line {
+ color: $dark-diff-match-bg;
+ background: $dark-diff-match-color;
+}
diff --git a/app/assets/stylesheets/highlight/embedded.scss b/app/assets/stylesheets/highlight/embedded.scss
index 44c8a1d39ec..74364ee4ddb 100644
--- a/app/assets/stylesheets/highlight/embedded.scss
+++ b/app/assets/stylesheets/highlight/embedded.scss
@@ -1,3 +1,3 @@
.code {
- @import "white_base";
+ @import 'white_base';
}
diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss
index 604f806dc58..16893dd047e 100644
--- a/app/assets/stylesheets/highlight/dark.scss
+++ b/app/assets/stylesheets/highlight/themes/dark.scss
@@ -1,5 +1,7 @@
/* https://github.com/MozMorris/tomorrow-pygments */
+@import "../common";
+
/*
* Dark syntax colors
*/
@@ -125,7 +127,7 @@ $dark-il: #de935f;
.diff-line-num.new,
.line_content.new {
- @include diff_background($dark-new-bg, $dark-new-idiff, $dark-border);
+ @include diff-background($dark-new-bg, $dark-new-idiff, $dark-border);
&::before,
a {
@@ -135,7 +137,7 @@ $dark-il: #de935f;
.diff-line-num.old,
.line_content.old {
- @include diff_background($dark-old-bg, $dark-old-idiff, $dark-border);
+ @include diff-background($dark-old-bg, $dark-old-idiff, $dark-border);
&::before,
a {
diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss
index 8e2720511da..37fe61b925c 100644
--- a/app/assets/stylesheets/highlight/monokai.scss
+++ b/app/assets/stylesheets/highlight/themes/monokai.scss
@@ -1,5 +1,7 @@
/* https://github.com/richleland/pygments-css/blob/master/monokai.css */
+@import "../common";
+
/*
* Monokai Colors
*/
@@ -125,7 +127,7 @@ $monokai-gi: #a6e22e;
.diff-line-num.new,
.line_content.new {
- @include diff_background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
+ @include diff-background($monokai-new-bg, $monokai-new-idiff, $monokai-diff-border);
&::before,
a {
@@ -135,7 +137,7 @@ $monokai-gi: #a6e22e;
.diff-line-num.old,
.line_content.old {
- @include diff_background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
+ @include diff-background($monokai-old-bg, $monokai-old-idiff, $monokai-diff-border);
&::before,
a {
diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss
new file mode 100644
index 00000000000..b4217aac37a
--- /dev/null
+++ b/app/assets/stylesheets/highlight/themes/none.scss
@@ -0,0 +1,238 @@
+/*
+* None Syntax Colors
+*/
+
+@import "../common";
+
+@mixin match-line {
+ color: $black-transparent;
+ background-color: $white-normal;
+}
+
+.code.none {
+ // Line numbers
+ .line-numbers,
+ .diff-line-num {
+ background-color: $gray-light;
+ }
+
+ .diff-line-num,
+ .diff-line-num a {
+ color: $black-transparent;
+ }
+
+ // Code itself
+ pre.code,
+ .diff-line-num {
+ border-color: $white-normal;
+ }
+
+ &,
+ pre.code,
+ .line_holder .line_content {
+ background-color: $white-light;
+ color: $gl-text-color;
+ }
+
+// Diff line
+
+ $none-over-bg: #ded7fc;
+ $none-expanded-border: #e0e0e0;
+ $none-expanded-bg: #e0e0e0;
+
+ .line_holder {
+
+ &.match .line_content,
+ .new-nonewline.line_content,
+ .old-nonewline.line_content {
+ @include match-line;
+ }
+
+ .diff-line-num {
+ &.old {
+ a {
+ color: scale-color($line-number-old, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.new {
+ a {
+ color: scale-color($line-number-new, $red: -30%, $green: -30%, $blue: -30%);
+ }
+ }
+
+ &.is-over,
+ &.hll:not(.empty-cell).is-over {
+ background-color: $none-over-bg;
+ border-color: darken($none-over-bg, 5%);
+
+ a {
+ color: darken($none-over-bg, 15%);
+ }
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $white-light;
+ border-color: $white-normal;
+ }
+ }
+
+ &:not(.diff-expanded) + .diff-expanded,
+ &.diff-expanded + .line_holder:not(.diff-expanded) {
+ > .diff-line-num,
+ > .line_content {
+ border-top: 1px solid $none-expanded-border;
+ }
+ }
+
+ &.diff-expanded {
+ > .diff-line-num,
+ > .line_content {
+ background: $none-expanded-bg;
+ border-color: $none-expanded-bg;
+ }
+ }
+
+ .line_content {
+ &.old {
+ background-color: $white-normal;
+
+ &::before {
+ color: $gl-text-color;
+ }
+
+ span.idiff {
+ background-color: $white-normal;
+ text-decoration: underline;
+ }
+ }
+
+ &.new {
+ background-color: $white-normal;
+
+ &::before {
+ color: $gl-text-color;
+ }
+
+ span.idiff {
+ background-color: $white-normal;
+ text-decoration: underline;
+ }
+ }
+
+ &.match {
+ @include match-line;
+ }
+
+ &.hll:not(.empty-cell) {
+ background-color: $white-normal;
+ }
+ }
+ }
+
+ // highlight line via anchor
+ pre .hll {
+ background-color: $white-normal;
+ }
+
+ // Search result highlight
+ span.highlight_word {
+ background-color: $white-normal;
+ }
+
+ // Links to URLs, emails, or dependencies
+ .line a {
+ color: $gl-text-color;
+ text-decoration: underline;
+ }
+
+ .hll { background-color: $white-light; }
+
+ .gd {
+ color: $gl-text-color;
+ background-color: $white-light;
+
+ .x {
+ color: $gl-text-color;
+ background-color: $white-light;
+ }
+ }
+
+ .gi {
+ color: $gl-text-color;
+ background-color: $white-light;
+
+ .x {
+ color: $gl-text-color;
+ background-color: $white-light;
+ }
+ }
+
+ .c { color: $gl-text-color; } /* Comment */
+ .err { color: $gl-text-color; } /* Error */
+ .g { color: $gl-text-color; } /* Generic */
+ .k { color: $gl-text-color; } /* Keyword */
+ .l { color: $gl-text-color; } /* Literal */
+ .n { color: $gl-text-color; } /* Name */
+ .o { color: $gl-text-color; } /* Operator */
+ .x { color: $gl-text-color; } /* Other */
+ .p { color: $gl-text-color; } /* Punctuation */
+ .cm { color: $gl-text-color; } /* Comment.Multiline */
+ .cp { color: $gl-text-color; } /* Comment.Preproc */
+ .c1 { color: $gl-text-color; } /* Comment.Single */
+ .cs { color: $gl-text-color; } /* Comment.Special */
+ .ge { color: $gl-text-color; } /* Generic.Emph */
+ .gr { color: $gl-text-color; } /* Generic.Error */
+ .gh { color: $gl-text-color; } /* Generic.Heading */
+ .go { color: $gl-text-color; } /* Generic.Output */
+ .gp { color: $gl-text-color; } /* Generic.Prompt */
+ .gs { color: $gl-text-color; } /* Generic.Strong */
+ .gu { color: $gl-text-color; } /* Generic.Subheading */
+ .gt { color: $gl-text-color; } /* Generic.Traceback */
+ .kc { color: $gl-text-color; } /* Keyword.Constant */
+ .kd { color: $gl-text-color; } /* Keyword.Declaration */
+ .kn { color: $gl-text-color; } /* Keyword.Namespace */
+ .kp { color: $gl-text-color; } /* Keyword.Pseudo */
+ .kr { color: $gl-text-color; } /* Keyword.Reserved */
+ .kt { color: $gl-text-color; } /* Keyword.Type */
+ .ld { color: $gl-text-color; } /* Literal.Date */
+ .m { color: $gl-text-color; } /* Literal.Number */
+ .s { color: $gl-text-color; } /* Literal.String */
+ .na { color: $gl-text-color; } /* Name.Attribute */
+ .nb { color: $gl-text-color; } /* Name.Builtin */
+ .nc { color: $gl-text-color; } /* Name.Class */
+ .no { color: $gl-text-color; } /* Name.Constant */
+ .nd { color: $gl-text-color; } /* Name.Decorator */
+ .ni { color: $gl-text-color; } /* Name.Entity */
+ .ne { color: $gl-text-color; } /* Name.Exception */
+ .nf { color: $gl-text-color; } /* Name.Function */
+ .nl { color: $gl-text-color; } /* Name.Label */
+ .nn { color: $gl-text-color; } /* Name.Namespace */
+ .nx { color: $gl-text-color; } /* Name.Other */
+ .py { color: $gl-text-color; } /* Name.Property */
+ .nt { color: $gl-text-color; } /* Name.Tag */
+ .nv { color: $gl-text-color; } /* Name.Variable */
+ .ow { color: $gl-text-color; } /* Operator.Word */
+ .w { color: $gl-text-color; } /* Text.Whitespace */
+ .mf { color: $gl-text-color; } /* Literal.Number.Float */
+ .mh { color: $gl-text-color; } /* Literal.Number.Hex */
+ .mi { color: $gl-text-color; } /* Literal.Number.Integer */
+ .mo { color: $gl-text-color; } /* Literal.Number.Oct */
+ .sb { color: $gl-text-color; } /* Literal.String.Backtick */
+ .sc { color: $gl-text-color; } /* Literal.String.Char */
+ .sd { color: $gl-text-color; } /* Literal.String.Doc */
+ .s2 { color: $gl-text-color; } /* Literal.String.Double */
+ .se { color: $gl-text-color; } /* Literal.String.Escape */
+ .sh { color: $gl-text-color; } /* Literal.String.Heredoc */
+ .si { color: $gl-text-color; } /* Literal.String.Interpol */
+ .sx { color: $gl-text-color; } /* Literal.String.Other */
+ .sr { color: $gl-text-color; } /* Literal.String.Regex */
+ .s1 { color: $gl-text-color; } /* Literal.String.Single */
+ .ss { color: $gl-text-color; } /* Literal.String.Symbol */
+ .bp { color: $gl-text-color; } /* Name.Builtin.Pseudo */
+ .vc { color: $gl-text-color; } /* Name.Variable.Class */
+ .vg { color: $gl-text-color; } /* Name.Variable.Global */
+ .vi { color: $gl-text-color; } /* Name.Variable.Instance */
+ .il { color: $gl-text-color; } /* Literal.Number.Integer.Long */
+
+}
diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
index cd1f0f6650f..a4e9eda22c9 100644
--- a/app/assets/stylesheets/highlight/solarized_dark.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss
@@ -1,5 +1,7 @@
/* https://gist.github.com/qguv/7936275 */
+@import "../common";
+
/*
* Solarized dark colors
*/
@@ -129,7 +131,7 @@ $solarized-dark-il: #2aa198;
.diff-line-num.new,
.line_content.new {
- @include diff_background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
+ @include diff-background($solarized-dark-new-bg, $solarized-dark-new-idiff, $solarized-dark-border);
&::before,
a {
@@ -139,7 +141,7 @@ $solarized-dark-il: #2aa198;
.diff-line-num.old,
.line_content.old {
- @include diff_background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
+ @include diff-background($solarized-dark-old-bg, $solarized-dark-old-idiff, $solarized-dark-border);
&::before,
a {
diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss
index 09c3ea36414..b604d1ccb6c 100644
--- a/app/assets/stylesheets/highlight/solarized_light.scss
+++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss
@@ -1,5 +1,7 @@
/* https://gist.github.com/qguv/7936275 */
+@import "../common";
+
/*
* Solarized light syntax colors
*/
@@ -90,7 +92,7 @@ $solarized-light-vg: #268bd2;
$solarized-light-vi: #268bd2;
$solarized-light-il: #2aa198;
-@mixin matchLine {
+@mixin match-line {
color: $black-transparent;
background: $solarized-light-matchline-bg;
}
@@ -125,7 +127,7 @@ $solarized-light-il: #2aa198;
&.match .line_content,
&.old-nonewline .line_content,
&.new-nonewline .line_content {
- @include matchLine;
+ @include match-line;
}
td.diff-line-num.hll:not(.empty-cell),
@@ -136,7 +138,7 @@ $solarized-light-il: #2aa198;
.diff-line-num.new,
.line_content.new {
- @include diff_background($solarized-light-new-bg,
+ @include diff-background($solarized-light-new-bg,
$solarized-light-new-idiff, $solarized-light-border);
&::before,
@@ -147,7 +149,7 @@ $solarized-light-il: #2aa198;
.diff-line-num.old,
.line_content.old {
- @include diff_background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
+ @include diff-background($solarized-light-old-bg, $solarized-light-old-idiff, $solarized-light-border);
&::before,
a {
@@ -168,7 +170,7 @@ $solarized-light-il: #2aa198;
}
.line_content.match {
- @include matchLine;
+ @include match-line;
}
&:not(.diff-expanded) + .diff-expanded,
diff --git a/app/assets/stylesheets/highlight/themes/white.scss b/app/assets/stylesheets/highlight/themes/white.scss
new file mode 100644
index 00000000000..7239086f649
--- /dev/null
+++ b/app/assets/stylesheets/highlight/themes/white.scss
@@ -0,0 +1,3 @@
+.code.white {
+ @import "../white_base";
+}
diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss
deleted file mode 100644
index 355c8d223f7..00000000000
--- a/app/assets/stylesheets/highlight/white.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-.code.white {
- @import "white_base";
-}
diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss
index 90a5250c247..ee0ec94c636 100644
--- a/app/assets/stylesheets/highlight/white_base.scss
+++ b/app/assets/stylesheets/highlight/white_base.scss
@@ -1,5 +1,7 @@
/* https://github.com/aahan/pygments-github-style */
+@import './common';
+
/*
* White Syntax Colors
*/
@@ -35,16 +37,16 @@ $white-kt: #458;
$white-m: #099;
$white-s: #d14;
$white-n: #333;
-$white-na: teal;
+$white-na: #008080;
$white-nb: #0086b3;
$white-nc: #458;
-$white-no: teal;
-$white-ni: purple;
+$white-no: #008080;
+$white-ni: #800080;
$white-ne: #900;
$white-nf: #900;
$white-nn: #555;
-$white-nt: navy;
-$white-nv: teal;
+$white-nt: #000080;
+$white-nv: #008080;
$white-w: #bbb;
$white-mf: #099;
$white-mh: #099;
@@ -62,20 +64,20 @@ $white-sr: #009926;
$white-s1: #d14;
$white-ss: #990073;
$white-bp: #999;
-$white-vc: teal;
-$white-vg: teal;
-$white-vi: teal;
+$white-vc: #008080;
+$white-vg: #008080;
+$white-vi: #008080;
$white-il: #099;
$white-gc-color: #999;
$white-gc-bg: #eaf2f5;
-@mixin matchLine {
+@mixin match-line {
color: $black-transparent;
background-color: $gray-light;
}
- // Line numbers
+// Line numbers
.line-numbers,
.diff-line-num {
background-color: $gray-light;
@@ -101,11 +103,10 @@ pre.code,
// Diff line
.line_holder {
-
&.match .line_content,
.new-nonewline.line_content,
.old-nonewline.line_content {
- @include matchLine;
+ @include match-line;
}
.diff-line-num {
@@ -185,7 +186,7 @@ pre.code,
}
&.match {
- @include matchLine;
+ @include match-line;
}
&.hll:not(.empty-cell) {
@@ -199,25 +200,38 @@ pre .hll {
background-color: $white-pre-hll-bg !important;
}
- // Search result highlight
+// Search result highlight
span.highlight_word {
background-color: $white-highlight !important;
}
- // Links to URLs, emails, or dependencies
+// Links to URLs, emails, or dependencies
.line a {
color: $white-nb;
}
.hll { background-color: $white-hll-bg; }
-.c { color: $white-c; font-style: italic; }
-.err { color: $white-err; background-color: $white-err-bg; }
+
+.c { color: $white-c;
+ font-style: italic; }
+
+.err { color: $white-err;
+ background-color: $white-err-bg; }
.k { font-weight: $gl-font-weight-bold; }
.o { font-weight: $gl-font-weight-bold; }
-.cm { color: $white-cm; font-style: italic; }
-.cp { color: $white-cp; font-weight: $gl-font-weight-bold; }
-.c1 { color: $white-c1; font-style: italic; }
-.cs { color: $white-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+.cm { color: $white-cm;
+ font-style: italic; }
+
+.cp { color: $white-cp;
+ font-weight: $gl-font-weight-bold; }
+
+.c1 { color: $white-c1;
+ font-style: italic; }
+
+.cs { color: $white-cs;
+ font-weight: $gl-font-weight-bold;
+ font-style: italic; }
.gd {
color: $white-gd;
@@ -246,24 +260,34 @@ span.highlight_word {
.go { color: $white-go; }
.gp { color: $white-gp; }
.gs { font-weight: $gl-font-weight-bold; }
-.gu { color: $white-gu; font-weight: $gl-font-weight-bold; }
+
+.gu { color: $white-gu;
+ font-weight: $gl-font-weight-bold; }
.gt { color: $white-gt; }
.kc { font-weight: $gl-font-weight-bold; }
.kd { font-weight: $gl-font-weight-bold; }
.kn { font-weight: $gl-font-weight-bold; }
.kp { font-weight: $gl-font-weight-bold; }
.kr { font-weight: $gl-font-weight-bold; }
-.kt { color: $white-kt; font-weight: $gl-font-weight-bold; }
+
+.kt { color: $white-kt;
+ font-weight: $gl-font-weight-bold; }
.m { color: $white-m; }
.s { color: $white-s; }
.n { color: $white-n; }
.na { color: $white-na; }
.nb { color: $white-nb; }
-.nc { color: $white-nc; font-weight: $gl-font-weight-bold; }
+
+.nc { color: $white-nc;
+ font-weight: $gl-font-weight-bold; }
.no { color: $white-no; }
.ni { color: $white-ni; }
-.ne { color: $white-ne; font-weight: $gl-font-weight-bold; }
-.nf { color: $white-nf; font-weight: $gl-font-weight-bold; }
+
+.ne { color: $white-ne;
+ font-weight: $gl-font-weight-bold; }
+
+.nf { color: $white-nf;
+ font-weight: $gl-font-weight-bold; }
.nn { color: $white-nn; }
.nt { color: $white-nt; }
.nv { color: $white-nv; }
@@ -289,4 +313,6 @@ span.highlight_word {
.vg { color: $white-vg; }
.vi { color: $white-vi; }
.il { color: $white-il; }
-.gc { color: $white-gc-color; background-color: $white-gc-bg; }
+
+.gc { color: $white-gc-color;
+ background-color: $white-gc-bg; }
diff --git a/app/assets/stylesheets/mailers/highlighted_diff_email.scss b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
index 8b234a5a656..33c114838c2 100644
--- a/app/assets/stylesheets/mailers/highlighted_diff_email.scss
+++ b/app/assets/stylesheets/mailers/highlighted_diff_email.scss
@@ -1,4 +1,4 @@
-@import "framework/variables";
+@import 'framework/variables';
// This file is largely copied from `highlight/white.scss`, but modified to
// avoid all descendant selectors (`table td`). This is because the CSS inlining
@@ -40,16 +40,16 @@ $highlighted-kt: #458;
$highlighted-m: #099;
$highlighted-s: #d14;
$highlighted-n: #333;
-$highlighted-na: teal;
+$highlighted-na: #008080;
$highlighted-nb: #0086b3;
$highlighted-nc: #458;
-$highlighted-no: teal;
-$highlighted-ni: purple;
+$highlighted-no: #008080;
+$highlighted-ni: #800080;
$highlighted-ne: #900;
$highlighted-nf: #900;
$highlighted-nn: #555;
-$highlighted-nt: navy;
-$highlighted-nv: teal;
+$highlighted-nt: #000080;
+$highlighted-nv: #008080;
$highlighted-w: #bbb;
$highlighted-mf: #099;
$highlighted-mh: #099;
@@ -67,9 +67,9 @@ $highlighted-sr: #009926;
$highlighted-s1: #d14;
$highlighted-ss: #990073;
$highlighted-bp: #999;
-$highlighted-vc: teal;
-$highlighted-vg: teal;
-$highlighted-vi: teal;
+$highlighted-vc: #008080;
+$highlighted-vg: #008080;
+$highlighted-vi: #008080;
$highlighted-il: #099;
$highlighted-gc: #999;
$highlighted-gc-bg: #eaf2f5;
@@ -151,14 +151,27 @@ span.highlight_word {
}
.hll { background-color: $highlighted-hll-bg; }
-.c { color: $highlighted-c; font-style: italic; }
-.err { color: $highlighted-err; background-color: $highlighted-err-bg; }
+
+.c { color: $highlighted-c;
+ font-style: italic; }
+
+.err { color: $highlighted-err;
+ background-color: $highlighted-err-bg; }
.k { font-weight: $gl-font-weight-bold; }
.o { font-weight: $gl-font-weight-bold; }
-.cm { color: $highlighted-cm; font-style: italic; }
-.cp { color: $highlighted-cp; font-weight: $gl-font-weight-bold; }
-.c1 { color: $highlighted-c1; font-style: italic; }
-.cs { color: $highlighted-cs; font-weight: $gl-font-weight-bold; font-style: italic; }
+
+.cm { color: $highlighted-cm;
+ font-style: italic; }
+
+.cp { color: $highlighted-cp;
+ font-weight: $gl-font-weight-bold; }
+
+.c1 { color: $highlighted-c1;
+ font-style: italic; }
+
+.cs { color: $highlighted-cs;
+ font-weight: $gl-font-weight-bold;
+ font-style: italic; }
.gd {
color: $highlighted-gd;
@@ -187,24 +200,34 @@ span.highlight_word {
.go { color: $highlighted-go; }
.gp { color: $highlighted-gp; }
.gs { font-weight: $gl-font-weight-bold; }
-.gu { color: $highlighted-gu; font-weight: $gl-font-weight-bold; }
+
+.gu { color: $highlighted-gu;
+ font-weight: $gl-font-weight-bold; }
.gt { color: $highlighted-gt; }
.kc { font-weight: $gl-font-weight-bold; }
.kd { font-weight: $gl-font-weight-bold; }
.kn { font-weight: $gl-font-weight-bold; }
.kp { font-weight: $gl-font-weight-bold; }
.kr { font-weight: $gl-font-weight-bold; }
-.kt { color: $highlighted-kt; font-weight: $gl-font-weight-bold; }
+
+.kt { color: $highlighted-kt;
+ font-weight: $gl-font-weight-bold; }
.m { color: $highlighted-m; }
.s { color: $highlighted-s; }
.n { color: $highlighted-n; }
.na { color: $highlighted-na; }
.nb { color: $highlighted-nb; }
-.nc { color: $highlighted-nc; font-weight: $gl-font-weight-bold; }
+
+.nc { color: $highlighted-nc;
+ font-weight: $gl-font-weight-bold; }
.no { color: $highlighted-no; }
.ni { color: $highlighted-ni; }
-.ne { color: $highlighted-ne; font-weight: $gl-font-weight-bold; }
-.nf { color: $highlighted-nf; font-weight: $gl-font-weight-bold; }
+
+.ne { color: $highlighted-ne;
+ font-weight: $gl-font-weight-bold; }
+
+.nf { color: $highlighted-nf;
+ font-weight: $gl-font-weight-bold; }
.nn { color: $highlighted-nn; }
.nt { color: $highlighted-nt; }
.nv { color: $highlighted-nv; }
@@ -230,4 +253,6 @@ span.highlight_word {
.vg { color: $highlighted-vg; }
.vi { color: $highlighted-vi; }
.il { color: $highlighted-il; }
-.gc { color: $highlighted-gc; background-color: $highlighted-gc-bg; }
+
+.gc { color: $highlighted-gc;
+ background-color: $highlighted-gc-bg; }
diff --git a/app/assets/stylesheets/notify.scss b/app/assets/stylesheets/notify.scss
index f24c80bd81c..d77b7dfad68 100644
--- a/app/assets/stylesheets/notify.scss
+++ b/app/assets/stylesheets/notify.scss
@@ -1,4 +1,4 @@
-@import "framework/variables";
+@import 'framework/variables';
img {
max-width: 100%;
diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss
index 98d0a2d43ea..0c1067bfacc 100644
--- a/app/assets/stylesheets/page_bundles/ide.scss
+++ b/app/assets/stylesheets/page_bundles/ide.scss
@@ -130,7 +130,7 @@ $ide-commit-header-height: 48px;
background: none;
border: 0;
border-radius: $border-radius-default;
- color: $theme-gray-900;
+ color: $gray-900;
svg {
position: relative;
@@ -145,7 +145,7 @@ $ide-commit-header-height: 48px;
}
&:not([disabled]):hover {
- background-color: $theme-gray-200;
+ background-color: $gray-200;
}
&:not([disabled]):focus {
@@ -179,6 +179,14 @@ $ide-commit-header-height: 48px;
display: none;
}
+ .monaco-editor .selected-text {
+ z-index: 1;
+ }
+
+ .monaco-editor .view-lines {
+ z-index: 2;
+ }
+
.is-readonly,
.editor.original {
.view-lines {
@@ -265,7 +273,7 @@ $ide-commit-header-height: 48px;
.margin {
background-color: $white-light;
- border-right: 1px solid $theme-gray-100;
+ border-right: 1px solid $gray-100;
.line-insert {
border-right: 1px solid $line-added-dark;
@@ -292,7 +300,7 @@ $ide-commit-header-height: 48px;
.monaco-editor,
.monaco-editor-background,
.monaco-editor .inputarea.ime-input {
- background-color: $theme-gray-50;
+ background-color: $gray-50;
}
}
}
@@ -395,6 +403,11 @@ $ide-commit-header-height: 48px;
svg {
vertical-align: sub;
}
+
+ .ide-status-avatar {
+ float: none;
+ margin: 0 0 1px;
+ }
}
.ide-status-file {
@@ -527,7 +540,7 @@ $ide-commit-header-height: 48px;
display: block;
margin-left: auto;
margin-right: auto;
- color: $theme-gray-700;
+ color: $gray-700;
}
.file-status-icon {
@@ -551,7 +564,7 @@ $ide-commit-header-height: 48px;
&:hover,
&:focus {
- background: $theme-gray-100;
+ background: $gray-100;
outline: 0;
@@ -563,7 +576,7 @@ $ide-commit-header-height: 48px;
}
&:active {
- background: $theme-gray-200;
+ background: $gray-200;
}
&.is-active {
@@ -677,25 +690,6 @@ $ide-commit-header-height: 48px;
flex: 1;
}
-.drag-handle {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 4px;
-
- &:hover {
- background-color: $white-normal;
- }
-
- &.drag-right {
- right: 0;
- }
-
- &.drag-left {
- left: 0;
- }
-}
-
.ide-commit-list-container {
display: flex;
flex: 1;
@@ -767,12 +761,12 @@ $ide-commit-header-height: 48px;
&:hover {
color: $gl-text-color;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
}
&:focus {
color: $gl-text-color;
- background-color: $theme-gray-200;
+ background-color: $gray-200;
}
&.active {
@@ -811,26 +805,6 @@ $ide-commit-header-height: 48px;
z-index: 1;
}
-.ide-file-finder-overlay {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
- z-index: 100;
-}
-
-.ide-file-finder {
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
-
- .highlighted {
- color: $blue-500;
- font-weight: $gl-font-weight-bold;
- }
-}
-
.ide-commit-message-field {
height: 200px;
background-color: $white-light;
@@ -1273,10 +1247,10 @@ $ide-commit-header-height: 48px;
.ide-entry-dropdown-toggle {
padding: $gl-padding-4;
color: $gl-text-color;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
&:hover {
- background-color: $theme-gray-200;
+ background-color: $gray-200;
}
&:active,
@@ -1331,7 +1305,7 @@ $ide-commit-header-height: 48px;
&:focus {
outline: 0;
box-shadow: none;
- border-color: $theme-gray-200;
+ border-color: $gray-200;
}
}
@@ -1358,7 +1332,7 @@ $ide-commit-header-height: 48px;
.ide-commit-editor-header {
height: 65px;
padding: 8px 16px;
- background-color: $theme-gray-50;
+ background-color: $gray-50;
box-shadow: inset 0 -1px $white-dark;
}
@@ -1370,7 +1344,7 @@ $ide-commit-header-height: 48px;
.ide-file-icon-holder {
display: flex;
align-items: center;
- color: $theme-gray-700;
+ color: $gray-700;
}
.file-row:hover,
diff --git a/app/assets/stylesheets/page_bundles/xterm.scss b/app/assets/stylesheets/page_bundles/xterm.scss
index 7f040ac9b96..de3f2a1177d 100644
--- a/app/assets/stylesheets/page_bundles/xterm.scss
+++ b/app/assets/stylesheets/page_bundles/xterm.scss
@@ -6,11 +6,11 @@
$black: #000;
$red: #ea1010;
- $green: #009900;
- $yellow: #999900;
+ $green: #090;
+ $yellow: #990;
$blue: #0073e6;
$magenta: #d411d4;
- $cyan: #009999;
+ $cyan: #099;
$white: #ccc;
$l-black: #373b41;
$l-red: #ff6161;
diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss
index 37984a8666f..09ff518bbdf 100644
--- a/app/assets/stylesheets/pages/boards.scss
+++ b/app/assets/stylesheets/pages/boards.scss
@@ -1,9 +1,4 @@
-[v-cloak] {
- display: none;
-}
-
.user-can-drag {
- cursor: -webkit-grab;
cursor: grab;
}
@@ -17,22 +12,24 @@
-ms-user-select: none;
user-select: none;
// !important to make sure no style can override this when dragging
- cursor: -webkit-grabbing !important;
cursor: grabbing !important;
}
}
.is-ghost {
opacity: 0.3;
+ pointer-events: none;
+}
+
+.dropdown-projects {
+ .dropdown-content {
+ max-height: 200px;
+ }
}
.dropdown-menu-issues-board-new {
width: 320px;
- .open & {
- max-height: 400px;
- }
-
.dropdown-content {
max-height: 162px;
}
@@ -42,11 +39,6 @@
margin: 0 8px 10px;
padding-bottom: 10px;
border-bottom: 1px solid $dropdown-divider-bg;
-
- > p {
- margin: 0;
- font-size: 14px;
- }
}
.issue-boards-page {
@@ -56,8 +48,6 @@
}
.boards-app {
- position: relative;
-
@include media-breakpoint-up(sm) {
transition: width $sidebar-transition-duration;
width: 100%;
@@ -68,17 +58,9 @@
}
}
-.boards-app-loading {
- width: 100%;
- font-size: 34px;
-}
-
.boards-list {
height: calc(100vh - #{$issue-board-list-difference-xs});
- width: 100%;
- padding: $gl-padding ($gl-padding / 2);
overflow-x: scroll;
- white-space: nowrap;
min-height: 200px;
@include media-breakpoint-only(sm) {
@@ -103,13 +85,7 @@
}
.board {
- display: inline-block;
width: calc(85vw - 15px);
- height: 100%;
- padding-right: ($gl-padding / 2);
- padding-left: ($gl-padding / 2);
- white-space: normal;
- vertical-align: top;
@include media-breakpoint-up(sm) {
width: 400px;
@@ -124,23 +100,7 @@
&.is-collapsed {
width: 50px;
- .board-header {
- position: absolute;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
-
- button {
- display: none;
- }
- }
-
.board-title {
- padding: 0;
- border-bottom: 0;
- justify-content: center;
-
> span {
width: 100%;
margin-top: -12px;
@@ -156,26 +116,16 @@
left: 50%;
margin-left: -10px;
}
-
- .board-list-component,
- .issue-count-badge {
- display: none;
- }
}
}
.board-inner {
- position: relative;
- height: 100%;
font-size: $issue-boards-font-size;
background: $gray-light;
border: 1px solid $border-color;
- border-radius: $border-radius-default;
}
.board-header {
- position: relative;
-
&.has-border::before {
border-top: 3px solid;
border-color: inherit;
@@ -200,30 +150,19 @@
}
}
-.board-inner-container {
- border-bottom: 1px solid $border-color;
- padding: $gl-padding;
-}
-
.board-title {
- margin: 0;
- padding: $gl-padding-8 $gl-padding;
font-size: 1em;
border-bottom: 1px solid $border-color;
- display: flex;
- align-items: center;
}
.board-title-text {
- margin-right: auto;
+ margin: $gl-vert-padding auto $gl-vert-padding 0;
}
.board-delete {
margin-right: 10px;
- padding: 0;
color: $gray-darkest;
background-color: transparent;
- border: 0;
outline: 0;
&:hover {
@@ -231,42 +170,32 @@
}
}
-.board-blank-state {
- height: calc(100% - 49px);
- padding: $gl-padding;
+.board-blank-state,
+.board-promotion-state {
background-color: $white-light;
+ flex: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
}
.board-blank-state-list {
- list-style: none;
-
> li:not(:last-child) {
margin-bottom: 8px;
}
.label-color {
- position: relative;
top: 2px;
- display: inline-block;
width: 16px;
height: 16px;
margin-right: 3px;
- border-radius: $border-radius-default;
}
}
.board-list-component {
- height: calc(100% - 49px);
- overflow: hidden;
- position: relative;
+ min-height: 0; // firefox fix
}
.board-list {
- height: 100%;
- width: 100%;
- margin-bottom: 0;
- padding: $gl-padding-4;
- list-style: none;
overflow-y: auto;
overflow-x: hidden;
}
@@ -277,14 +206,11 @@
}
.board-card {
- position: relative;
- padding: $gl-padding;
background: $white-light;
- border-radius: $border-radius-default;
- border: 1px solid $theme-gray-200;
+ border: 1px solid $gray-200;
box-shadow: 0 1px 2px $issue-boards-card-shadow;
- list-style: none;
line-height: $gl-padding;
+ list-style: none;
&:not(:last-child) {
margin-bottom: $gl-padding-8;
@@ -311,10 +237,6 @@
}
}
- svg {
- vertical-align: top;
- }
-
.confidential-icon {
color: $orange-600;
cursor: help;
@@ -339,11 +261,10 @@
}
.board-card-header {
- display: flex;
+ text-align: initial;
}
.board-card-assignee {
- display: flex;
margin-top: -$gl-padding-4;
margin-bottom: -$gl-padding-4;
@@ -403,34 +324,16 @@
.board-card-number {
font-size: $gl-font-size-xs;
color: $gl-text-color-secondary;
- overflow: hidden;
@include media-breakpoint-up(md) {
font-size: $label-font-size;
}
}
-.board-card-number-container {
- overflow: hidden;
-}
-
-.issue-boards-search {
- width: 395px;
-
- .form-control {
- display: inline-block;
- width: 210px;
- }
-}
-
.board-list-count {
padding: 10px 0;
color: $gl-text-color-secondary;
font-size: 13px;
-
- > .fa {
- margin-right: 5px;
- }
}
.board-new-issue-form {
@@ -438,16 +341,9 @@
margin: 5px;
}
-.page-with-contextual-sidebar.layout-page .issue-boards-sidebar {
- .issuable-sidebar-header {
- position: relative;
- }
-
+.issue-boards-sidebar {
.gutter-toggle {
- position: absolute;
- top: 0;
bottom: 15px;
- right: 0;
width: 22px;
color: $gray-darkest;
@@ -467,10 +363,6 @@
.issuable-header-text {
@include overflow-break-word();
padding-right: 35px;
-
- > strong {
- font-weight: $gl-font-weight-bold;
- }
}
}
@@ -489,51 +381,25 @@
}
.add-issues-modal {
- display: -webkit-flex;
- display: flex;
- position: fixed;
- top: 0;
- right: 0;
- bottom: 0;
- left: 0;
background-color: rgba($black, 0.3);
z-index: 9999;
}
.add-issues-container {
- display: -webkit-flex;
- display: flex;
- -webkit-flex-direction: column;
- flex-direction: column;
width: 90vw;
height: 85vh;
max-width: 1100px;
min-height: 500px;
- margin: auto;
padding: 25px 15px 0;
background-color: $white-light;
- border-radius: $border-radius-default;
box-shadow: 0 2px 12px rgba($black, 0.5);
.empty-state {
- display: -webkit-flex;
- display: flex;
- -webkit-flex: 1;
- flex: 1;
- margin-top: 0;
-
&.add-issues-empty-state-filter {
- -webkit-flex-direction: column;
flex-direction: column;
- -webkit-justify-content: center;
justify-content: center;
}
- > .row {
- width: 100%;
- margin: auto 0;
- }
-
.svg-content {
margin-top: -40px;
}
@@ -542,27 +408,15 @@
.add-issues-header {
margin: -25px -15px -5px;
- border-top: 0;
border-bottom: 1px solid $border-color;
border-top-right-radius: $border-radius-default;
border-top-left-radius: $border-radius-default;
> h2 {
- margin: 0;
font-size: 18px;
}
}
-.add-issues-search {
- display: -webkit-flex;
- display: flex;
-
- .issues-filters {
- -webkit-flex: 1;
- flex: 1;
- }
-}
-
.add-issues-list-column {
width: 100%;
@@ -576,10 +430,6 @@
}
.add-issues-list {
- display: -webkit-flex;
- display: flex;
- -webkit-flex: 1;
- flex: 1;
padding-top: 3px;
margin-left: -$gl-vert-padding;
margin-right: -$gl-vert-padding;
@@ -596,15 +446,6 @@
}
}
-.add-issues-list-loading {
- -webkit-align-self: center;
- align-self: center;
- width: 100%;
- padding-left: $gl-vert-padding;
- padding-right: $gl-vert-padding;
- font-size: 35px;
-}
-
.add-issues-footer {
margin: auto -15px 0;
padding-left: 15px;
@@ -632,27 +473,6 @@
border-radius: 50%;
}
-.modal-filters {
- display: flex;
-
- > .dropdown {
- display: none;
- margin-right: 10px;
-
- @include media-breakpoint-up(sm) {
- display: block;
- }
- }
-
- .dropdown-menu-toggle {
- width: 100px;
-
- @include media-breakpoint-up(md) {
- width: 140px;
- }
- }
-}
-
.board-card-info {
color: $gl-text-color-secondary;
white-space: nowrap;
@@ -673,7 +493,7 @@
}
.board-card-info-icon {
- color: $theme-gray-600;
+ color: $gray-600;
margin-right: $gl-padding-4;
}
diff --git a/app/assets/stylesheets/pages/branches.scss b/app/assets/stylesheets/pages/branches.scss
index 38fec3f0aa8..ce0622b3d48 100644
--- a/app/assets/stylesheets/pages/branches.scss
+++ b/app/assets/stylesheets/pages/branches.scss
@@ -11,15 +11,24 @@
}
.divergence-graph {
+ $graph-side-width: 80px;
+ $graph-separator-width: 1px;
+
padding: 0 6px;
.graph-side {
position: relative;
- width: 80px;
+ width: $graph-side-width;
height: 22px;
padding: 5px 0 13px;
float: left;
+ &.full {
+ width: $graph-side-width * 2 + $graph-separator-width;
+ display: flex;
+ justify-content: center;
+ }
+
.bar {
position: absolute;
height: 4px;
@@ -57,7 +66,7 @@
.graph-separator {
position: relative;
- width: 1px;
+ width: $graph-separator-width;
height: 18px;
margin: 5px 0 0;
float: left;
diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss
index 57918eafd6f..6fc742871e7 100644
--- a/app/assets/stylesheets/pages/builds.scss
+++ b/app/assets/stylesheets/pages/builds.scss
@@ -46,25 +46,20 @@
}
.build-page {
- .build-trace-container {
- position: relative;
- }
-
-
.build-trace {
@include build-trace();
}
- .archived-sticky {
+ .archived-job {
top: $header-height;
border-radius: 2px 2px 0 0;
color: $orange-600;
background-color: $orange-100;
border: 1px solid $border-gray-normal;
- border-bottom: 0;
padding: 3px 12px;
margin: auto;
align-items: center;
+ z-index: 1;
.with-performance-bar & {
top: $header-height + $performance-bar-height;
@@ -75,7 +70,11 @@
@include build-trace-top-bar(35px);
&.has-archived-block {
- top: $header-height + $performance-bar-height + 28px;
+ top: $header-height + 28px;
+
+ .with-performance-bar & {
+ top: $header-height + $performance-bar-height + 28px;
+ }
}
&.affix {
@@ -101,18 +100,6 @@
top: 0;
}
- .truncated-info {
- .truncated-info-size {
- margin: 0 5px;
- }
-
- .raw-link {
- color: $gl-text-color;
- margin-left: 5px;
- text-decoration: underline;
- }
- }
-
.controllers {
@include build-controllers(15px, center, false, 0, inline, 0);
}
@@ -135,12 +122,7 @@
.build-loader-animation {
@include build-loader-animation;
float: left;
- }
-}
-
-.with-performance-bar .build-page {
- .top-bar.affix {
- top: $header-height + $performance-bar-height;
+ padding-left: $gl-padding-8;
}
}
@@ -229,9 +211,13 @@
}
.trigger-variables-btn-container {
- @extend .d-flex;
justify-content: space-between;
align-items: center;
+
+ .trigger-variables-btn {
+ margin-top: -5px;
+ margin-bottom: -5px;
+ }
}
.trigger-build-variables {
@@ -255,7 +241,7 @@
.trigger-variables-table-cell {
font-size: $gl-font-size-small;
line-height: $gl-line-height;
- border: 1px solid $theme-gray-200;
+ border: 1px solid $gray-200;
padding: $gl-padding-4 6px;
width: 50%;
vertical-align: top;
@@ -266,7 +252,7 @@
}
.retry-link {
- display: none;
+ display: block;
.btn-inverted-secondary {
color: $blue-500;
@@ -275,16 +261,6 @@
color: $white-light;
}
}
-
- @include media-breakpoint-down(sm) {
- display: block;
-
- .btn {
- i {
- margin-left: 5px;
- }
- }
- }
}
.stage-item {
@@ -324,16 +300,12 @@
}
}
- .build-job {
- position: relative;
-
- .icon-arrow-right {
- position: absolute;
- left: 15px;
- top: 20px;
- display: block;
- }
+ .icon-arrow-right {
+ left: 15px;
+ top: 20px;
+ }
+ .build-job {
&.active {
font-weight: $gl-font-weight-bold;
}
@@ -345,10 +317,6 @@
&:hover {
background-color: $gray-darker;
}
-
- .icon-retry {
- margin-left: 3px;
- }
}
}
@@ -386,3 +354,14 @@
right: 0;
margin-top: -17px;
}
+
+@include media-breakpoint-down(sm) {
+ .top-bar {
+ .truncated-info {
+ white-space: nowrap;
+ overflow: hidden;
+ max-width: 220px;
+ text-overflow: ellipsis;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss
index ad12cd101b6..255383d89c8 100644
--- a/app/assets/stylesheets/pages/clusters.scss
+++ b/app/assets/stylesheets/pages/clusters.scss
@@ -58,6 +58,22 @@
}
}
+.cluster-application-banner {
+ height: 45px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.cluster-application-banner-close {
+ align-self: flex-start;
+ font-weight: 500;
+ font-size: 20px;
+ color: $orange-900;
+ opacity: 1;
+ margin: $gl-padding-8 14px 0 0;
+}
+
.cluster-application-description {
flex: 1;
}
diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss
index 11966931a6c..66cd113db84 100644
--- a/app/assets/stylesheets/pages/commits.scss
+++ b/app/assets/stylesheets/pages/commits.scss
@@ -58,14 +58,6 @@
display: inline-block;
vertical-align: middle;
- .stage-cell .stage-container {
- margin: 0 3px 3px 0;
- }
-
- .stage-container:last-child {
- margin-right: 0;
- }
-
.dropdown-menu {
margin-top: 11px;
}
@@ -128,18 +120,9 @@
}
.commit-row-title {
- .notes_count {
- float: right;
- margin-right: 10px;
- }
-
.str-truncated {
max-width: 70%;
}
-
- .commit-row-message {
- color: $gl-text-color;
- }
}
.text-expander {
@@ -185,7 +168,7 @@
flex-grow: 1;
min-width: 0;
- .project_namespace {
+ .project-namespace {
color: $gl-text-color-secondary;
}
}
@@ -208,10 +191,6 @@
}
}
- .ci-status-link {
- display: inline-flex;
- }
-
.ci-status-icon svg {
vertical-align: text-bottom;
}
@@ -239,7 +218,6 @@
}
.label-monospace {
- @extend .monospace;
user-select: text;
color: $gl-text-color;
background-color: $gray-light;
@@ -266,7 +244,7 @@
}
.commit,
-.generic_commit_status {
+.generic-commit-status {
a,
button {
vertical-align: baseline;
@@ -278,37 +256,22 @@
&.autodevops-badge {
color: $white-light;
}
-
- &.autodevops-link {
- color: $blue-600;
- }
}
.commit-row-description {
@extend %commit-description-base;
display: none;
flex: 1;
-
- a {
- color: $gl-text-color;
- }
}
&.inline-commit {
.commit-row-title {
font-size: 13px;
}
-
- .committed_ago {
- @extend .cgray;
- float: right;
- }
}
}
.branch-commit {
- color: $gl-text-color;
-
.commit-icon {
text-align: center;
display: inline-block;
@@ -320,14 +283,15 @@
fill: $gl-text-color-secondary;
}
}
+}
+.commit,
+.generic-commit-status,
+.branch-commit {
+ .autodevops-link,
.commit-sha {
color: $blue-600;
}
-
- .commit-row-message {
- color: $gl-text-color;
- }
}
.gpg-status-box {
@@ -342,11 +306,11 @@
}
&.invalid {
- @include status-color($gray-dark, color("gray"), $gray-darkest);
+ @include status-color($gray-dark, color('gray'), $gray-darkest);
border-color: $gray-darkest;
&:not(span):hover {
- color: color("gray");
+ color: color('gray');
}
}
}
diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss
index ec2108b15be..2b932d164a5 100644
--- a/app/assets/stylesheets/pages/cycle_analytics.scss
+++ b/app/assets/stylesheets/pages/cycle_analytics.scss
@@ -330,7 +330,6 @@
// Custom Styles for stage items
.item-build-component {
-
.item-title {
.icon-build-status {
float: left;
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/pages/detail_page.scss
index 37ed5ae674a..c386493231c 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/pages/detail_page.scss
@@ -21,7 +21,6 @@
.detail-page-header-body {
position: relative;
- line-height: 35px;
display: flex;
flex: 1 1;
min-width: 0;
@@ -34,7 +33,6 @@
.detail-page-header-actions {
align-self: center;
- flex-shrink: 0;
flex: 0 0 auto;
@include media-breakpoint-down(xs) {
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 18c62cb4f1e..b3a634e23a3 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -7,20 +7,15 @@
cursor: pointer;
@media (min-width: map-get($grid-breakpoints, md)) {
+ // The `-1` below is to prevent two borders from clashing up against eachother -
+ // the bottom of the compare-versions header and the top of the file header
+ $mr-file-header-top: $mr-version-controls-height + $header-height + $mr-tabs-height - 1;
+
position: -webkit-sticky;
position: sticky;
- top: 92px;
- margin-left: -1px;
- border-left: 1px solid $border-color;
+ top: $mr-file-header-top;
z-index: 102;
-
- &.is-commit {
- top: $header-height + 36px;
-
- .with-performance-bar & {
- top: $header-height + 36px + $performance-bar-height;
- }
- }
+ height: $mr-version-controls-height;
&::before {
content: '';
@@ -34,7 +29,23 @@
}
.with-performance-bar & {
- top: 127px;
+ top: $mr-file-header-top + $performance-bar-height;
+ }
+
+ &.is-commit {
+ top: $header-height + $commit-stat-summary-height;
+
+ .with-performance-bar & {
+ top: $header-height + $commit-stat-summary-height + $performance-bar-height;
+ }
+ }
+
+ &.is-compare {
+ top: $header-height + $compare-branches-sticky-header-height;
+
+ .with-performance-bar & {
+ top: $performance-bar-height + $header-height + $compare-branches-sticky-header-height;
+ }
}
}
@@ -46,6 +57,11 @@
background-color: $gray-normal;
}
+ a,
+ button {
+ color: $gray-700;
+ }
+
svg {
vertical-align: middle;
top: -1px;
@@ -77,138 +93,6 @@
}
}
- .note-text {
- table {
- font-family: $font-family-sans-serif;
- }
- }
-
- table {
- width: 100%;
- font-family: $monospace-font;
- border: 0;
- border-collapse: separate;
- margin: 0;
- padding: 0;
- table-layout: fixed;
- border-radius: 0 0 $border-radius-default $border-radius-default;
-
- .diff-line-num {
- width: 50px;
- position: relative;
-
- a {
- transition: none;
- }
- }
-
- .line_holder td {
- line-height: $code-line-height;
- font-size: $code-font-size;
- vertical-align: top;
-
- &.noteable_line {
- position: relative;
- }
-
- span {
- white-space: pre-wrap;
-
- &.context-cell {
- display: inline-block;
- width: 100%;
- height: 100%;
- }
- }
-
- .line {
- word-wrap: break-word;
- }
- }
-
- &.left-side-selected {
- td.line_content.parallel.right-side {
- user-select: none;
- }
- }
-
- &.right-side-selected {
- td.line_content.parallel.left-side {
- user-select: none;
- }
- }
- }
-
- tr.line_holder.parallel {
- td.line_content.parallel {
- width: 46%;
- }
-
- .add-diff-note {
- margin-left: -55px;
- }
- }
-
- .old_line,
- .new_line {
- user-select: none;
- margin: 0;
- border: 0;
- padding: 0 5px;
- border-right: 1px solid;
- text-align: right;
- min-width: 35px;
- max-width: 50px;
- width: 35px;
-
- a {
- float: left;
- width: 35px;
- font-weight: $gl-font-weight-normal;
-
- &[disabled] {
- cursor: default;
-
- &:hover,
- &:active {
- text-decoration: none;
- }
- }
- }
- }
-
- .line_content {
- display: block;
- margin: 0;
- padding: 0 1.5em;
- border: 0;
- position: relative;
-
- &.parallel {
- display: table-cell;
-
- span {
- word-break: break-all;
- }
- }
-
- &.old {
- &::before {
- content: '-';
- position: absolute;
- left: 0.5em;
- }
- }
-
- &.new {
- &::before {
- content: '+';
- position: absolute;
- left: 0.5em;
- }
- }
- }
-
.diff-loading-error-block {
padding: $gl-padding * 2 $gl-padding;
text-align: center;
@@ -231,22 +115,18 @@
img {
border: 1px solid $white-light;
- background-image: linear-gradient(
- 45deg,
- $border-color 25%,
- transparent 25%,
- transparent 75%,
- $border-color 75%,
- $border-color 100%
- ),
- linear-gradient(
- 45deg,
- $border-color 25%,
- transparent 25%,
- transparent 75%,
- $border-color 75%,
- $border-color 100%
- );
+ background-image: linear-gradient(45deg,
+ $border-color 25%,
+ transparent 25%,
+ transparent 75%,
+ $border-color 75%,
+ $border-color 100%),
+ linear-gradient(45deg,
+ $border-color 25%,
+ transparent 25%,
+ transparent 75%,
+ $border-color 75%,
+ $border-color 100%);
background-size: 10px 10px;
background-position: 0 0, 5px 5px;
max-width: 100%;
@@ -278,11 +158,34 @@
.swipe-wrap {
overflow: hidden;
- border-left: 1px solid $gl-gray-400;
+ border-right: 1px solid $gl-gray-400;
position: absolute;
display: block;
top: 13px;
right: 7px;
+
+ &.left-oriented {
+ /* only for commit view (different swipe viewer) */
+ border-right: 0;
+ border-left: 1px solid $gl-gray-400;
+ }
+ }
+
+ .frame {
+ top: 0;
+ right: 0;
+
+ &.old-diff {
+ /* only for commit / compare view */
+ position: absolute;
+ }
+
+ &.deleted {
+ margin: 0;
+ display: block;
+ top: 13px;
+ right: 7px;
+ }
}
.swipe-bar {
@@ -435,10 +338,6 @@
}
}
- .line_content {
- white-space: pre-wrap;
- }
-
.diff-file-container {
.frame.deleted {
border: 1px solid $deleted;
@@ -500,6 +399,139 @@
}
}
+table.code {
+ width: 100%;
+ font-family: $monospace-font;
+ border: 0;
+ border-collapse: separate;
+ margin: 0;
+ padding: 0;
+ table-layout: fixed;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
+
+ tr.line_holder td {
+ line-height: $code-line-height;
+ font-size: $code-font-size;
+ vertical-align: top;
+
+ span {
+ white-space: pre-wrap;
+
+ &.context-cell {
+ display: inline-block;
+ width: 100%;
+ height: 100%;
+ }
+
+ &.line {
+ word-wrap: break-word;
+ }
+ }
+
+ &.diff-line-num {
+ user-select: none;
+ margin: 0;
+ padding: 0 10px 0 5px;
+ border-right-width: 1px;
+ border-right-style: solid;
+ text-align: right;
+ width: 50px;
+ position: relative;
+
+ a {
+ transition: none;
+ float: left;
+ width: 100%;
+ font-weight: $gl-font-weight-normal;
+
+ &[disabled] {
+ cursor: default;
+
+ &:hover,
+ &:active {
+ text-decoration: none;
+ }
+ }
+ }
+
+ &:not(.js-unfold-bottom) a::before {
+ content: attr(data-linenumber);
+ }
+ }
+
+ &.line_content {
+ display: block;
+ margin: 0;
+ padding: 0 1.5em;
+ border: 0;
+ position: relative;
+ white-space: pre-wrap;
+
+ &.parallel {
+ display: table-cell;
+ width: 46%;
+
+ span {
+ word-break: break-all;
+ }
+ }
+
+ &.old {
+ &::before {
+ content: '-';
+ position: absolute;
+ left: 0.5em;
+ }
+ }
+
+ &.new {
+ &::before {
+ content: '+';
+ position: absolute;
+ left: 0.5em;
+ }
+ }
+ }
+ }
+
+ .line_holder:last-of-type {
+ td:first-child {
+ border-bottom-left-radius: $border-radius-default;
+ }
+ }
+
+ &.left-side-selected {
+ td.line_content.parallel.right-side {
+ user-select: none;
+ }
+ }
+
+ &.right-side-selected {
+ td.line_content.parallel.left-side {
+ user-select: none;
+ }
+ }
+}
+
+.diff-stats {
+ align-items: center;
+ padding: 0 0.25rem;
+
+ .diff-stats-group {
+ padding: 0 0.25rem;
+ }
+
+ svg.diff-stats-icon {
+ vertical-align: text-bottom;
+ }
+
+ &.is-compare-versions-header {
+ .diff-stats-group {
+ padding: 0 0.5rem;
+ }
+ }
+}
+
.file-content .diff-file {
margin: 0;
border: 0;
@@ -575,34 +607,12 @@
}
}
-@mixin diff_background($background, $idiff, $border) {
- background: $background;
-
- &.line_content span.idiff {
- background: $idiff;
- }
-
- &.diff-line-num {
- border-color: $border;
- }
-}
-
.files {
.diff-file:last-child {
margin-bottom: 0;
}
}
-.file-holder {
- .diff-line-num:not(.js-unfold-bottom) {
- a {
- &::before {
- content: attr(data-linenumber);
- }
- }
- }
-}
-
.diff-comment-avatar-holders {
position: absolute;
height: 19px;
@@ -819,34 +829,26 @@
width: 100%;
height: 10px;
background-color: $white-light;
- background-image: linear-gradient(
- 45deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- 225deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- 135deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- ),
- linear-gradient(
- -45deg,
- transparent,
- transparent 73%,
- $diff-jagged-border-gradient-color 75%,
- $white-light 80%
- );
+ background-image: linear-gradient(45deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(225deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(135deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%),
+ linear-gradient(-45deg,
+ transparent,
+ transparent 73%,
+ $diff-jagged-border-gradient-color 75%,
+ $white-light 80%);
background-position: 5px 5px, 0 5px, 0 5px, 5px 5px;
background-size: 10px 10px;
background-repeat: repeat;
@@ -889,7 +891,7 @@
}
}
-.files:not([data-can-create-note="true"]) .frame {
+.files:not([data-can-create-note='true']) .frame {
cursor: auto;
}
@@ -898,15 +900,14 @@
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg')
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
- cursor: -webkit-image-set(
- image-url('illustrations/image_comment_light_cursor.svg') 1x,
- image-url('illustrations/image_comment_light_cursor@2x.svg') 2x
- )
- $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
+ // scss-lint:disable DuplicateProperty
+ cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
+ image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
+ $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
@@ -1011,12 +1012,30 @@
}
.diff-tree-list {
- width: 320px;
+ position: -webkit-sticky;
+ position: sticky;
+ $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
+ top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px;
+ max-height: calc(100vh - #{$top-pos});
+ padding-right: $gl-padding;
+ z-index: 202;
+
+ .with-performance-bar & {
+ $performance-bar-top-pos: $performance-bar-height + $top-pos;
+ top: $performance-bar-top-pos;
+ max-height: calc(100vh - #{$performance-bar-top-pos});
+ }
+
+ .drag-handle {
+ bottom: 16px;
+ transform: translateX(-6px);
+ }
}
.diff-files-holder {
flex: 1;
min-width: 0;
+ z-index: 201;
}
.compare-versions-container {
@@ -1024,21 +1043,12 @@
}
.tree-list-holder {
- position: -webkit-sticky;
- position: sticky;
- top: 100px;
- max-height: calc(100vh - 100px);
- padding-right: $gl-padding;
+ height: 100%;
.file-row {
margin-left: 0;
margin-right: 0;
}
-
- .with-performance-bar & {
- top: 135px;
- max-height: calc(100vh - 135px);
- }
}
.tree-list-scroll {
@@ -1082,7 +1092,10 @@
flex-direction: column;
.diff-tree-list {
- width: 100%;
+ position: relative;
+ top: 0;
+ // !important is required to override inline styles of resizable sidebar
+ width: 100% !important;
}
.tree-list-holder {
@@ -1092,12 +1105,6 @@
}
}
-.tree-list-view-toggle {
- svg {
- top: 0;
- }
-}
-
.image-diff-overlay,
.image-diff-overlay-add-comment {
top: 0;
diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss
index f46ff360496..655b297295a 100644
--- a/app/assets/stylesheets/pages/editor.scss
+++ b/app/assets/stylesheets/pages/editor.scss
@@ -128,6 +128,10 @@
width: 100%;
}
}
+
+ @media(max-width: map-get($grid-breakpoints, md)-1) {
+ clear: both;
+ }
}
.editor-ref {
@@ -178,9 +182,8 @@
.template-selector-dropdowns-wrap {
display: inline-block;
- margin-left: 8px;
- vertical-align: top;
margin: 5px 0 0 8px;
+ vertical-align: top;
@media(max-width: map-get($grid-breakpoints, md)-1) {
display: block;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 75166ffcada..93dffb5ff09 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -12,34 +12,6 @@
.environments-container {
.ci-table {
- .deployment-column {
- > span {
- word-break: break-all;
- }
-
- .avatar {
- float: none;
- }
- }
-
- .btn-group {
- > .btn:not(.btn-danger) {
- color: $gl-text-color-secondary;
- }
-
- svg path {
- fill: $gl-text-color-secondary;
- }
-
- .dropdown {
- outline: none;
- }
- }
-
- .btn .text-center {
- display: inline;
- }
-
.commit-title {
margin: 0;
}
@@ -49,47 +21,16 @@
color: $gl-text-color-secondary;
}
- .dropdown-menu {
- .fa {
- margin-right: 6px;
- color: $gl-text-color-secondary;
- }
- }
-
.build-link,
.ref-name {
color: $gl-text-color;
}
- .stop-env-link,
- .external-url {
- color: $gl-text-color-secondary;
-
- .stop-env-icon {
- font-size: 14px;
- }
- }
-
- .deployment .build-column {
- .build-link {
- color: $gl-text-color;
- }
-
- .avatar {
- float: none;
- margin-right: 0;
- }
- }
-
.folder-icon {
margin-right: 3px;
color: $gl-text-color-secondary;
display: inline-block;
vertical-align: text-top;
-
- .fa:nth-child(1) {
- margin-right: 3px;
- }
}
.folder-name {
@@ -103,12 +44,6 @@
text-align: center;
}
- .branch-commit {
- .commit-sha {
- margin-right: 0;
- }
- }
-
.no-btn {
border: 0;
background: none;
@@ -168,11 +103,6 @@
opacity: 0.25;
}
-.prometheus-graph-overlay {
- fill: none;
- opacity: 0;
- pointer-events: all;
-}
.rect-text-metric {
fill: $white-light;
@@ -203,284 +133,10 @@
stroke: $gray-darkest;
}
-.prometheus-graphs {
- .environments {
- .dropdown-menu-toggle {
- svg {
- position: absolute;
- right: 5%;
- top: 25%;
- }
- }
-
- .dropdown-menu-toggle,
- .dropdown-menu {
- width: 240px;
- }
- }
-}
-
.environments-actions {
.external-url,
.monitoring-url,
- .terminal-button,
- .stop-env-link {
+ .terminal-button {
width: 38px;
}
}
-
-.prometheus-panel {
- margin-top: 20px;
-}
-
-.prometheus-graph-group {
- display: flex;
- flex-wrap: wrap;
- padding: $gl-padding / 2;
-}
-
-.prometheus-graph {
- flex: 1 0 auto;
- min-width: 450px;
- max-width: 100%;
- padding: $gl-padding / 2;
-
- h5 {
- font-size: 16px;
- }
-
- @include media-breakpoint-down(sm) {
- min-width: 100%;
- }
-}
-
-.prometheus-graph-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: $gl-padding-8;
-
- h5 {
- margin: 0;
- }
-}
-
-.prometheus-graph-cursor {
- position: absolute;
- background: $theme-gray-600;
- width: 1px;
-}
-
-.prometheus-graph-flag {
- display: block;
- min-width: 160px;
- border: 0;
- box-shadow: 0 1px 4px 0 $black-transparent;
-
- h5 {
- padding: 0;
- margin: 0;
- font-size: 14px;
- line-height: 1.2;
- }
-
- .deploy-meta-content {
- border-bottom: 1px solid $white-dark;
-
- svg {
- height: 15px;
- vertical-align: bottom;
- }
- }
-
- &.popover {
- padding: 0;
-
- &.left {
- left: auto;
- right: 0;
- margin-right: 10px;
-
- > .arrow {
- right: -14px;
- border-left-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-left: 4px solid $theme-gray-50;
- }
-
- .arrow-shadow {
- right: -3px;
- box-shadow: 1px 0 9px 0 $black-transparent;
- }
- }
-
- &.right {
- left: 0;
- right: auto;
- margin-left: 10px;
-
- > .arrow {
- left: -7px;
- border-right-color: $border-color;
- }
-
- > .arrow::after {
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-right: 4px solid $theme-gray-50;
- }
-
- .arrow-shadow {
- left: -3px;
- box-shadow: 1px 0 8px 0 $black-transparent;
- }
- }
-
- > .arrow {
- top: 10px;
- margin: 0;
- }
-
- .arrow-shadow {
- content: '';
- position: absolute;
- width: 7px;
- height: 7px;
- background-color: transparent;
- transform: rotate(45deg);
- top: 13px;
- }
-
- > .popover-title,
- > .popover-content {
- padding: 8px;
- font-size: 12px;
- white-space: nowrap;
- position: relative;
- }
-
- > .popover-title {
- background-color: $theme-gray-50;
- border-radius: $border-radius-default $border-radius-default 0 0;
- }
- }
-
- strong {
- font-weight: 600;
- }
-}
-
-.prometheus-table {
- border-collapse: collapse;
- padding: 0;
- margin: 0;
-
- td {
- vertical-align: middle;
-
- + td {
- padding-left: 8px;
- vertical-align: top;
- }
- }
-
- .legend-metric-title {
- font-size: 12px;
- vertical-align: middle;
- }
-}
-
-.prometheus-svg-container {
- position: relative;
- height: 0;
- width: 100%;
- padding: 0;
- padding-bottom: 100%;
-
- .text-metric-usage {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 12px;
- }
-
- > svg {
- position: absolute;
- height: 100%;
- width: 100%;
- left: 0;
- top: 0;
-
- text {
- fill: $gl-text-color;
- stroke-width: 0;
- }
-
- .text-metric-bold {
- font-weight: $gl-font-weight-bold;
- }
-
- .label-axis-text {
- fill: $black;
- font-weight: $gl-font-weight-normal;
- font-size: 10px;
- }
-
- .legend-axis-text {
- fill: $black;
- }
-
- .tick {
- > line {
- stroke: $gray-darker;
- }
-
- > text {
- fill: $theme-gray-600;
- font-size: 10px;
- }
- }
-
- .y-label-text,
- .x-label-text {
- fill: $gray-darkest;
- }
-
- .axis-tick {
- stroke: $gray-darker;
- }
-
- .deploy-info-text {
- dominant-baseline: text-before-edge;
- font-size: 12px;
- }
-
- .deploy-info-text-link {
- font-family: $monospace-font;
- fill: $blue-600;
-
- &:hover {
- fill: $blue-800;
- }
- }
-
- @include media-breakpoint-down(sm) {
- .label-axis-text,
- .text-metric-usage,
- .legend-axis-text {
- font-size: 8px;
- }
-
- .tick > text {
- font-size: 8px;
- }
- }
- }
-}
-
-.prometheus-table-row-highlight {
- background-color: $theme-gray-100;
-}
diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss
index 618f23d81b1..e34628002ac 100644
--- a/app/assets/stylesheets/pages/events.scss
+++ b/app/assets/stylesheets/pages/events.scss
@@ -156,6 +156,10 @@
&:hover {
background: none;
}
+
+ a {
+ color: $blue-600;
+ }
}
}
}
diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss
index 4fb1a956fab..3febf4cf826 100644
--- a/app/assets/stylesheets/pages/graph.scss
+++ b/app/assets/stylesheets/pages/graph.scss
@@ -44,7 +44,7 @@
}
.x-axis-text {
- fill: $theme-gray-900;
+ fill: $gray-900;
}
.bar-rect {
@@ -64,19 +64,17 @@
text {
font-weight: bold;
font-size: 12px;
- fill: $theme-gray-800;
+ fill: $gray-800;
}
}
}
.svg-graph-container-with-grab {
cursor: grab;
- cursor: -webkit-grab;
}
.svg-graph-container-grabbed {
cursor: grabbing;
- cursor: -webkit-grabbing;
}
@keyframes flickerAnimation {
@@ -87,5 +85,5 @@
.animate-flicker {
animation: flickerAnimation 1.5s infinite;
- fill: $theme-gray-500;
+ fill: $gray-500;
}
diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss
index f0cb81e0bc3..0a07747e0d4 100644
--- a/app/assets/stylesheets/pages/groups.scss
+++ b/app/assets/stylesheets/pages/groups.scss
@@ -15,6 +15,11 @@
word-wrap: nowrap;
}
+.content-list .group-name {
+ font-weight: $gl-font-weight-bold;
+ color: $pages-group-name-color;
+}
+
.group-row {
@include basic-list-stats;
@@ -29,9 +34,7 @@
}
}
-.group-nav-container .group-search,
.group-nav-container .nav-controls {
- display: flex;
align-items: flex-start;
padding: $gl-padding-top 0 0;
@@ -44,6 +47,52 @@
margin-top: 0;
}
+ @include media-breakpoint-down(sm) {
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ display: block;
+ }
+
+ .group-filter-form,
+ .dropdown {
+ margin-bottom: 10px;
+ margin-right: 0;
+ }
+
+ &,
+ .group-filter-form,
+ .group-filter-form-field,
+ .dropdown,
+ .dropdown .dropdown-toggle,
+ .btn-success {
+ width: 100%;
+ }
+
+ .dropdown .dropdown-toggle .fa-chevron-down {
+ position: absolute;
+ top: 11px;
+ right: 8px;
+ }
+ }
+}
+
+.home-panel-buttons {
+ .home-panel-action-button {
+ vertical-align: top;
+ }
+
+
+ .notification-dropdown {
+ .dropdown-menu {
+ @extend .dropdown-menu-right;
+ }
+
+ .icon {
+ fill: $gl-text-color-secondary;
+ }
+ }
+
.new-project-subgroup {
.dropdown-primary {
min-width: 115px;
@@ -78,7 +127,7 @@
&:hover {
background-color: $gray-darker;
- color: $theme-gray-900;
+ color: $gray-900;
}
}
@@ -99,67 +148,79 @@
font-weight: $gl-font-weight-bold;
}
}
- }
- }
- @include media-breakpoint-down(sm) {
- &,
- .dropdown,
- .dropdown .dropdown-toggle,
- .btn-success {
- display: block;
- }
+ @include media-breakpoint-down(sm) {
+ display: flex;
+ align-items: flex-start;
- .group-filter-form,
- .dropdown {
- margin-bottom: 10px;
- margin-right: 0;
- }
+ .dropdown-primary {
+ flex: 1;
+ }
- .group-filter-form,
- .dropdown .dropdown-toggle,
- .btn-success {
- width: 100%;
- }
+ .dropdown-toggle {
+ width: auto;
+ }
- .dropdown .dropdown-toggle .fa-chevron-down {
- position: absolute;
- top: 11px;
- right: 8px;
+ .dropdown-menu {
+ width: 100%;
+ max-width: inherit;
+ min-width: inherit;
+ }
+ }
}
+ }
+}
- .new-project-subgroup {
- display: flex;
- align-items: flex-start;
-
- .dropdown-primary {
- flex: 1;
- }
+.groups-listing {
+ .group-list-tree .group-row:first-child {
+ border-top: 0;
+ }
+}
- .dropdown-toggle {
- width: auto;
- }
+.card {
+ .shared_runners_limit_under_quota {
+ color: $green-500;
+ }
- .dropdown-menu {
- width: 100%;
- max-width: inherit;
- min-width: inherit;
- }
- }
+ .shared_runners_limit_over_quota {
+ color: $red-500;
}
}
-.group-nav-container .group-search {
- padding: $gl-padding 0;
- border-bottom: 1px solid $border-color;
+.pipeline-quota {
+ border-top: 1px solid $table-border-color;
+ border-bottom: 1px solid $table-border-color;
+ margin: 0 0 $gl-padding;
+
+ .row {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ }
+
+ .right {
+ text-align: right;
+ }
+
+ .progress {
+ height: 6px;
+ width: 100%;
+ margin-bottom: 0;
+ margin-top: 4px;
+ }
}
-.groups-listing {
- .group-list-tree .group-row:first-child {
+.user-settings-pipeline-quota {
+ margin-top: $gl-padding;
+
+ .pipeline-quota {
border-top: 0;
}
}
+table.pipeline-project-metrics tr td {
+ padding: $gl-padding;
+}
+
.mattermost-icon svg {
width: 16px;
height: 16px;
diff --git a/app/assets/stylesheets/pages/help.scss b/app/assets/stylesheets/pages/help.scss
index 2c23f31c240..7610c5cf6f3 100644
--- a/app/assets/stylesheets/pages/help.scss
+++ b/app/assets/stylesheets/pages/help.scss
@@ -30,19 +30,11 @@
.key {
@extend .badge.badge-pill;
background-color: $label-inverse-bg;
- font: 11px Consolas, "Liberation Mono", Menlo, Courier, monospace;
+ font: 11px Consolas, 'Liberation Mono', Menlo, Courier, monospace;
padding: 3px 5px;
}
}
.documentation {
padding: 7px;
-
- // Border around images in the help pages.
- img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- margin: 5px;
- max-height: calc(100vh - 100px);
- }
}
diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss
index a4f76a9495a..74f80a11471 100644
--- a/app/assets/stylesheets/pages/import.scss
+++ b/app/assets/stylesheets/pages/import.scss
@@ -1,10 +1,51 @@
-.import-jobs-from-col,
.import-jobs-to-col {
- width: 40%;
+ width: 39%;
}
.import-jobs-status-col {
- width: 20%;
+ width: 15%;
+}
+
+.import-jobs-cta-col {
+ width: 1%;
+}
+
+.import-project-name-input {
+ border-radius: 0 $border-radius-default $border-radius-default 0;
+ position: relative;
+ left: -1px;
+ max-width: 300px;
+}
+
+.import-namespace-select {
+ > .select2-choice {
+ border-radius: $border-radius-default 0 0 $border-radius-default;
+ position: relative;
+ left: 1px;
+ }
+}
+
+.import-slash-divider {
+ background-color: $gray-lightest;
+ border: 1px solid $border-color;
+}
+
+.import-row {
+ height: 55px;
+}
+
+.import-table {
+ .import-jobs-from-col,
+ .import-jobs-to-col,
+ .import-jobs-status-col,
+ .import-jobs-cta-col {
+ border-bottom-width: 1px;
+ padding-left: $gl-padding;
+ }
+}
+
+.import-projects-loading-icon {
+ margin-top: $gl-padding-32;
}
.btn-import {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 5b5f486ea63..04c66006027 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -1,8 +1,3 @@
-// Limit MR description for side-by-side diff view
-.fixed-width-container {
- @include fixed-width-container;
-}
-
.issuable-warning-icon {
background-color: $orange-100;
border-radius: $border-radius-default;
@@ -22,11 +17,12 @@
.detail-page-header,
.page-content-header,
.commit-box,
+ .info-well,
.commit-ci-menu,
.files-changed-inner,
.limited-header-width,
.limited-width-notes {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
.issuable-details {
@@ -34,13 +30,13 @@
.mr-source-target,
.mr-state-widget,
.merge-manually {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
}
.merge-request-details {
.emoji-list-container {
- @extend .fixed-width-container;
+ @include fixed-width-container;
}
}
}
@@ -60,6 +56,11 @@
padding: 0;
margin-bottom: $gl-padding;
border-bottom: 0;
+ word-wrap: break-word;
+ overflow-wrap: break-word;
+ min-width: 0;
+ width: 100%;
+ text-align: initial;
}
.btn-edit {
@@ -67,14 +68,6 @@
height: $gl-padding * 2;
}
- // Border around images in issue and MR descriptions.
- .description img:not(.emoji) {
- border: 1px solid $white-normal;
- padding: 5px;
- max-height: calc(100vh - 100px);
- max-width: 100%;
- }
-
.emoji-block {
padding: 10px 0;
}
@@ -113,6 +106,20 @@
font-size: 0;
margin-bottom: -5px;
}
+
+ .scoped-label-wrapper {
+ > a {
+ max-width: 100%;
+ }
+
+ .color-label {
+ padding-right: $gl-padding-24;
+ }
+
+ .scoped-label {
+ right: 12px;
+ }
+ }
}
.right-sidebar {
@@ -132,7 +139,7 @@
color: $blue-800;
.avatar {
- border-color: rgba($gray-normal, .2);
+ border-color: rgba($gray-normal, 0.2);
}
}
@@ -219,7 +226,7 @@
}
a.edit-link:not([href]):hover {
- color: rgba($gray-normal, .2);
+ color: rgba($gray-normal, 0.2);
}
.lock-edit, // uses same style, different js behaviour
@@ -259,6 +266,10 @@
.selectbox {
display: none;
+
+ &.show {
+ display: block;
+ }
}
.btn-clipboard:hover {
@@ -312,6 +323,7 @@
}
.no-value,
+ .btn-default-hover-link,
.btn-secondary-hover-link {
color: $gl-text-color-secondary;
}
@@ -595,7 +607,6 @@
margin: -7px;
}
-
.user-list {
display: flex;
flex-wrap: wrap;
@@ -707,14 +718,11 @@
.issuable-list {
li {
-
.issue-box {
- display: -webkit-flex;
display: flex;
}
.issuable-info-container {
- -webkit-flex: 1;
flex: 1;
display: flex;
padding-right: $gl-padding;
@@ -722,6 +730,7 @@
.issuable-main-info {
flex: 1 auto;
margin-right: 10px;
+ min-width: 0;
.issue-weight-icon {
vertical-align: sub;
@@ -783,6 +792,7 @@
@media(max-width: map-get($grid-breakpoints, lg)-1) {
.task-status,
.issuable-due-date,
+ .issuable-weight,
.project-ref-path {
display: none;
}
@@ -809,7 +819,6 @@
.sidebar-collapsed-icon {
-
> .stopwatch-svg {
display: inline-block;
}
@@ -867,11 +876,11 @@
}
.help-state-toggle-enter-active {
- transition: all .8s ease;
+ transition: all 0.8s ease;
}
.help-state-toggle-leave-active {
- transition: all .5s ease;
+ transition: all 0.5s ease;
}
.help-state-toggle-enter,
@@ -930,7 +939,7 @@
.sidebar-collapsed-divider {
line-height: 5px;
font-size: 12px;
- color: $theme-gray-700;
+ color: $gray-700;
+ .sidebar-collapsed-icon {
padding-top: 0;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index bb6b6f84849..c7d2369a6b8 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -58,8 +58,6 @@ form.edit-issue {
}
ul.related-merge-requests > li {
- display: -ms-flexbox;
- display: -webkit-flex;
display: flex;
align-items: center;
@@ -80,7 +78,6 @@ ul.related-merge-requests > li {
}
}
-.merge-requests-title,
.related-branches-title {
font-size: 16px;
font-weight: $gl-font-weight-bold;
@@ -91,23 +88,16 @@ ul.related-merge-requests > li {
}
.merge-request-status {
- font-size: 13px;
- padding: 0 5px;
- color: $white-light;
- height: 20px;
- border-radius: 3px;
- line-height: 18px;
-
&.merged {
- background: $blue-500;
+ color: $blue-500;
}
&.closed {
- background: $red-500;
+ color: $red-500;
}
&.open {
- background: $green-500;
+ color: $green-500;
}
}
@@ -155,6 +145,19 @@ ul.related-merge-requests > li {
}
}
+.issues-footer {
+ padding-top: $gl-padding;
+ padding-bottom: 37px;
+}
+
+.issues-nav-controls {
+ font-size: 0;
+
+ .btn-group:empty {
+ display: none;
+ }
+}
+
.issuable-email-modal-btn {
padding: 0;
color: $blue-600;
diff --git a/app/assets/stylesheets/pages/issues/issue_count_badge.scss b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
index 4fba89e956b..64ca61f7094 100644
--- a/app/assets/stylesheets/pages/issues/issue_count_badge.scss
+++ b/app/assets/stylesheets/pages/issues/issue_count_badge.scss
@@ -1,11 +1,13 @@
-.issue-count-badge {
+.issue-count-badge,
+.mr-count-badge {
display: inline-flex;
border-radius: $border-radius-base;
border: 1px solid $border-color;
padding: 5px $gl-padding-8;
}
-.issue-count-badge-count {
+.issue-count-badge-count,
+.mr-count-badge-count {
display: inline-flex;
align-items: center;
}
diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss
index d2b9470be69..13288d8bad1 100644
--- a/app/assets/stylesheets/pages/labels.scss
+++ b/app/assets/stylesheets/pages/labels.scss
@@ -34,7 +34,7 @@
.dropdown-new-label {
.dropdown-content {
- max-height: 136px;
+ max-height: initial;
}
}
@@ -75,14 +75,14 @@
padding: 0;
margin-bottom: 0;
- > li:not(.empty-message):not(.is-not-draggable) {
+ > li:not(.empty-message):not(.no-border) {
background-color: $white-light;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
padding: $gl-padding;
border-radius: $border-radius-default;
- border: 1px solid $theme-gray-100;
+ border: 1px solid $gray-100;
&:last-child {
margin-bottom: 0;
@@ -92,16 +92,14 @@
opacity: 0.3;
}
- .prioritized-labels & {
+ .prioritized-labels:not(.is-not-draggable) & {
box-shadow: 0 1px 2px $issue-boards-card-shadow;
cursor: move;
- cursor: -webkit-grab;
- cursor: -moz-grab;
+ cursor: grab;
border: 0;
&:active {
- cursor: -webkit-grabbing;
- cursor: -moz-grabbing;
+ cursor: grabbing;
}
}
}
@@ -257,7 +255,7 @@
}
.label-badge {
- color: $theme-gray-900;
+ color: $gray-900;
font-weight: $gl-font-weight-normal;
padding: $gl-padding-4 $gl-padding-8;
border-radius: $border-radius-default;
@@ -269,7 +267,7 @@
}
.label-badge-gray {
- background-color: $theme-gray-100;
+ background-color: $gray-100;
}
.label-links {
@@ -326,11 +324,11 @@
}
.label-action {
- color: $theme-gray-800;
+ color: $gray-800;
cursor: pointer;
svg {
- fill: $theme-gray-800;
+ fill: $gray-800;
}
&:hover {
@@ -355,7 +353,7 @@
@media (max-width: map-get($grid-breakpoints, md)-1) {
.manage-labels-list {
- > li:not(.empty-message):not(.is-not-draggable) {
+ > li:not(.empty-message):not(.no-border) {
flex-wrap: wrap;
}
@@ -404,3 +402,66 @@
.priority-labels-empty-state .svg-content img {
max-width: $priority-label-empty-state-width;
}
+
+.scoped-label-tooltip-title {
+ color: $indigo-300;
+}
+
+.scoped-label-wrapper {
+ max-width: 100%;
+ vertical-align: top;
+
+ .badge {
+ text-overflow: ellipsis;
+ overflow-x: hidden;
+ }
+
+ &.label-link .color-label a {
+ color: inherit;
+ }
+
+ .color-label {
+ padding-right: $gl-padding-24;
+ max-width: 100%;
+ }
+
+ .scoped-label {
+ position: absolute;
+ top: 4px;
+ right: 8px;
+ padding: 0;
+ margin: 0;
+ line-height: $gl-line-height;
+ }
+
+ &.board-label {
+ .scoped-label {
+ top: 1px;
+ }
+ }
+}
+
+// Label inside title of Delete Label Modal
+.modal-header .page-title {
+ .scoped-label-wrapper {
+ .scoped-label {
+ line-height: 20px;
+ }
+
+ span.color-label {
+ padding-right: $gl-padding-24;
+ }
+ }
+}
+
+// Don't hide the overflow in system messages
+.system-note-message,
+.issuable-detail,
+.md-preview-holder,
+.note-body {
+ .scoped-label-wrapper {
+ .badge {
+ overflow: initial;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss
index 67d7a8175ac..297f642681b 100644
--- a/app/assets/stylesheets/pages/login.scss
+++ b/app/assets/stylesheets/pages/login.scss
@@ -21,13 +21,6 @@
color: $login-brand-holder-color;
}
- h1:first-child {
- font-weight: $gl-font-weight-normal;
- margin-bottom: 0.68em;
- margin-top: 0;
- font-size: 34px;
- }
-
h3 {
font-size: 22px;
}
@@ -49,8 +42,8 @@
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
- border-bottom-right-radius: $border-radius-small;
- border-bottom-left-radius: $border-radius-small;
+ border-bottom-right-radius: $border-radius;
+ border-bottom-left-radius: $border-radius;
padding: 15px;
.login-heading h3 {
@@ -95,7 +88,7 @@
}
.omniauth-container {
- border-radius: $border-radius-small;
+ border-radius: $border-radius;
font-size: 13px;
p {
@@ -120,7 +113,6 @@
}
.new-session-tabs {
- display: -webkit-flex;
display: flex;
box-shadow: 0 0 0 1px $border-color;
border-top-right-radius: $border-radius-default;
@@ -190,7 +182,7 @@
margin-top: 16px;
}
- input[type="submit"] {
+ input[type='submit'] {
@extend .btn-block;
margin-bottom: 0;
}
diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss
index 99609a96976..f8e273a2735 100644
--- a/app/assets/stylesheets/pages/members.scss
+++ b/app/assets/stylesheets/pages/members.scss
@@ -14,6 +14,12 @@
}
.member {
+ &.is-overridden {
+ .btn-ldap-override {
+ display: none !important;
+ }
+ }
+
.list-item-name {
@include media-breakpoint-up(sm) {
float: left;
@@ -27,7 +33,6 @@
.controls {
@include media-breakpoint-up(sm) {
- display: -webkit-flex;
display: flex;
}
@@ -123,6 +128,46 @@
outline: 0;
}
+.members-ldap {
+ align-self: center;
+}
+
+.alert-member-ldap {
+ background-color: $orange-50;
+
+ @include media-breakpoint-up(sm) {
+ line-height: 40px;
+ }
+
+ > p {
+ float: left;
+ margin-bottom: 10px;
+ color: $orange-600;
+
+ @include media-breakpoint-up(sm) {
+ padding-left: 55px;
+ margin-bottom: 0;
+ }
+ }
+
+ .controls {
+ width: 100%;
+
+ @include media-breakpoint-up(sm) {
+ width: auto;
+ }
+ }
+}
+
+.btn-ldap-override {
+ width: 100%;
+
+ @include media-breakpoint-up(sm) {
+ margin-left: 10px;
+ width: auto;
+ }
+}
+
.flex-project-members-panel {
display: flex;
flex-direction: row;
diff --git a/app/assets/stylesheets/pages/merge_conflicts.scss b/app/assets/stylesheets/pages/merge_conflicts.scss
index d26659701e1..278a9014458 100644
--- a/app/assets/stylesheets/pages/merge_conflicts.scss
+++ b/app/assets/stylesheets/pages/merge_conflicts.scss
@@ -20,81 +20,101 @@ $colors: (
white-header-not-chosen : #f0f0f0,
white-line-not-chosen : $gray-light,
- dark-header-head-neutral : rgba(#3f3, .2),
- dark-line-head-neutral : rgba(#3f3, .1),
+ dark-header-head-neutral : rgba(#3f3, 0.2),
+ dark-line-head-neutral : rgba(#3f3, 0.1),
dark-button-head-neutral : #40874f,
- dark-header-head-chosen : rgba(#3f3, .33),
- dark-line-head-chosen : rgba(#3f3, .2),
+ dark-header-head-chosen : rgba(#3f3, 0.33),
+ dark-line-head-chosen : rgba(#3f3, 0.2),
dark-button-head-chosen : #258537,
- dark-header-origin-neutral : rgba(#2878c9, .4),
- dark-line-origin-neutral : rgba(#2878c9, .3),
+ dark-header-origin-neutral : rgba(#2878c9, 0.4),
+ dark-line-origin-neutral : rgba(#2878c9, 0.3),
dark-button-origin-neutral : #2a5c8c,
- dark-header-origin-chosen : rgba(#2878c9, .6),
- dark-line-origin-chosen : rgba(#2878c9, .4),
+ dark-header-origin-chosen : rgba(#2878c9, 0.6),
+ dark-line-origin-chosen : rgba(#2878c9, 0.4),
dark-button-origin-chosen : #1d6cbf,
- dark-header-not-chosen : rgba(#fff, .25),
- dark-line-not-chosen : rgba(#fff, .1),
+ dark-header-not-chosen : rgba(#fff, 0.25),
+ dark-line-not-chosen : rgba(#fff, 0.1),
- monokai-header-head-neutral : rgba(#a6e22e, .25),
- monokai-line-head-neutral : rgba(#a6e22e, .1),
+ monokai-header-head-neutral : rgba(#a6e22e, 0.25),
+ monokai-line-head-neutral : rgba(#a6e22e, 0.1),
monokai-button-head-neutral : #376b20,
- monokai-header-head-chosen : rgba(#a6e22e, .4),
- monokai-line-head-chosen : rgba(#a6e22e, .25),
+ monokai-header-head-chosen : rgba(#a6e22e, 0.4),
+ monokai-line-head-chosen : rgba(#a6e22e, 0.25),
monokai-button-head-chosen : #39800d,
- monokai-header-origin-neutral : rgba(#60d9f1, .35),
- monokai-line-origin-neutral : rgba(#60d9f1, .15),
+ monokai-header-origin-neutral : rgba(#60d9f1, 0.35),
+ monokai-line-origin-neutral : rgba(#60d9f1, 0.15),
monokai-button-origin-neutral : #38848c,
- monokai-header-origin-chosen : rgba(#60d9f1, .5),
- monokai-line-origin-chosen : rgba(#60d9f1, .35),
+ monokai-header-origin-chosen : rgba(#60d9f1, 0.5),
+ monokai-line-origin-chosen : rgba(#60d9f1, 0.35),
monokai-button-origin-chosen : #3ea4b2,
- monokai-header-not-chosen : rgba(#76715d, .24),
- monokai-line-not-chosen : rgba(#76715d, .1),
+ monokai-header-not-chosen : rgba(#76715d, 0.24),
+ monokai-line-not-chosen : rgba(#76715d, 0.1),
- solarized-light-header-head-neutral : rgba(#859900, .37),
- solarized-light-line-head-neutral : rgba(#859900, .2),
+ solarized-light-header-head-neutral : rgba(#859900, 0.37),
+ solarized-light-line-head-neutral : rgba(#859900, 0.2),
solarized-light-button-head-neutral : #afb262,
- solarized-light-header-head-chosen : rgba(#859900, .5),
- solarized-light-line-head-chosen : rgba(#859900, .37),
+ solarized-light-header-head-chosen : rgba(#859900, 0.5),
+ solarized-light-line-head-chosen : rgba(#859900, 0.37),
solarized-light-button-head-chosen : #94993d,
- solarized-light-header-origin-neutral : rgba(#2878c9, .37),
- solarized-light-line-origin-neutral : rgba(#2878c9, .15),
+ solarized-light-header-origin-neutral : rgba(#2878c9, 0.37),
+ solarized-light-line-origin-neutral : rgba(#2878c9, 0.15),
solarized-light-button-origin-neutral : #60a1bf,
- solarized-light-header-origin-chosen : rgba(#2878c9, .6),
- solarized-light-line-origin-chosen : rgba(#2878c9, .37),
+ solarized-light-header-origin-chosen : rgba(#2878c9, 0.6),
+ solarized-light-line-origin-chosen : rgba(#2878c9, 0.37),
solarized-light-button-origin-chosen : #2482b2,
- solarized-light-header-not-chosen : rgba(#839496, .37),
- solarized-light-line-not-chosen : rgba(#839496, .2),
+ solarized-light-header-not-chosen : rgba(#839496, 0.37),
+ solarized-light-line-not-chosen : rgba(#839496, 0.2),
- solarized-dark-header-head-neutral : rgba(#859900, .35),
- solarized-dark-line-head-neutral : rgba(#859900, .15),
+ solarized-dark-header-head-neutral : rgba(#859900, 0.35),
+ solarized-dark-line-head-neutral : rgba(#859900, 0.15),
solarized-dark-button-head-neutral : #376b20,
- solarized-dark-header-head-chosen : rgba(#859900, .5),
- solarized-dark-line-head-chosen : rgba(#859900, .35),
+ solarized-dark-header-head-chosen : rgba(#859900, 0.5),
+ solarized-dark-line-head-chosen : rgba(#859900, 0.35),
solarized-dark-button-head-chosen : #39800d,
- solarized-dark-header-origin-neutral : rgba(#2878c9, .35),
- solarized-dark-line-origin-neutral : rgba(#2878c9, .15),
+ solarized-dark-header-origin-neutral : rgba(#2878c9, 0.35),
+ solarized-dark-line-origin-neutral : rgba(#2878c9, 0.15),
solarized-dark-button-origin-neutral : #086799,
- solarized-dark-header-origin-chosen : rgba(#2878c9, .6),
- solarized-dark-line-origin-chosen : rgba(#2878c9, .35),
+ solarized-dark-header-origin-chosen : rgba(#2878c9, 0.6),
+ solarized-dark-line-origin-chosen : rgba(#2878c9, 0.35),
solarized-dark-button-origin-chosen : #0082cc,
- solarized-dark-header-not-chosen : rgba(#839496, .25),
- solarized-dark-line-not-chosen : rgba(#839496, .15)
+ solarized_dark_header_not_chosen : rgba(#839496, 0.25),
+ solarized_dark_line_not_chosen : rgba(#839496, 0.15),
+
+ none_header_head_neutral : $gray-normal,
+ none_line_head_neutral : $gray-normal,
+ none_button_head_neutral : $gray-normal,
+
+ none_header_head_chosen : $gray-darker,
+ none_line_head_chosen : $gray-darker,
+ none_button_head_chosen : $gray-darker,
+
+ none_header_origin_neutral : $gray-normal,
+ none_line_origin_neutral : $gray-normal,
+ none_button_origin_neutral : $gray-normal,
+
+ none_header_origin_chosen : $gray-darker,
+ none_line_origin_chosen : $gray-darker,
+ none_button_origin_chosen : $gray-darker,
+
+ none_header_not_chosen : $gray-light,
+ none_line_not_chosen : $gray-light
+
);
// scss-lint:enable ColorVariable
@@ -190,26 +210,20 @@ $colors: (
}
#conflicts {
-
.white {
- @include color-scheme('white')
- }
+ @include color-scheme('white'); }
.dark {
- @include color-scheme('dark')
- }
+ @include color-scheme('dark'); }
.monokai {
- @include color-scheme('monokai')
- }
+ @include color-scheme('monokai'); }
.solarized-light {
- @include color-scheme('solarized-light')
- }
+ @include color-scheme('solarized-light'); }
.solarized-dark {
- @include color-scheme('solarized-dark')
- }
+ @include color-scheme('solarized-dark'); }
.diff-wrap-lines .line_content {
white-space: normal;
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index 221b4e934ff..ab5a9e170f0 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -38,9 +38,7 @@
}
.mr-widget-section {
- .media {
- align-items: center;
- }
+ border-radius: $border-radius-default $border-radius-default 0 0;
.code-text {
flex: 1;
@@ -56,6 +54,11 @@
.mr-widget-extension {
border-top: 1px solid $border-color;
background-color: $gray-light;
+
+ &.clickable:hover {
+ background-color: $gl-gray-200;
+ cursor: pointer;
+ }
}
.mr-widget-workflow {
@@ -64,7 +67,7 @@
&::before {
content: '';
- border-left: 1px solid $theme-gray-200;
+ border-left: 1px solid $gray-200;
position: absolute;
left: 32px;
top: -17px;
@@ -78,20 +81,52 @@
border-top: 0;
}
-.mr-widget-section,
+.mr-widget-body,
.mr-widget-content,
.mr-widget-footer {
padding: $gl-padding;
}
+.mr-widget-info {
+ padding-left: $gl-padding-50 - $gl-padding-32;
+ padding-right: $gl-padding;
+}
+
.mr-state-widget {
color: $gl-text-color;
+ .commit-message-edit {
+ border-radius: $border-radius-default;
+ }
+
.mr-widget-section,
.mr-widget-footer {
border-top: solid 1px $border-color;
}
+ .mr-fast-forward-message {
+ padding-left: $gl-padding-50;
+ padding-bottom: $gl-padding;
+ }
+
+ .commits-list {
+ > li {
+ padding: $gl-padding;
+
+ @include media-breakpoint-up(md) {
+ padding-left: $gl-padding-50;
+ }
+ }
+ }
+
+ .mr-commit-dropdown {
+ .dropdown-menu {
+ @include media-breakpoint-up(md) {
+ width: 150%;
+ }
+ }
+ }
+
.mr-widget-footer {
padding: 0;
}
@@ -136,6 +171,7 @@
float: left;
.accept-merge-request {
+ &.ci-preparing,
&.ci-pending,
&.ci-running {
@include btn-blue;
@@ -405,7 +441,7 @@
}
.mr-widget-help {
- padding: 10px 16px 10px 48px;
+ padding: 10px 16px 10px $gl-padding-50;
font-style: italic;
}
@@ -423,10 +459,6 @@
}
}
-.mr-widget-body-controls {
- flex-wrap: wrap;
-}
-
.mr_source_commit,
.mr_target_commit {
margin-bottom: 0;
@@ -465,14 +497,22 @@
.merge-request {
padding: 10px 0 10px 15px;
position: relative;
- display: -webkit-flex;
display: flex;
.issuable-info-container {
- -webkit-flex: 1;
flex: 1;
}
+ .issuable-meta {
+ .author-link {
+ display: inline-block;
+ }
+
+ .issuable-comments {
+ height: 18px;
+ }
+ }
+
.merge-request-title {
margin-bottom: 2px;
@@ -525,6 +565,10 @@
.mr-links {
padding-left: $status-icon-size + $gl-btn-padding;
+
+ &:last-child {
+ padding-bottom: $gl-padding;
+ }
}
.mr-info-list {
@@ -570,7 +614,6 @@
color: $gl-text-color;
}
-
.git-merge-container {
justify-content: space-between;
flex: 1;
@@ -708,8 +751,10 @@
.mr-version-controls {
position: relative;
+ z-index: 203;
background: $gray-light;
color: $gl-text-color;
+ margin-top: -1px;
.mr-version-menus-container {
display: flex;
@@ -732,6 +777,7 @@
.content-block {
padding: $gl-padding-top $gl-padding;
+ border-bottom: 0;
}
.comments-disabled-notif {
@@ -755,17 +801,40 @@
color: $orange-500;
padding-right: 5px;
}
+
+ // Shortening button height by 1px to make compare-versions
+ // header 56px and fit into our 8px design grid
+ button {
+ height: 34px;
+ }
+
+ @include media-breakpoint-up(md) {
+ position: -webkit-sticky;
+ position: sticky;
+ top: $header-height + $mr-tabs-height;
+ margin-left: -16px;
+ width: calc(100% + 32px);
+
+ .mr-version-menus-container {
+ flex-wrap: nowrap;
+ }
+
+ .with-performance-bar & {
+ top: $header-height + $performance-bar-height + $mr-tabs-height;
+ }
+ }
}
-.merge-request-tabs-holder {
+.merge-request-tabs-holder,
+.epic-tabs-holder {
top: $header-height;
- z-index: 200;
+ z-index: 250;
background-color: $white-light;
border-bottom: 1px solid $border-color;
@include media-breakpoint-up(sm) {
- position: sticky;
position: -webkit-sticky;
+ position: sticky;
}
&.affix {
@@ -775,11 +844,6 @@
@include media-breakpoint-down(xs) {
right: 0;
}
-
- .merge-request-tabs-container {
- padding-left: $gl-padding;
- padding-right: $gl-padding;
- }
}
.nav-links {
@@ -787,11 +851,21 @@
}
}
-.with-performance-bar .merge-request-tabs-holder {
- top: $header-height + $performance-bar-height;
+.merge-request-tabs-holder.affix .merge-request-tabs-container,
+.epic-tabs-holder.affix .epic-tabs-container {
+ padding-left: $gl-padding;
+ padding-right: $gl-padding;
+}
+
+.with-performance-bar {
+ .merge-request-tabs-holder,
+ .epic-tabs-holder {
+ top: $header-height + $performance-bar-height;
+ }
}
-.merge-request-tabs {
+.merge-request-tabs,
+.epic-tabs {
display: flex;
flex-wrap: nowrap;
margin-bottom: 0;
@@ -799,7 +873,8 @@
}
.limit-container-width {
- .merge-request-tabs-container {
+ .merge-request-tabs-container,
+ .epic-tabs-container {
max-width: $limited-layout-width;
margin-left: auto;
margin-right: auto;
@@ -812,11 +887,12 @@
}
}
-.merge-request-tabs-container {
+.merge-request-tabs-container,
+.epic-tabs-container {
display: flex;
justify-content: space-between;
- @include media-breakpoint-down(md) {
+ @include media-breakpoint-down(sm) {
flex-direction: column-reverse;
}
@@ -830,10 +906,9 @@
}
.limit-container-width:not(.container-limited) {
- .merge-request-tabs-holder:not(.affix) {
- .merge-request-tabs-container {
- max-width: $limited-layout-width - ($gl-padding * 2);
- }
+ .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container,
+ .epic-tabs-holder:not(.affix) .epic-tabs-container {
+ max-width: $limited-layout-width - ($gl-padding * 2);
}
}
@@ -906,10 +981,6 @@
}
}
- .btn svg {
- fill: $theme-gray-700;
- }
-
.dropdown-menu {
width: 400px;
}
@@ -951,7 +1022,7 @@
.coverage {
font-size: 12px;
- color: $theme-gray-700;
+ color: $gray-700;
line-height: initial;
}
@@ -961,3 +1032,19 @@
width: $ci-action-icon-size-lg;
}
}
+
+.merge-request-details .file-finder-overlay.diff-file-finder {
+ position: fixed;
+ z-index: 99999;
+ background: $black-transparent;
+}
+
+.mr-compare {
+ .diff-file .file-title-flex-parent {
+ top: $header-height + 51px;
+
+ .with-performance-bar & {
+ top: $performance-bar-height + $header-height + 51px;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss
index 1e92582d6d9..49608a3964f 100644
--- a/app/assets/stylesheets/pages/milestone.scss
+++ b/app/assets/stylesheets/pages/milestone.scss
@@ -1,3 +1,5 @@
+$status-box-line-height: 26px;
+
.issues-sortable-list .str-truncated {
max-width: 90%;
}
@@ -6,7 +8,7 @@
padding: $gl-padding-8;
margin-top: $gl-padding-8;
border-radius: $border-radius-default;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
.milestone {
border: 0;
@@ -38,6 +40,7 @@
font-size: $tooltip-font-size;
margin-top: 0;
margin-right: $gl-padding-4;
+ line-height: $status-box-line-height;
@include media-breakpoint-down(xs) {
line-height: unset;
@@ -64,18 +67,14 @@
.card-header {
line-height: $line-height-base;
padding: 14px 16px;
- display: -webkit-flex;
display: flex;
.title {
- -webkit-flex: 1;
- -webkit-flex-grow: 1;
flex: 1;
flex-grow: 2;
}
.counter {
- -webkit-flex: 1;
flex: 0;
padding-left: 16px;
}
@@ -236,6 +235,7 @@
padding: 0;
}
+ .popover-body,
.popover-content {
padding: 0;
}
diff --git a/app/assets/stylesheets/pages/monitor.scss b/app/assets/stylesheets/pages/monitor.scss
new file mode 100644
index 00000000000..25ff5abd774
--- /dev/null
+++ b/app/assets/stylesheets/pages/monitor.scss
@@ -0,0 +1,5 @@
+.chart-tooltip > .popover {
+ min-width: 0;
+ width: max-content;
+ max-width: $chart-tooltip-max-width;
+}
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 5b30295adf9..3343b55d24b 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -1,10 +1,6 @@
/**
* Note Form
*/
-.comment-btn {
- @extend .btn-success;
-}
-
.diff-file .diff-content {
tr.line_holder:hover > td .line_note_link {
opacity: 1;
@@ -62,7 +58,7 @@
border: 1px solid $border-color;
border-radius: $border-radius-base;
transition: border-color ease-in-out 0.15s,
- box-shadow ease-in-out 0.15s;
+ box-shadow ease-in-out 0.15s;
&.is-focused {
@extend .form-control:focus;
@@ -76,7 +72,7 @@
&.is-dropzone-hover {
border-color: $green-500;
box-shadow: 0 0 2px $black-transparent,
- 0 0 4px $green-500-focus;
+ 0 0 4px $green-500-focus;
.comment-toolbar,
.nav-links {
@@ -88,9 +84,7 @@
.md-header .nav-links {
display: flex;
- display: -webkit-flex;
flex-flow: row wrap;
- -webkit-flex-flow: row wrap;
width: 100%;
.float-right {
@@ -151,7 +145,7 @@
}
.sidebar-item-value & {
- fill: $theme-gray-700;
+ fill: $gray-700;
}
}
@@ -386,7 +380,7 @@ table {
}
.comment-type-dropdown {
- .comment-btn {
+ .btn-success {
width: auto;
}
@@ -417,7 +411,7 @@ table {
width: 100%;
margin-bottom: 10px;
- .comment-btn {
+ .btn-success {
flex-grow: 1;
flex-shrink: 0;
width: auto;
@@ -448,7 +442,7 @@ table {
.uploading-error-message {
@include media-breakpoint-down(xs) {
&::after {
- content: "\a";
+ content: '\a';
white-space: pre;
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 2adfa0d312e..50c87e55f56 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -5,7 +5,7 @@ $note-form-margin-left: 72px;
@mixin vertical-line($left) {
&::before {
content: '';
- border-left: 2px solid $theme-gray-100;
+ border-left: 2px solid $gray-100;
position: absolute;
top: 0;
bottom: 0;
@@ -68,7 +68,7 @@ $note-form-margin-left: 72px;
}
}
- .notes_content {
+ .notes-content {
border: 0;
border-top: 1px solid $border-color;
}
@@ -107,6 +107,7 @@ $note-form-margin-left: 72px;
&.collapsed {
color: $gl-text-color-secondary;
+ border-radius: 0 0 $border-radius-default $border-radius-default;
svg {
float: left;
@@ -152,6 +153,16 @@ $note-form-margin-left: 72px;
display: block;
position: relative;
+ .timeline-discussion-body {
+ margin-top: -$gl-padding-8;
+ overflow-x: auto;
+ overflow-y: hidden;
+
+ .note-body {
+ margin-top: $gl-padding-8;
+ }
+ }
+
.diff-content {
overflow: visible;
padding: 0;
@@ -214,14 +225,7 @@ $note-form-margin-left: 72px;
overflow-y: hidden;
.note-text {
- @include md-typography;
- // Reset ul style types since we're nested inside a ul already
- @include bulleted-list;
word-wrap: break-word;
-
- table {
- @include markdown-table;
- }
}
}
@@ -273,8 +277,6 @@ $note-form-margin-left: 72px;
}
.system-note-message {
- display: inline;
-
&::first-letter {
text-transform: lowercase;
}
@@ -293,26 +295,6 @@ $note-form-margin-left: 72px;
}
}
- .timeline-icon {
- float: left;
- display: flex;
- align-items: center;
- background-color: $white-light;
- width: $system-note-icon-size;
- height: $system-note-icon-size;
- border: 1px solid $border-color;
- border-radius: $system-note-icon-size;
- margin: -6px $gl-padding 0 0;
-
- svg {
- width: $system-note-svg-size;
- height: $system-note-svg-size;
- fill: $gray-darkest;
- display: block;
- margin: 0 auto;
- }
- }
-
.timeline-content {
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: 30px;
@@ -370,6 +352,37 @@ $note-form-margin-left: 72px;
}
}
}
+
+ .system-note,
+ .discussion-filter-note {
+ .timeline-icon {
+ float: left;
+ display: flex;
+ align-items: center;
+ background-color: $white-light;
+ width: $system-note-icon-size;
+ height: $system-note-icon-size;
+ border: 1px solid $border-color;
+ border-radius: $system-note-icon-size;
+ margin: -6px $gl-padding 0 0;
+
+ svg {
+ width: $system-note-svg-size;
+ height: $system-note-svg-size;
+ fill: $gray-darkest;
+ display: block;
+ margin: 0 auto;
+ }
+ }
+ }
+
+ .discussion-filter-note {
+ .timeline-icon {
+ width: $system-note-icon-size + 6;
+ height: $system-note-icon-size + 6;
+ margin-top: -8px;
+ }
+ }
}
// Diff code in discussion view
@@ -441,7 +454,7 @@ $note-form-margin-left: 72px;
// Merge request notes in diffs
// Diff is inline
- .notes_content .note-header .note-headline-light {
+ .notes-content .note-header .note-headline-light {
display: inline-block;
position: relative;
}
@@ -453,12 +466,17 @@ $note-form-margin-left: 72px;
border: 1px solid $border-color;
border-left: 0;
- &.notes_content {
+ &.notes-content {
border-width: 1px 0;
padding: 0;
vertical-align: top;
white-space: normal;
+ // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-ce/issues/53973
+ // background-color is needed for dark code preference
+ padding-bottom: 1px;
+ background-color: $white-light;
+
&.parallel {
border-width: 1px;
@@ -484,11 +502,6 @@ $note-form-margin-left: 72px;
.discussion-notes {
margin-left: 0;
border-left: 0;
-
- .notes {
- position: relative;
- @include vertical-line(52px);
- }
}
.note-wrapper {
@@ -505,7 +518,7 @@ $note-form-margin-left: 72px;
}
.commit-diff {
- .notes_content {
+ .notes-content {
background-color: $white-light;
}
}
@@ -540,6 +553,11 @@ $note-form-margin-left: 72px;
.note-header-info {
padding-bottom: 0;
}
+
+ .timeline-content {
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
}
.unresolved {
@@ -586,17 +604,14 @@ $note-form-margin-left: 72px;
}
.note-headline-meta {
- display: inline-block;
- white-space: nowrap;
-
- .system-note-message {
- white-space: normal;
- }
-
.system-note-separator {
color: $gl-text-color-disabled;
}
+ .note-timestamp {
+ white-space: nowrap;
+ }
+
a:hover {
text-decoration: underline;
}
@@ -630,7 +645,7 @@ $note-form-margin-left: 72px;
display: inline-flex;
align-items: center;
margin-left: 10px;
- color: $gray-darkest;
+ color: $gray-600;
@include notes-media('max', map-get($grid-breakpoints, sm) - 1) {
float: none;
@@ -726,7 +741,7 @@ $note-form-margin-left: 72px;
.add-diff-note {
@include btn-comment-icon;
opacity: 0;
- margin-left: -55px;
+ margin-left: -50px;
position: absolute;
top: 50%;
transform: translateY(-50%);
@@ -892,7 +907,6 @@ $note-form-margin-left: 72px;
}
.discussion-filter-container {
-
.btn > svg {
width: $gl-col-padding;
height: $gl-col-padding;
@@ -914,7 +928,6 @@ $note-form-margin-left: 72px;
//This needs to be deleted when Snippet/Commit comments are convered to Vue
// See https://gitlab.com/gitlab-org/gitlab-ce/issues/53918#note_117038785
.unstyled-comments {
-
.discussion-header {
padding: $gl-padding;
border-bottom: 1px solid $border-color;
diff --git a/app/assets/stylesheets/pages/pipeline_schedules.scss b/app/assets/stylesheets/pages/pipeline_schedules.scss
index 617b3db2fae..85c4902eee2 100644
--- a/app/assets/stylesheets/pages/pipeline_schedules.scss
+++ b/app/assets/stylesheets/pages/pipeline_schedules.scss
@@ -1,9 +1,4 @@
-.js-pipeline-schedule-form {
- .dropdown-select,
- .dropdown-menu-toggle {
- width: 100% !important;
- }
-
+.pipeline-schedule-form {
.gl-field-error {
margin: 10px 0 0;
}
diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss
index fdd17af35fb..aa6bbc8e473 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -256,14 +256,25 @@
}
}
- .mini-pipeline-graph-dropdown-toggle svg {
- height: $ci-action-icon-size;
- width: $ci-action-icon-size;
- position: absolute;
- top: -1px;
- left: -1px;
- z-index: 2;
- overflow: visible;
+ .mini-pipeline-graph-dropdown-toggle {
+ svg {
+ height: $ci-action-icon-size;
+ width: $ci-action-icon-size;
+ position: absolute;
+ top: -1px;
+ left: -1px;
+ z-index: 2;
+ overflow: visible;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ svg {
+ top: -2px;
+ left: -2px;
+ }
+ }
}
.stage-container {
@@ -293,7 +304,7 @@
width: 7px;
position: absolute;
right: -7px;
- top: 10px;
+ top: 11px;
border-bottom: 2px solid $border-color;
}
}
@@ -330,13 +341,15 @@
&.builds .ci-table tr {
height: 71px;
}
-}
-.build-failures {
- th {
- border-top: 0;
+ .ci-table {
+ thead th {
+ border-top: 0;
+ }
}
+}
+.build-failures {
.build-state {
padding: 20px 2px;
@@ -433,6 +446,7 @@
}
.pipeline-tab-content {
+ display: flex;
width: 100%;
background-color: $gray-light;
padding: $gl-padding;
@@ -484,7 +498,8 @@
list-style: none;
}
- &:last-child {
+ // when downstream pipelines are present, the last stage isn't the last column
+ &:last-child:not(.has-downstream) {
.build {
// Remove right connecting horizontal line from first build in last stage
&:first-child::after {
@@ -501,7 +516,8 @@
}
}
- &:first-child {
+ // when upstream pipelines are present, the first stage isn't the first column
+ &:first-child:not(.has-upstream) {
.build {
// Remove left curved connectors from all builds in first stage
&:not(:first-child)::before {
@@ -549,6 +565,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
+ line-height: 2.2em;
}
.build {
@@ -685,6 +702,11 @@
}
}
}
+
+ .stage-action svg {
+ left: 1px;
+ top: -2px;
+ }
}
// Triggers the dropdown in the big pipeline graph
@@ -696,32 +718,48 @@
top: 8px;
}
+.ci-build-text,
.ci-status-text {
- max-width: 110px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- vertical-align: bottom;
- display: inline-block;
- position: relative;
- font-weight: $gl-font-weight-normal;
+ font-weight: 200;
}
-@mixin mini-pipeline-graph-color($color-light, $color-main, $color-dark) {
- border-color: $color-main;
- color: $color-main;
+@mixin mini-pipeline-graph-color(
+ $color-background-default,
+ $color-background-hover-focus,
+ $color-background-active,
+ $color-foreground-default,
+ $color-foreground-hover-focus,
+ $color-foreground-active
+) {
+ background-color: $color-background-default;
+ border-color: $color-foreground-default;
+
+ svg {
+ fill: $color-foreground-default;
+ }
&:hover,
- &:focus,
+ &:focus {
+ background-color: $color-background-hover-focus;
+ border-color: $color-foreground-hover-focus;
+
+ svg {
+ fill: $color-foreground-hover-focus;
+ }
+ }
+
&:active {
- background-color: $color-light;
- border-color: $color-dark;
- color: $color-dark;
+ background-color: $color-background-active;
+ border-color: $color-foreground-active;
svg {
- fill: $color-dark;
+ fill: $color-foreground-active;
}
}
+
+ &:focus {
+ box-shadow: 0 0 4px 1px $blue-300;
+ }
}
@mixin mini-pipeline-item() {
@@ -733,26 +771,33 @@
height: $ci-action-icon-size;
margin: 0;
padding: 0;
- transition: all 0.2s linear;
position: relative;
vertical-align: middle;
+ &:hover,
+ &:active,
+ &:focus {
+ outline: none;
+ border-width: 2px;
+ }
+
// Dropdown button animation in mini pipeline graph
&.ci-status-icon-success {
- @include mini-pipeline-graph-color($green-100, $green-500, $green-600);
+ @include mini-pipeline-graph-color($white, $green-100, $green-200, $green-500, $green-600, $green-700);
}
&.ci-status-icon-failed {
- @include mini-pipeline-graph-color($red-100, $red-500, $red-600);
+ @include mini-pipeline-graph-color($white, $red-100, $red-200, $red-500, $red-600, $red-700);
}
&.ci-status-icon-pending,
- &.ci-status-icon-success_with_warnings {
- @include mini-pipeline-graph-color($orange-100, $orange-500, $orange-600);
+ &.ci-status-icon-success-with-warnings {
+ @include mini-pipeline-graph-color($white, $orange-100, $orange-200, $orange-500, $orange-600, $orange-700);
}
+ &.ci-status-icon-preparing,
&.ci-status-icon-running {
- @include mini-pipeline-graph-color($blue-100, $blue-400, $blue-600);
+ @include mini-pipeline-graph-color($white, $blue-100, $blue-200, $blue-500, $blue-600, $blue-700);
}
&.ci-status-icon-canceled,
@@ -760,42 +805,18 @@
&.ci-status-icon-disabled,
&.ci-status-icon-not-found,
&.ci-status-icon-manual {
- @include mini-pipeline-graph-color(rgba($gl-text-color, 0.1), $gl-text-color, $gl-text-color);
+ @include mini-pipeline-graph-color($white, $gray-700, $gray-800, $gray-900, $gray-950, $black);
}
&.ci-status-icon-created,
&.ci-status-icon-skipped {
- @include mini-pipeline-graph-color(rgba($gray-darkest, 0.1), $gray-darkest, $gray-darkest);
+ @include mini-pipeline-graph-color($white, $gray-200, $gray-300, $gray-500, $gray-600, $gray-700);
}
}
// Dropdown button in mini pipeline graph
button.mini-pipeline-graph-dropdown-toggle {
@include mini-pipeline-item();
-
- > .fa.fa-caret-down {
- position: absolute;
- left: 20px;
- top: 5px;
- display: inline-block;
- visibility: hidden;
- opacity: 0;
- color: inherit;
- font-size: 12px;
- transition: visibility 0.1s, opacity 0.1s linear;
- }
-
- &:active,
- &:focus,
- &:hover {
- outline: none;
- width: 35px;
-
- .fa.fa-caret-down {
- visibility: visible;
- opacity: 1;
- }
- }
}
/**
@@ -845,7 +866,7 @@ button.mini-pipeline-graph-dropdown-toggle {
height: $ci-action-dropdown-svg-size;
fill: $gl-text-color-secondary;
position: relative;
- top: 0;
+ top: 1px;
vertical-align: initial;
}
}
@@ -853,7 +874,7 @@ button.mini-pipeline-graph-dropdown-toggle {
// SVGs in the commit widget and mr widget
a.ci-action-icon-container.ci-action-icon-wrapper svg {
- top: 2px;
+ top: 4px;
}
.scrollable-menu {
@@ -882,7 +903,7 @@ button.mini-pipeline-graph-dropdown-toggle {
// Match dropdown.scss for all `a` tags
&.non-details-job-component {
- padding: 8px 16px;
+ padding: $gl-padding-8 $gl-btn-horz-padding;
}
.ci-job-name-component {
@@ -891,26 +912,6 @@ button.mini-pipeline-graph-dropdown-toggle {
flex: 1;
}
- // build name
- .ci-build-text,
- .ci-status-text {
- font-weight: 200;
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- max-width: 70%;
- margin-left: 2px;
- display: inline-block;
-
- &::after {
- content: '';
- display: block;
- }
-
- @include media-breakpoint-down(xs) {
- max-width: 60%;
- }
- }
.ci-status-icon {
@extend .append-right-8;
@@ -978,8 +979,6 @@ button.mini-pipeline-graph-dropdown-toggle {
* Top arrow in the dropdown in the mini pipeline graph
*/
.mini-pipeline-graph-dropdown-menu {
- z-index: 200;
-
&::before,
&::after {
content: '';
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index a4831b64344..87cef43b923 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -249,6 +249,16 @@
.event-item {
padding-left: 40px;
}
+
+ @include media-breakpoint-up(lg) {
+ margin-right: 5px;
+ }
+ }
+
+ .projects-block {
+ @include media-breakpoint-up(lg) {
+ margin-left: 5px;
+ }
}
@include media-breakpoint-down(xs) {
@@ -256,23 +266,6 @@
padding-top: 20px;
}
- .cover-controls {
- position: static;
- padding: 0 16px;
- margin-bottom: 20px;
- display: -webkit-flex;
- display: flex;
-
- .btn {
- -webkit-flex-grow: 1;
- flex-grow: 1;
-
- &:first-child {
- margin-left: 0;
- }
- }
- }
-
.user-profile-nav {
a {
margin-right: 0;
@@ -312,6 +305,7 @@ table.u2f-registrations {
margin: 20px -5px 0;
.bordered-box {
+ padding: 32px;
border: 1px solid $blue-300;
border-radius: $border-radius-default;
background-color: $blue-50;
@@ -445,8 +439,17 @@ table.u2f-registrations {
}
}
+ .form-group > label {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .form-group > .form-text {
+ font-size: $gl-font-size;
+ }
+
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
+ padding: 6px 10px;
.no-emoji-placeholder {
position: relative;
@@ -456,4 +459,53 @@ table.u2f-registrations {
}
}
}
+
+ @include media-breakpoint-down(sm) {
+ .input-md,
+ .input-lg {
+ max-width: 100%;
+ }
+ }
+}
+
+.help-block {
+ color: $gl-text-color-secondary;
+}
+
+.gitlab-slack-gif {
+ width: 100%;
+ max-width: $add-to-slack-gif-max-width;
+}
+
+.gitlab-slack-well {
+ background-color: $white-light;
+ box-shadow: none;
+ max-width: $add-to-slack-well-max-width;
+}
+
+.gitlab-slack-logo {
+ width: $add-to-slack-logo-size;
+ height: $add-to-slack-logo-size;
+}
+
+.gitlab-slack-popup {
+ width: 100%;
+ max-width: $add-to-slack-popup-max-width;
+}
+
+.gitlab-slack-right-arrow svg {
+ fill: $white-dark;
+ width: $right-arrow-size;
+ height: $right-arrow-size;
+ vertical-align: text-bottom;
+}
+
+.gitlab-slack-double-headed-arrow {
+ vertical-align: text-top;
+
+ svg {
+ fill: $gray-darker;
+ width: $double-headed-arrow-width;
+ height: $double-headed-arrow-height;
+ }
}
diff --git a/app/assets/stylesheets/pages/profiles/preferences.scss b/app/assets/stylesheets/pages/profiles/preferences.scss
index a353f301d07..45e62913f37 100644
--- a/app/assets/stylesheets/pages/profiles/preferences.scss
+++ b/app/assets/stylesheets/pages/profiles/preferences.scss
@@ -60,11 +60,11 @@
}
&.ui-dark {
- background-color: $theme-gray-900;
+ background-color: $gray-900;
}
&.ui-light {
- background-color: $theme-gray-200;
+ background-color: $gray-200;
}
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 0ce0db038a7..151af843c95 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -18,12 +18,9 @@
}
.input-group {
- display: flex;
-
.select2-container {
display: unset;
max-width: unset;
- width: unset !important;
flex-grow: 1;
}
@@ -70,6 +67,10 @@
}
}
+.classification-label {
+ background-color: $red-500;
+}
+
.toggle-wrapper {
margin-top: 5px;
}
@@ -140,73 +141,19 @@
}
}
-.project-home-panel,
-.group-home-panel {
- padding-top: 24px;
- padding-bottom: 24px;
-
- .group-avatar {
- float: none;
- margin: 0 auto;
-
- &.identicon {
- border-radius: 50%;
- }
- }
-
- .group-title {
- margin-top: 10px;
- margin-bottom: 10px;
- font-size: 24px;
- font-weight: $gl-font-weight-normal;
- line-height: 1;
- word-wrap: break-word;
-
- .fa {
- margin-left: 2px;
- font-size: 12px;
- vertical-align: middle;
- }
- }
-
- .group-home-desc {
- margin-left: auto;
- margin-right: auto;
- margin-bottom: 0;
- max-width: 700px;
-
- > p {
- margin-bottom: 0;
- }
- }
-
- .notifications-btn {
- .fa-bell,
- .fa-spinner {
- margin-right: 6px;
- }
-
- .fa-angle-down {
- margin-left: 6px;
- }
- }
-}
-
+.group-home-panel,
.project-home-panel {
padding-top: $gl-padding;
padding-bottom: $gl-padding;
- .project-avatar {
- width: $project-title-row-height;
- height: $project-title-row-height;
+ .home-panel-avatar {
+ width: $home-panel-title-row-height;
+ height: $home-panel-title-row-height;
flex-shrink: 0;
- flex-basis: $project-title-row-height;
- margin: 0 $gl-padding 0 0;
+ flex-basis: $home-panel-title-row-height;
}
- .project-title {
- margin-top: 8px;
- margin-bottom: 5px;
+ .home-panel-title {
font-size: 20px;
line-height: $gl-line-height-24;
font-weight: bold;
@@ -215,11 +162,7 @@
font-size: $gl-font-size-large;
}
- .project-visibility {
- color: $gl-text-color-secondary;
- }
-
- .project-tag-list {
+ .home-panel-topic-list {
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
@@ -231,12 +174,12 @@
}
}
- .project-title-row {
+ .home-panel-title-row {
@include media-breakpoint-down(sm) {
- .project-avatar {
- width: $project-avatar-mobile-size;
- height: $project-avatar-mobile-size;
- flex-basis: $project-avatar-mobile-size;
+ .home-panel-avatar {
+ width: $home-panel-avatar-mobile-size;
+ height: $home-panel-avatar-mobile-size;
+ flex-basis: $home-panel-avatar-mobile-size;
.avatar {
font-size: 20px;
@@ -244,42 +187,39 @@
}
}
- .project-title {
+ .home-panel-title {
margin-top: 4px;
margin-bottom: 2px;
font-size: $gl-font-size;
line-height: $gl-font-size-large;
}
- .project-tag-list,
- .project-metadata {
+ .home-panel-topic-list,
+ .home-panel-metadata {
font-size: $gl-font-size-small;
}
}
}
- .project-metadata {
+ .home-panel-metadata {
font-weight: normal;
font-size: 14px;
line-height: $gl-btn-line-height;
- color: $gl-text-color-secondary;
-
- .project-license {
+ .home-panel-license {
.btn {
line-height: 0;
border-width: 0;
}
}
- .access-request-link,
- .project-tag-list {
+ .access-request-link {
padding-left: $gl-padding-8;
border-left: 1px solid $gl-text-color-secondary;
}
}
- .project-description {
+ .home-panel-description {
@include media-breakpoint-up(md) {
font-size: $gl-font-size-large;
}
@@ -292,12 +232,11 @@
}
}
-.nav > .project-repo-buttons {
+.nav > .project-buttons {
margin-top: 0;
}
-.project-repo-buttons,
-.group-buttons {
+.project-repo-buttons {
.btn {
&:last-child {
margin-left: 0;
@@ -318,8 +257,30 @@
margin-left: 0;
}
}
+
+ .notifications-icon {
+ top: 1px;
+ margin-right: 0;
+ }
+ }
+
+ .icon {
+ top: 0;
+ }
+
+ .count-badge,
+ .btn-xs {
+ height: 24px;
+ }
+
+ .dropdown-toggle,
+ .clone-dropdown-btn {
+ .fa {
+ color: unset;
+ }
}
+ .home-panel-action-button,
.project-action-button {
margin: $gl-padding $gl-padding-8 0 0;
vertical-align: top;
@@ -385,31 +346,6 @@
}
}
-.project-repo-buttons {
- .icon {
- top: 0;
- }
-
- .count-badge,
- .btn-xs {
- height: 24px;
- }
-
- .dropdown-toggle,
- .clone-dropdown-btn {
- .fa {
- color: unset;
- }
- }
-
- .btn {
- .notifications-icon {
- top: 1px;
- margin-right: 0;
- }
- }
-}
-
.split-one {
display: inline-table;
margin-right: 12px;
@@ -459,7 +395,7 @@
margin-right: $gl-padding-4;
margin-bottom: $gl-padding-4;
color: $gl-text-color-secondary;
- background-color: $theme-gray-100;
+ background-color: $gray-100;
line-height: $gl-btn-line-height;
&:hover {
@@ -571,12 +507,6 @@
}
.template-option {
- .logo {
- .btn-template-icon {
- width: 40px !important;
- }
- }
-
padding: 16px 0;
&:not(:first-child) {
@@ -615,9 +545,8 @@
}
.selected-icon {
- svg {
+ img {
display: none;
- top: 7px;
height: 20px;
width: 20px;
}
@@ -642,9 +571,7 @@
.import-buttons {
padding-left: 0;
- display: -webkit-flex;
display: flex;
- -webkit-flex-wrap: wrap;
flex-wrap: wrap;
.btn {
@@ -766,20 +693,13 @@
}
}
-.project-empty-note-panel {
- border-bottom: 1px solid $border-color;
-}
-
.project-stats,
.project-buttons {
- font-size: 0;
- text-align: center;
-
.scrolling-tabs-container {
.scrolling-tabs {
margin-top: $gl-padding-8;
- margin-bottom: $gl-padding-8 - $browserScrollbarSize;
- padding-bottom: $browserScrollbarSize;
+ margin-bottom: $gl-padding-8 - $browser-scrollbar-size;
+ padding-bottom: $browser-scrollbar-size;
flex-wrap: wrap;
border-bottom: 0;
}
@@ -787,7 +707,7 @@
.fade-left,
.fade-right {
top: 0;
- height: calc(100% - #{$browserScrollbarSize});
+ height: calc(100% - #{$browser-scrollbar-size});
.fa {
top: 50%;
@@ -914,7 +834,7 @@
}
.repository-language-bar-tooltip-share {
- color: $theme-gray-400;
+ color: $gray-400;
}
pre.light-well {
@@ -973,7 +893,7 @@ pre.light-well {
padding: $gl-padding 0;
@include media-breakpoint-up(lg) {
- padding: $gl-padding-24 0;
+ padding: $gl-padding 0;
}
&.no-description {
@@ -990,7 +910,7 @@ pre.light-well {
}
h2 {
- font-size: $gl-font-size-medium;
+ font-size: $gl-font-size-large;
font-weight: $gl-font-weight-bold;
margin-bottom: 0;
@@ -1013,6 +933,11 @@ pre.light-well {
.flex-wrapper {
min-width: 0;
margin-top: -$gl-padding-8; // negative margin required for flex-wrap
+ flex: 1 1 100%;
+
+ .project-title {
+ line-height: 20px;
+ }
}
p,
@@ -1025,8 +950,10 @@ pre.light-well {
margin: 0;
}
- @include media-breakpoint-up(md) {
- .description {
+ .description {
+ line-height: 1.5;
+
+ @include media-breakpoint-up(md) {
color: $gl-text-color;
}
}
@@ -1049,14 +976,16 @@ pre.light-well {
}
.controls {
- margin-top: $gl-padding;
+ @include media-breakpoint-down(xs) {
+ margin-top: $gl-padding-8;
+ }
- @include media-breakpoint-down(md) {
+ @include media-breakpoint-up(sm) {
margin-top: 0;
}
- @include media-breakpoint-down(xs) {
- margin-top: $gl-padding-8;
+ @include media-breakpoint-up(lg) {
+ flex: 1 1 40%;
}
.icon-wrapper {
@@ -1106,7 +1035,7 @@ pre.light-well {
min-height: 40px;
min-width: 40px;
- .identicon.s64 {
+ .identicon.s48 {
font-size: 16px;
}
}
@@ -1233,6 +1162,8 @@ pre.light-well {
.cannot-be-merged:hover {
color: $red-500;
margin-top: 2px;
+ position: relative;
+ z-index: 2;
}
.private-forks-notice .private-fork-icon {
@@ -1515,3 +1446,86 @@ pre.light-well {
}
}
}
+
+.project-filters {
+ .btn svg {
+ color: $gl-gray-700;
+ }
+
+ .button-filter-group {
+ .btn {
+ width: 96px;
+ }
+
+ a {
+ color: $black;
+ }
+
+ .active {
+ background: $btn-active-gray;
+ }
+ }
+
+ .filtered-search-dropdown-label {
+ min-width: 68px;
+
+ @include media-breakpoint-down(xs) {
+ min-width: 60px;
+ }
+ }
+
+ .filtered-search {
+ min-width: 30%;
+ flex-basis: 0;
+
+ .project-filter-form .project-filter-form-field {
+ padding-right: $gl-padding-8;
+ }
+
+ .filtered-search,
+ .filtered-search-nav,
+ .filtered-search-dropdown {
+ flex-basis: 0;
+ }
+
+ @include media-breakpoint-down(lg) {
+ min-width: 15%;
+
+ .project-filter-form-field {
+ min-width: 150px;
+ }
+ }
+
+ @include media-breakpoint-down(md) {
+ min-width: 30%;
+ }
+ }
+
+ .filtered-search-box {
+ border-radius: 3px 0 0 3px;
+ }
+
+ .dropdown-menu-toggle {
+ margin-left: $gl-padding-8;
+ }
+
+ @include media-breakpoint-down(md) {
+ .extended-filtered-search-box {
+ min-width: 55%;
+ }
+
+ .filtered-search-dropdown {
+ width: 50%;
+
+ .dropdown-menu-toggle {
+ width: 100%;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(xs) {
+ .filtered-search-dropdown {
+ width: 100%;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss
new file mode 100644
index 00000000000..c03554b287f
--- /dev/null
+++ b/app/assets/stylesheets/pages/prometheus.scss
@@ -0,0 +1,270 @@
+.prometheus-graphs {
+ .dropdowns {
+ .dropdown-menu-toggle {
+ svg {
+ position: absolute;
+ right: 5%;
+ top: 25%;
+ }
+ }
+
+ .dropdown-menu-toggle,
+ .dropdown-menu {
+ width: 240px;
+ }
+ }
+}
+
+.prometheus-panel {
+ margin-top: 20px;
+}
+
+.prometheus-graph-group {
+ display: flex;
+ flex-wrap: wrap;
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph {
+ padding: $gl-padding / 2;
+}
+
+.prometheus-graph-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: $gl-padding-8;
+
+ h5 {
+ font-size: $gl-font-size-large;
+ margin: 0;
+ }
+}
+
+.prometheus-graph-cursor {
+ position: absolute;
+ background: $gray-600;
+ width: 1px;
+}
+
+.prometheus-graph-flag {
+ display: block;
+ min-width: 160px;
+ border: 0;
+ box-shadow: 0 1px 4px 0 $black-transparent;
+
+ h5 {
+ padding: 0;
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.2;
+ }
+
+ .deploy-meta-content {
+ border-bottom: 1px solid $white-dark;
+
+ svg {
+ height: 15px;
+ vertical-align: bottom;
+ }
+ }
+
+ &.popover {
+ padding: 0;
+
+ &.left {
+ left: auto;
+ right: 0;
+ margin-right: 10px;
+
+ > .arrow {
+ right: -14px;
+ border-left-color: $border-color;
+ }
+
+ > .arrow::after {
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-left: 4px solid $gray-50;
+ }
+
+ .arrow-shadow {
+ right: -3px;
+ box-shadow: 1px 0 9px 0 $black-transparent;
+ }
+ }
+
+ &.right {
+ left: 0;
+ right: auto;
+ margin-left: 10px;
+
+ > .arrow {
+ left: -7px;
+ border-right-color: $border-color;
+ }
+
+ > .arrow::after {
+ border-top: 6px solid transparent;
+ border-bottom: 6px solid transparent;
+ border-right: 4px solid $gray-50;
+ }
+
+ .arrow-shadow {
+ left: -3px;
+ box-shadow: 1px 0 8px 0 $black-transparent;
+ }
+ }
+
+ > .arrow {
+ top: 10px;
+ margin: 0;
+ }
+
+ .arrow-shadow {
+ content: '';
+ position: absolute;
+ width: 7px;
+ height: 7px;
+ background-color: transparent;
+ transform: rotate(45deg);
+ top: 13px;
+ }
+
+ > .popover-title,
+ > .popover-content,
+ > .popover-header,
+ > .popover-body {
+ padding: 8px;
+ font-size: 12px;
+ white-space: nowrap;
+ position: relative;
+ }
+
+ > .popover-title {
+ background-color: $gray-50;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+ }
+ }
+
+ strong {
+ font-weight: 600;
+ }
+}
+
+.prometheus-table {
+ border-collapse: collapse;
+ padding: 0;
+ margin: 0;
+
+ td {
+ vertical-align: middle;
+
+ + td {
+ padding-left: 8px;
+ vertical-align: top;
+ }
+ }
+
+ .legend-metric-title {
+ font-size: 12px;
+ vertical-align: middle;
+ }
+}
+
+.prometheus-svg-container {
+ position: relative;
+ height: 0;
+ width: 100%;
+ padding: 0;
+ padding-bottom: 100%;
+
+ .text-metric-usage {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 12px;
+ }
+
+ > svg {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ left: 0;
+ top: 0;
+
+ text {
+ fill: $gl-text-color;
+ stroke-width: 0;
+ }
+
+ .text-metric-bold {
+ font-weight: $gl-font-weight-bold;
+ }
+
+ .label-axis-text {
+ fill: $black;
+ font-weight: $gl-font-weight-normal;
+ font-size: 10px;
+ }
+
+ .legend-axis-text {
+ fill: $black;
+ }
+
+ .tick {
+ > line {
+ stroke: $gray-darker;
+ }
+
+ > text {
+ fill: $gray-600;
+ font-size: 10px;
+ }
+ }
+
+ .y-label-text,
+ .x-label-text {
+ fill: $gray-darkest;
+ }
+
+ .axis-tick {
+ stroke: $gray-darker;
+ }
+
+ .deploy-info-text {
+ dominant-baseline: text-before-edge;
+ font-size: 12px;
+ }
+
+ .deploy-info-text-link {
+ font-family: $monospace-font;
+ fill: $blue-600;
+
+ &:hover {
+ fill: $blue-800;
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ .label-axis-text,
+ .text-metric-usage,
+ .legend-axis-text {
+ font-size: 8px;
+ }
+
+ .tick > text {
+ font-size: 8px;
+ }
+ }
+ }
+}
+
+.prometheus-table-row-highlight {
+ background-color: $gray-100;
+}
+
+.prometheus-graph-overlay {
+ fill: none;
+ opacity: 0;
+ pointer-events: all;
+}
diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss
index ecd51aa06a4..94da72622af 100644
--- a/app/assets/stylesheets/pages/reports.scss
+++ b/app/assets/stylesheets/pages/reports.scss
@@ -52,11 +52,6 @@
.report-block-list-icon .loading-container {
position: relative;
left: -2px;
- // needed to make the next element align with the
- // elements below that have a svg with 16px width
- .fa-spinner {
- width: 16px;
- }
}
}
@@ -96,7 +91,7 @@
}
&.neutral svg {
- color: $theme-gray-700;
+ color: $gray-700;
}
.ci-status-icon {
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 149c3254d84..dbf600df9d6 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -75,6 +75,8 @@ input[type='checkbox']:hover {
}
.search-input-wrap {
+ width: 100%;
+
.search-icon,
.clear-icon {
position: absolute;
@@ -87,6 +89,7 @@ input[type='checkbox']:hover {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
+ user-select: none;
}
.clear-icon {
@@ -185,13 +188,11 @@ input[type='checkbox']:hover {
.search-holder {
@include media-breakpoint-up(sm) {
- display: -webkit-flex;
display: flex;
}
.search-field-holder,
.project-filter-form {
- -webkit-flex: 1 0 auto;
flex: 1 0 auto;
position: relative;
margin-right: 0;
@@ -260,3 +261,13 @@ input[type='checkbox']:hover {
color: $blue-600;
}
}
+
+// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
+/* stylelint-disable property-no-vendor-prefix */
+input[type='search']::-webkit-search-decoration,
+input[type='search']::-webkit-search-cancel-button,
+input[type='search']::-webkit-search-results-button,
+input[type='search']::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+}
+/* stylelint-enable */
diff --git a/app/assets/stylesheets/pages/serverless.scss b/app/assets/stylesheets/pages/serverless.scss
new file mode 100644
index 00000000000..a5b73492380
--- /dev/null
+++ b/app/assets/stylesheets/pages/serverless.scss
@@ -0,0 +1,3 @@
+.url-text-field {
+ cursor: text;
+}
diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss
index ccfa4e00a5b..0a9c56f5625 100644
--- a/app/assets/stylesheets/pages/settings.scss
+++ b/app/assets/stylesheets/pages/settings.scss
@@ -23,7 +23,10 @@
}
.settings {
- border-bottom: 1px solid $gray-darker;
+ // border-top for each item except the top one
+ + .settings {
+ border-top: 1px solid $border-color;
+ }
&:first-of-type {
margin-top: 10px;
@@ -36,7 +39,7 @@
.settings-header {
position: relative;
- padding: 20px 110px 10px 0;
+ padding: 20px 110px 0 0;
h4 {
margin-top: 0;
@@ -167,12 +170,14 @@
font-weight: $gl-font-weight-normal;
display: inline-block;
color: $gl-text-color;
+ vertical-align: top;
}
.option-description,
.option-disabled-reason {
margin-left: 30px;
color: $project-option-descr-color;
+ margin-top: -5px;
}
.option-disabled-reason {
@@ -211,6 +216,31 @@
}
}
+.nested-settings {
+ padding-left: 20px;
+}
+
+.input-btn-group {
+ display: flex;
+
+ .input-large {
+ flex: 1;
+ }
+
+ .btn {
+ margin-left: 10px;
+ }
+}
+
+.content-list > .settings-flex-row {
+ display: flex;
+ align-items: center;
+
+ .float-right {
+ margin-left: auto;
+ }
+}
+
.prometheus-metrics-monitoring {
.card {
.card-toggle {
@@ -241,6 +271,27 @@
}
}
+ .custom-monitored-metrics {
+ .card-title {
+ display: flex;
+ align-items: center;
+
+ > .btn-success {
+ margin-left: auto;
+ }
+ }
+
+ .custom-metric {
+ display: flex;
+ align-items: center;
+ }
+
+ .custom-metric-link-bold {
+ font-weight: $gl-font-weight-bold;
+ text-decoration: none;
+ }
+ }
+
.loading-metrics,
.empty-metrics {
padding: 30px 10px;
@@ -275,6 +326,12 @@
}
}
+.saml-settings.info-well {
+ .form-control[readonly] {
+ background: $white-light;
+ }
+}
+
.modal-doorkeepr-auth {
.modal-body {
padding: $gl-padding;
@@ -297,7 +354,7 @@
.btn-clipboard {
background-color: $white-light;
- border: 1px solid $theme-gray-200;
+ border: 1px solid $gray-200;
}
.deploy-token-help-block {
@@ -314,8 +371,4 @@
.push-pull-table {
margin-top: 1em;
-
- .mirror-action-buttons {
- padding-right: 0;
- }
}
diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss
index d331edaa302..31ccdacbc02 100644
--- a/app/assets/stylesheets/pages/stat_graph.scss
+++ b/app/assets/stylesheets/pages/stat_graph.scss
@@ -18,21 +18,15 @@
@include make-col-ready();
@include make-col(12);
}
-
- svg {
- width: 100%;
- }
}
#contributors {
+ flex: 1;
+
.contributors-list {
margin: 0 0 10px;
list-style: none;
padding: 0;
-
- svg {
- width: 100%;
- }
}
.person {
diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 7d59dd3b5d1..613f643af3a 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -33,17 +33,18 @@
border-color: $gl-text-color;
&:not(span):hover {
- background-color: rgba($gl-text-color, .07);
+ background-color: rgba($gl-text-color, 0.07);
}
}
&.ci-pending,
- &.ci-failed_with_warnings,
- &.ci-success_with_warnings {
+ &.ci-failed-with-warnings,
+ &.ci-success-with-warnings {
@include status-color($orange-100, $orange-500, $orange-700);
}
&.ci-info,
+ &.ci-preparing,
&.ci-running {
@include status-color($blue-100, $blue-500, $blue-600);
}
@@ -54,7 +55,7 @@
border-color: $gl-text-color-secondary;
&:not(span):hover {
- background-color: rgba($gl-text-color-secondary, .07);
+ background-color: rgba($gl-text-color-secondary, 0.07);
}
}
}
diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss
index 3fc37e20c36..586365eb1ce 100644
--- a/app/assets/stylesheets/pages/todos.scss
+++ b/app/assets/stylesheets/pages/todos.scss
@@ -6,9 +6,7 @@
.todos-list > .todo {
// workaround because we cannot use border-colapse
border-top: 1px solid transparent;
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: row;
flex-direction: row;
&:hover {
@@ -29,23 +27,18 @@
.todo-avatar,
.todo-actions {
@include transition(opacity);
- -webkit-flex: 0 0 auto;
flex: 0 0 auto;
}
.todo-actions {
- display: -webkit-flex;
display: flex;
- -webkit-justify-content: center;
justify-content: center;
- -webkit-flex-direction: column;
flex-direction: column;
margin-left: 10px;
min-width: 55px;
}
.todo-item {
- -webkit-flex: 0 1 100%;
flex: 0 1 100%;
min-width: 0;
}
@@ -60,13 +53,13 @@
.todo-avatar,
.todo-item {
- opacity: .6;
+ opacity: 0.6;
}
}
.todo-avatar,
.todo-item {
- opacity: .2;
+ opacity: 0.2;
}
.btn {
@@ -82,7 +75,6 @@
display: flex;
> .title-item {
- -webkit-flex: 0 0 auto;
flex: 0 0 auto;
margin: 0 2px;
@@ -96,7 +88,6 @@
}
.todo-label {
- -webkit-flex: 0 1 auto;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
@@ -119,45 +110,38 @@
}
.todo-body {
- .todo-note {
- word-wrap: break-word;
-
- .md {
- color: $gl-grayish-blue;
- font-size: $gl-font-size;
-
- .badge.badge-pill {
- color: $gl-text-color;
- }
+ .badge.badge-pill,
+ p {
+ color: $gl-text-color;
+ }
- p {
- color: $gl-text-color;
- }
- }
+ .md {
+ color: $gl-grayish-blue;
+ font-size: $gl-font-size;
+ }
- code {
- white-space: pre-wrap;
- }
+ code {
+ white-space: pre-wrap;
+ }
- pre {
- border: 0;
- background: $gray-light;
- border-radius: 0;
- color: $gl-gray-500;
- margin: 0 20px;
- overflow: hidden;
- }
+ pre {
+ border: 0;
+ background: $gray-light;
+ border-radius: 0;
+ color: $gl-gray-500;
+ margin: 0 20px;
+ overflow: hidden;
+ }
- .note-image-attach {
- margin-top: 4px;
- margin-left: 0;
- max-width: 200px;
- float: none;
- }
+ .note-image-attach {
+ margin-top: 4px;
+ margin-left: 0;
+ max-width: 200px;
+ float: none;
+ }
- p:last-child {
- margin-bottom: 0;
- }
+ p:last-child {
+ margin-bottom: 0;
}
}
}
@@ -222,23 +206,19 @@
}
.todos-empty {
- display: -webkit-flex;
display: flex;
- -webkit-flex-direction: column;
flex-direction: column;
max-width: 900px;
margin-left: auto;
margin-right: auto;
@include media-breakpoint-up(sm) {
- -webkit-flex-direction: row;
flex-direction: row;
padding-top: 80px;
}
}
.todos-empty-content {
- -webkit-align-self: center;
align-self: center;
max-width: 480px;
margin-right: 20px;
@@ -252,7 +232,6 @@
@include media-breakpoint-up(sm) {
width: 300px;
margin-right: 0;
- -webkit-order: 2;
order: 2;
}
}
diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss
index a46b8679a42..5664f46484e 100644
--- a/app/assets/stylesheets/pages/tree.scss
+++ b/app/assets/stylesheets/pages/tree.scss
@@ -172,26 +172,6 @@
text-decoration: inherit;
}
}
-
- .tree_commit {
- max-width: 320px;
-
- .str-truncated {
- max-width: 100%;
- }
- }
-
- .tree_time_ago {
- min-width: 135px;
- }
- }
-
- .tree_author {
- padding-right: 8px;
-
- .commit-author-name {
- color: $gl-text-color;
- }
}
.tree-truncated-warning {
diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss
index 84c617c7ec0..7744fd814d0 100644
--- a/app/assets/stylesheets/pages/ui_dev_kit.scss
+++ b/app/assets/stylesheets/pages/ui_dev_kit.scss
@@ -10,7 +10,7 @@
margin-bottom: 15px;
&::before {
- content: "Example";
+ content: 'Example';
color: $ui-dev-kit-example-color;
}
}
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 82e887aa62a..3260aed143e 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -179,9 +179,3 @@ ul.wiki-pages-list.content-list {
}
}
}
-
-.wiki:not(.use-csslab) {
- table {
- @include markdown-table;
- }
-}
diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss
index 9c01a2f8bda..5a8940ffd6d 100644
--- a/app/assets/stylesheets/performance_bar.scss
+++ b/app/assets/stylesheets/performance_bar.scss
@@ -79,8 +79,12 @@
table {
color: $black;
- strong {
- color: $black;
+ td {
+ vertical-align: top;
+ }
+
+ .backtrace-row {
+ display: none;
}
}
diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss
index bb10928a037..9ed1600419d 100644
--- a/app/assets/stylesheets/print.scss
+++ b/app/assets/stylesheets/print.scss
@@ -1,21 +1,21 @@
-.wiki h1,
-.wiki h2,
-.wiki h3,
-.wiki h4,
-.wiki h5,
-.wiki h6 {
+.md h1,
+.md h2,
+.md h3,
+.md h4,
+.md h5,
+.md h6 {
margin-top: 17px;
}
-.wiki h1 {
+.md h1 {
font-size: 30px;
}
-.wiki h2 {
+.md h2 {
font-size: 22px;
}
-.wiki h3 {
+.md h3 {
font-size: 18px;
font-weight: 600;
}
diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss
new file mode 100644
index 00000000000..3648ec5e239
--- /dev/null
+++ b/app/assets/stylesheets/utilities.scss
@@ -0,0 +1,17 @@
+@each $variant, $range in $color-ranges {
+ @each $suffix, $color in $range {
+ #{'.bg-#{$variant}-#{$suffix}'} {
+ background-color: $color;
+ }
+
+ #{'.text-#{$variant}-#{$suffix}'} {
+ color: $color;
+ }
+ }
+}
+
+@each $index, $size in $type-scale {
+ #{'.text-#{$index}'} {
+ font-size: $size;
+ }
+}
diff --git a/app/assets/stylesheets/vendors/atwho.scss b/app/assets/stylesheets/vendors/atwho.scss
new file mode 100644
index 00000000000..ccf3824ea56
--- /dev/null
+++ b/app/assets/stylesheets/vendors/atwho.scss
@@ -0,0 +1,92 @@
+.atwho-view {
+ overflow-y: auto;
+ overflow-x: hidden;
+
+ .name,
+ small.aliases,
+ small.params {
+ float: left;
+ }
+
+ small.aliases,
+ small.params {
+ padding: 2px 5px;
+ }
+
+ small.description {
+ float: right;
+ padding: 3px 5px;
+ }
+
+ .avatar-inline {
+ margin-bottom: 0;
+ }
+
+ .has-warning {
+ .name,
+ .description {
+ color: $orange-700;
+ }
+ }
+
+ .cur {
+ .avatar {
+ @include disable-all-animation;
+ border: 1px solid $white-light;
+ }
+ }
+
+ ul > li {
+ @include clearfix;
+ white-space: nowrap;
+ }
+
+ // TODO: fallback to global style
+ .atwho-view-ul {
+ padding: 8px 1px;
+
+ li {
+ padding: 8px 16px;
+ border: 0;
+
+ &.cur {
+ background-color: $gray-darker;
+ color: $gl-text-color;
+
+ small {
+ color: inherit;
+ }
+
+ &.has-warning {
+ color: $orange-700;
+ background-color: $orange-100;
+ }
+ }
+
+ div.avatar {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+
+ .center {
+ line-height: 14px;
+ }
+ }
+
+ strong {
+ color: $gl-text-color;
+ }
+ }
+ }
+}
+
+@include media-breakpoint-down(xs) {
+ .atwho-view-ul {
+ width: 350px;
+ }
+
+ .atwho-view ul li {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+}
diff --git a/app/controllers/abuse_reports_controller.rb b/app/controllers/abuse_reports_controller.rb
index 68e14f0c2e5..7d8016f763d 100644
--- a/app/controllers/abuse_reports_controller.rb
+++ b/app/controllers/abuse_reports_controller.rb
@@ -16,7 +16,7 @@ class AbuseReportsController < ApplicationController
if @abuse_report.save
@abuse_report.notify
- message = "Thank you for your report. A GitLab administrator will look into it shortly."
+ message = _("Thank you for your report. A GitLab administrator will look into it shortly.")
redirect_to @abuse_report.user, notice: message
else
render :new
@@ -37,9 +37,9 @@ class AbuseReportsController < ApplicationController
@user = User.find_by(id: params[:user_id])
if @user.nil?
- redirect_to root_path, alert: "Cannot create the abuse report. The user has been deleted."
+ redirect_to root_path, alert: _("Cannot create the abuse report. The user has been deleted.")
elsif @user.blocked?
- redirect_to @user, alert: "Cannot create the abuse report. This user has been blocked."
+ redirect_to @user, alert: _("Cannot create the abuse report. This user has been blocked.")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/admin/appearances_controller.rb b/app/controllers/admin/appearances_controller.rb
index e3226c86b0b..383ec2a7d16 100644
--- a/app/controllers/admin/appearances_controller.rb
+++ b/app/controllers/admin/appearances_controller.rb
@@ -14,7 +14,7 @@ class Admin::AppearancesController < Admin::ApplicationController
@appearance = Appearance.new(appearance_params)
if @appearance.save
- redirect_to admin_appearances_path, notice: 'Appearance was successfully created.'
+ redirect_to admin_appearances_path, notice: _('Appearance was successfully created.')
else
render action: 'show'
end
@@ -22,7 +22,7 @@ class Admin::AppearancesController < Admin::ApplicationController
def update
if @appearance.update(appearance_params)
- redirect_to admin_appearances_path, notice: 'Appearance was successfully updated.'
+ redirect_to admin_appearances_path, notice: _('Appearance was successfully updated.')
else
render action: 'show'
end
@@ -33,21 +33,21 @@ class Admin::AppearancesController < Admin::ApplicationController
@appearance.save
- redirect_to admin_appearances_path, notice: 'Logo was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Logo was successfully removed.')
end
def header_logos
@appearance.remove_header_logo!
@appearance.save
- redirect_to admin_appearances_path, notice: 'Header logo was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Header logo was successfully removed.')
end
def favicon
@appearance.remove_favicon!
@appearance.save
- redirect_to admin_appearances_path, notice: 'Favicon was successfully removed.'
+ redirect_to admin_appearances_path, notice: _('Favicon was successfully removed.')
end
private
@@ -74,6 +74,11 @@ class Admin::AppearancesController < Admin::ApplicationController
favicon_cache
new_project_guidelines
updated_by
+ header_message
+ footer_message
+ message_background_color
+ message_font_color
+ email_header_and_footer_enabled
]
end
end
diff --git a/app/controllers/admin/application_controller.rb b/app/controllers/admin/application_controller.rb
index ef182b981f1..b742b7e19cf 100644
--- a/app/controllers/admin/application_controller.rb
+++ b/app/controllers/admin/application_controller.rb
@@ -4,10 +4,7 @@
#
# Automatically sets the layout and ensures an administrator is logged in
class Admin::ApplicationController < ApplicationController
- before_action :authenticate_admin!
- layout 'admin'
+ include EnforcesAdminAuthentication
- def authenticate_admin!
- render_404 unless current_user.admin?
- end
+ layout 'admin'
end
diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb
index 8f683ca06ad..d5bc723aa8c 100644
--- a/app/controllers/admin/application_settings_controller.rb
+++ b/app/controllers/admin/application_settings_controller.rb
@@ -48,7 +48,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
respond_to do |format|
if successful
format.json { head :ok }
- format.html { redirect_to redirect_path, notice: 'Application settings saved successfully' }
+ format.html { redirect_to redirect_path, notice: _('Application settings saved successfully') }
else
format.json { head :bad_request }
format.html { render :show }
@@ -70,14 +70,14 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
def reset_registration_token
@application_setting.reset_runners_registration_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('New runners registration token has been generated!')
redirect_to admin_runners_path
end
def reset_health_check_token
@application_setting.reset_health_check_access_token!
- flash[:notice] = 'New health check access token has been generated!'
- redirect_to :back
+ flash[:notice] = _('New health check access token has been generated!')
+ redirect_back_or_default
end
def clear_repository_check_states
@@ -85,10 +85,17 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
redirect_to(
admin_application_settings_path,
- notice: 'Started asynchronous removal of all repository check states.'
+ notice: _('Started asynchronous removal of all repository check states.')
)
end
+ # Getting ToS url requires `directory` api call to Let's Encrypt
+ # which could result in 500 error/slow rendering on settings page
+ # Because of that we use separate controller action
+ def lets_encrypt_terms_of_service
+ redirect_to ::Gitlab::LetsEncrypt.terms_of_service_url
+ end
+
private
def set_application_setting
@@ -124,7 +131,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
end
def visible_application_setting_attributes
- ApplicationSettingsHelper.visible_attributes + [
+ [
+ *::ApplicationSettingsHelper.visible_attributes,
+ *::ApplicationSettingsHelper.external_authorization_service_attributes,
+ *lets_encrypt_visible_attributes,
:domain_blacklist_file,
disabled_oauth_sign_in_sources: [],
import_sources: [],
@@ -132,4 +142,13 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
restricted_visibility_levels: []
]
end
+
+ def lets_encrypt_visible_attributes
+ return [] unless Feature.enabled?(:pages_auto_ssl)
+
+ [
+ :lets_encrypt_notification_email,
+ :lets_encrypt_terms_of_service_accepted
+ ]
+ end
end
diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb
index 6fc336714b6..3648c8be426 100644
--- a/app/controllers/admin/applications_controller.rb
+++ b/app/controllers/admin/applications_controller.rb
@@ -34,7 +34,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def update
if @application.update(application_params)
- redirect_to admin_application_path(@application), notice: 'Application was successfully updated.'
+ redirect_to admin_application_path(@application), notice: _('Application was successfully updated.')
else
render :edit
end
@@ -42,7 +42,7 @@ class Admin::ApplicationsController < Admin::ApplicationController
def destroy
@application.destroy
- redirect_to admin_applications_url, status: 302, notice: 'Application was successfully destroyed.'
+ redirect_to admin_applications_url, status: 302, notice: _('Application was successfully destroyed.')
end
private
diff --git a/app/controllers/admin/broadcast_messages_controller.rb b/app/controllers/admin/broadcast_messages_controller.rb
index a91d9a534cd..6e5dd1a1f55 100644
--- a/app/controllers/admin/broadcast_messages_controller.rb
+++ b/app/controllers/admin/broadcast_messages_controller.rb
@@ -19,7 +19,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
@broadcast_message = BroadcastMessage.new(broadcast_message_params)
if @broadcast_message.save
- redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully created.'
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully created.')
else
render :index
end
@@ -27,7 +27,7 @@ class Admin::BroadcastMessagesController < Admin::ApplicationController
def update
if @broadcast_message.update(broadcast_message_params)
- redirect_to admin_broadcast_messages_path, notice: 'Broadcast Message was successfully updated.'
+ redirect_to admin_broadcast_messages_path, notice: _('Broadcast Message was successfully updated.')
else
render :edit
end
diff --git a/app/controllers/admin/clusters/applications_controller.rb b/app/controllers/admin/clusters/applications_controller.rb
new file mode 100644
index 00000000000..7400cc16175
--- /dev/null
+++ b/app/controllers/admin/clusters/applications_controller.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class Admin::Clusters::ApplicationsController < Clusters::ApplicationsController
+ include EnforcesAdminAuthentication
+
+ private
+
+ def clusterable
+ @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
+ end
+end
diff --git a/app/controllers/admin/clusters_controller.rb b/app/controllers/admin/clusters_controller.rb
new file mode 100644
index 00000000000..f54933de10f
--- /dev/null
+++ b/app/controllers/admin/clusters_controller.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class Admin::ClustersController < Clusters::ClustersController
+ include EnforcesAdminAuthentication
+
+ layout 'admin'
+
+ private
+
+ def clusterable
+ @clusterable ||= InstanceClusterablePresenter.fabricate(Clusters::Instance.new, current_user: current_user)
+ end
+end
diff --git a/app/controllers/admin/deploy_keys_controller.rb b/app/controllers/admin/deploy_keys_controller.rb
index 49ce275ad14..180f7d4c803 100644
--- a/app/controllers/admin/deploy_keys_controller.rb
+++ b/app/controllers/admin/deploy_keys_controller.rb
@@ -25,7 +25,7 @@ class Admin::DeployKeysController < Admin::ApplicationController
def update
if deploy_key.update(update_params)
- flash[:notice] = 'Deploy key was successfully updated.'
+ flash[:notice] = _('Deploy key was successfully updated.')
redirect_to admin_deploy_keys_path
else
render 'edit'
diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb
index 46e85e1424f..15f7ef881c8 100644
--- a/app/controllers/admin/groups_controller.rb
+++ b/app/controllers/admin/groups_controller.rb
@@ -36,7 +36,7 @@ class Admin::GroupsController < Admin::ApplicationController
if @group.save
@group.add_owner(current_user)
- redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created."
+ redirect_to [:admin, @group], notice: _('Group %{group_name} was successfully created.') % { group_name: @group.name }
else
render "new"
end
@@ -44,7 +44,7 @@ class Admin::GroupsController < Admin::ApplicationController
def update
if @group.update(group_params)
- redirect_to [:admin, @group], notice: 'Group was successfully updated.'
+ redirect_to [:admin, @group], notice: _('Group was successfully updated.')
else
render "edit"
end
@@ -55,7 +55,7 @@ class Admin::GroupsController < Admin::ApplicationController
result = Members::CreateService.new(current_user, member_params.merge(limit: -1)).execute(@group)
if result[:status] == :success
- redirect_to [:admin, @group], notice: 'Users were successfully added.'
+ redirect_to [:admin, @group], notice: _('Users were successfully added.')
else
redirect_to [:admin, @group], alert: result[:message]
end
@@ -66,7 +66,7 @@ class Admin::GroupsController < Admin::ApplicationController
redirect_to admin_groups_path,
status: 302,
- alert: "Group '#{@group.name}' was scheduled for deletion."
+ alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name }
end
private
@@ -89,7 +89,8 @@ class Admin::GroupsController < Admin::ApplicationController
:request_access_enabled,
:visibility_level,
:require_two_factor_authentication,
- :two_factor_grace_period
+ :two_factor_grace_period,
+ :project_creation_level
]
end
end
diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb
index 25cc241e5b0..7cd80e8b5e1 100644
--- a/app/controllers/admin/health_check_controller.rb
+++ b/app/controllers/admin/health_check_controller.rb
@@ -2,6 +2,12 @@
class Admin::HealthCheckController < Admin::ApplicationController
def show
- @errors = HealthCheck::Utils.process_checks(['standard'])
+ @errors = HealthCheck::Utils.process_checks(checks)
+ end
+
+ private
+
+ def checks
+ ['standard']
end
end
diff --git a/app/controllers/admin/hooks_controller.rb b/app/controllers/admin/hooks_controller.rb
index d0abdec50ae..51b0f45c5be 100644
--- a/app/controllers/admin/hooks_controller.rb
+++ b/app/controllers/admin/hooks_controller.rb
@@ -14,7 +14,7 @@ class Admin::HooksController < Admin::ApplicationController
@hook = SystemHook.new(hook_params.to_h)
if @hook.save
- redirect_to admin_hooks_path, notice: 'Hook was successfully created.'
+ redirect_to admin_hooks_path, notice: _('Hook was successfully created.')
else
@hooks = SystemHook.all
render :index
@@ -26,7 +26,7 @@ class Admin::HooksController < Admin::ApplicationController
def update
if hook.update(hook_params)
- flash[:notice] = 'System hook was successfully updated.'
+ flash[:notice] = _('System hook was successfully updated.')
redirect_to admin_hooks_path
else
render 'edit'
diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb
index b51c2f678ca..f518f7a657f 100644
--- a/app/controllers/admin/identities_controller.rb
+++ b/app/controllers/admin/identities_controller.rb
@@ -13,7 +13,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
@identity.user_id = user.id
if @identity.save
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully created.'
+ redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully created.')
else
render :new
end
@@ -29,7 +29,7 @@ class Admin::IdentitiesController < Admin::ApplicationController
def update
if @identity.update(identity_params)
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), notice: 'User identity was successfully updated.'
+ redirect_to admin_user_identities_path(@user), notice: _('User identity was successfully updated.')
else
render :edit
end
@@ -38,9 +38,9 @@ class Admin::IdentitiesController < Admin::ApplicationController
def destroy
if @identity.destroy
RepairLdapBlockedUserService.new(@user).execute
- redirect_to admin_user_identities_path(@user), status: 302, notice: 'User identity was successfully removed.'
+ redirect_to admin_user_identities_path(@user), status: 302, notice: _('User identity was successfully removed.')
else
- redirect_to admin_user_identities_path(@user), status: 302, alert: 'Failed to remove user identity.'
+ redirect_to admin_user_identities_path(@user), status: 302, alert: _('Failed to remove user identity.')
end
end
diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb
index 706bcc1e549..c35619a944e 100644
--- a/app/controllers/admin/impersonation_tokens_controller.rb
+++ b/app/controllers/admin/impersonation_tokens_controller.rb
@@ -12,7 +12,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
if @impersonation_token.save
PersonalAccessToken.redis_store!(current_user.id, @impersonation_token.token)
- redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created."
+ redirect_to admin_user_impersonation_tokens_path, notice: _("A new impersonation token has been created.")
else
set_index_vars
render :index
@@ -23,9 +23,9 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
@impersonation_token = finder.find(params[:id])
if @impersonation_token.revoke!
- flash[:notice] = "Revoked impersonation token #{@impersonation_token.name}!"
+ flash[:notice] = _("Revoked impersonation token %{token_name}!") % { token_name: @impersonation_token.name }
else
- flash[:alert] = "Could not revoke impersonation token #{@impersonation_token.name}."
+ flash[:alert] = _("Could not revoke impersonation token %{token_name}.") % { token_name: @impersonation_token.name }
end
redirect_to admin_user_impersonation_tokens_path
@@ -49,7 +49,7 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def set_index_vars
- @scopes = Gitlab::Auth.available_scopes(current_user)
+ @scopes = Gitlab::Auth.available_scopes_for(current_user)
@impersonation_token ||= finder.build
@inactive_impersonation_tokens = finder(state: 'inactive').execute
diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb
index 4e9262ccc96..340eecd7632 100644
--- a/app/controllers/admin/keys_controller.rb
+++ b/app/controllers/admin/keys_controller.rb
@@ -17,9 +17,9 @@ class Admin::KeysController < Admin::ApplicationController
respond_to do |format|
if key.destroy
- format.html { redirect_to keys_admin_user_path(user), status: 302, notice: 'User key was successfully removed.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, notice: _('User key was successfully removed.') }
else
- format.html { redirect_to keys_admin_user_path(user), status: 302, alert: 'Failed to remove user key.' }
+ format.html { redirect_to keys_admin_user_path(user), status: 302, alert: _('Failed to remove user key.') }
end
end
end
diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb
index aa5eae7a474..90c1694fd2e 100644
--- a/app/controllers/admin/labels_controller.rb
+++ b/app/controllers/admin/labels_controller.rb
@@ -21,7 +21,7 @@ class Admin::LabelsController < Admin::ApplicationController
@label = Labels::CreateService.new(label_params).execute(template: true)
if @label.persisted?
- redirect_to admin_labels_url, notice: "Label was created"
+ redirect_to admin_labels_url, notice: _("Label was created")
else
render :new
end
@@ -31,7 +31,7 @@ class Admin::LabelsController < Admin::ApplicationController
@label = Labels::UpdateService.new(label_params).execute(@label)
if @label.valid?
- redirect_to admin_labels_path, notice: 'Label was successfully updated.'
+ redirect_to admin_labels_path, notice: _('Label was successfully updated.')
else
render :edit
end
@@ -43,7 +43,7 @@ class Admin::LabelsController < Admin::ApplicationController
respond_to do |format|
format.html do
- redirect_to admin_labels_path, status: 302, notice: 'Label was removed'
+ redirect_to admin_labels_path, status: 302, notice: _('Label was removed')
end
format.js
end
diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb
index 06b0e6a15a3..704e727b1da 100644
--- a/app/controllers/admin/logs_controller.rb
+++ b/app/controllers/admin/logs_controller.rb
@@ -15,7 +15,8 @@ class Admin::LogsController < Admin::ApplicationController
Gitlab::EnvironmentLogger,
Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger,
- Gitlab::ProjectServiceLogger
+ Gitlab::ProjectServiceLogger,
+ Gitlab::Kubernetes::Logger
]
end
end
diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb
index 550f29a58d2..aeff0c96b64 100644
--- a/app/controllers/admin/projects_controller.rb
+++ b/app/controllers/admin/projects_controller.rb
@@ -15,7 +15,7 @@ class Admin::ProjectsController < Admin::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("admin/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("admin/projects/_projects", projects: @projects)
}
end
end
@@ -40,7 +40,7 @@ class Admin::ProjectsController < Admin::ApplicationController
namespace = Namespace.find_by(id: params[:new_namespace_id])
::Projects::TransferService.new(@project, current_user, params.dup).execute(namespace)
- @project.reload
+ @project.reset
redirect_to admin_project_path(@project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -50,7 +50,7 @@ class Admin::ProjectsController < Admin::ApplicationController
redirect_to(
admin_project_path(@project),
- notice: 'Repository check was triggered.'
+ notice: _('Repository check was triggered.')
)
end
diff --git a/app/controllers/admin/requests_profiles_controller.rb b/app/controllers/admin/requests_profiles_controller.rb
index 57f7d3e3951..89d4c4f18d9 100644
--- a/app/controllers/admin/requests_profiles_controller.rb
+++ b/app/controllers/admin/requests_profiles_controller.rb
@@ -11,7 +11,7 @@ class Admin::RequestsProfilesController < Admin::ApplicationController
profile = Gitlab::RequestProfiler::Profile.find(clean_name)
if profile
- render html: profile.content
+ render html: profile.content.html_safe
else
redirect_to admin_requests_profiles_path, alert: 'Profile not found'
end
diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb
index 0b6ff491c66..783c59822f1 100644
--- a/app/controllers/admin/runners_controller.rb
+++ b/app/controllers/admin/runners_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class Admin::RunnersController < Admin::ApplicationController
- before_action :runner, except: :index
+ before_action :runner, except: [:index, :tag_list]
def index
finder = Admin::RunnersFinder.new(params: params)
@@ -34,20 +34,26 @@ class Admin::RunnersController < Admin::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
+ redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
- redirect_to admin_runners_path, alert: 'Runner was not updated.'
+ redirect_to admin_runners_path, alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to admin_runners_path, notice: 'Runner was successfully updated.'
+ redirect_to admin_runners_path, notice: _('Runner was successfully updated.')
else
- redirect_to admin_runners_path, alert: 'Runner was not updated.'
+ redirect_to admin_runners_path, alert: _('Runner was not updated.')
end
end
+ def tag_list
+ tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute
+
+ render json: ActsAsTaggableOn::TagSerializer.new.represent(tags)
+ end
+
private
def runner
diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb
index 18d22c95b61..45cf0d3207e 100644
--- a/app/controllers/admin/spam_logs_controller.rb
+++ b/app/controllers/admin/spam_logs_controller.rb
@@ -14,7 +14,7 @@ class Admin::SpamLogsController < Admin::ApplicationController
spam_log.remove_user(deleted_by: current_user)
redirect_to admin_spam_logs_path,
status: 302,
- notice: "User #{spam_log.user.username} was successfully removed."
+ notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username }
else
spam_log.destroy
head :ok
@@ -25,9 +25,9 @@ class Admin::SpamLogsController < Admin::ApplicationController
spam_log = SpamLog.find(params[:id])
if HamService.new(spam_log).mark_as_ham!
- redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.'
+ redirect_to admin_spam_logs_path, notice: _('Spam log successfully submitted as ham.')
else
- redirect_to admin_spam_logs_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to admin_spam_logs_path, alert: _('Error with Akismet. Please check the logs for more info.')
end
end
end
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
index e93be1c1ba2..a02d0843615 100644
--- a/app/controllers/admin/users_controller.rb
+++ b/app/controllers/admin/users_controller.rb
@@ -1,11 +1,13 @@
# frozen_string_literal: true
class Admin::UsersController < Admin::ApplicationController
+ include RoutableActions
+
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
def index
- @users = User.order_name_asc.filter(params[:filter])
+ @users = User.filter_items(params[:filter]).order_name_asc
@users = @users.search_with_secondary_emails(params[:search_query]) if params[:search_query].present?
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@@ -37,19 +39,19 @@ class Admin::UsersController < Admin::ApplicationController
warden.set_user(user, scope: :user)
- Gitlab::AppLogger.info("User #{current_user.username} has started impersonating #{user.username}")
+ Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
- flash[:alert] = "You are now impersonating #{user.username}"
+ flash[:alert] = _("You are now impersonating %{username}") % { username: user.username }
redirect_to root_path
else
flash[:alert] =
if user.blocked?
- "You cannot impersonate a blocked user"
+ _("You cannot impersonate a blocked user")
elsif user.internal?
- "You cannot impersonate an internal user"
+ _("You cannot impersonate an internal user")
else
- "You cannot impersonate a user who cannot log in"
+ _("You cannot impersonate a user who cannot log in")
end
redirect_to admin_user_path(user)
@@ -58,35 +60,35 @@ class Admin::UsersController < Admin::ApplicationController
def block
if update_user { |user| user.block }
- redirect_back_or_admin_user(notice: "Successfully blocked")
+ redirect_back_or_admin_user(notice: _("Successfully blocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not blocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not blocked"))
end
end
def unblock
if user.ldap_blocked?
- redirect_back_or_admin_user(alert: "This user cannot be unlocked manually from GitLab")
+ redirect_back_or_admin_user(alert: _("This user cannot be unlocked manually from GitLab"))
elsif update_user { |user| user.activate }
- redirect_back_or_admin_user(notice: "Successfully unblocked")
+ redirect_back_or_admin_user(notice: _("Successfully unblocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not unblocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not unblocked"))
end
end
def unlock
if update_user { |user| user.unlock_access! }
- redirect_back_or_admin_user(alert: "Successfully unlocked")
+ redirect_back_or_admin_user(alert: _("Successfully unlocked"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not unlocked")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not unlocked"))
end
end
def confirm
if update_user { |user| user.confirm }
- redirect_back_or_admin_user(notice: "Successfully confirmed")
+ redirect_back_or_admin_user(notice: _("Successfully confirmed"))
else
- redirect_back_or_admin_user(alert: "Error occurred. User was not confirmed")
+ redirect_back_or_admin_user(alert: _("Error occurred. User was not confirmed"))
end
end
@@ -94,7 +96,7 @@ class Admin::UsersController < Admin::ApplicationController
update_user { |user| user.disable_two_factor! }
redirect_to admin_user_path(user),
- notice: 'Two-factor Authentication has been disabled for this user'
+ notice: _('Two-factor Authentication has been disabled for this user')
end
def create
@@ -107,7 +109,7 @@ class Admin::UsersController < Admin::ApplicationController
respond_to do |format|
if @user.persisted?
- format.html { redirect_to [:admin, @user], notice: 'User was successfully created.' }
+ format.html { redirect_to [:admin, @user], notice: _('User was successfully created.') }
format.json { render json: @user, status: :created, location: @user }
else
format.html { render "new" }
@@ -136,7 +138,7 @@ class Admin::UsersController < Admin::ApplicationController
end
if result[:status] == :success
- format.html { redirect_to [:admin, user], notice: 'User was successfully updated.' }
+ format.html { redirect_to [:admin, user], notice: _('User was successfully updated.') }
format.json { head :ok }
else
# restore username to keep form action url.
@@ -151,7 +153,7 @@ class Admin::UsersController < Admin::ApplicationController
user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete))
respond_to do |format|
- format.html { redirect_to admin_users_path, status: 302, notice: "The user is being deleted." }
+ format.html { redirect_to admin_users_path, status: 302, notice: _("The user is being deleted.") }
format.json { head :ok }
end
end
@@ -162,11 +164,11 @@ class Admin::UsersController < Admin::ApplicationController
respond_to do |format|
if success
- format.html { redirect_back_or_admin_user(notice: 'Successfully removed email.') }
+ format.html { redirect_back_or_admin_user(notice: _('Successfully removed email.')) }
format.json { head :ok }
else
- format.html { redirect_back_or_admin_user(alert: 'There was an error removing the e-mail.') }
- format.json { render json: 'There was an error removing the e-mail.', status: :bad_request }
+ format.html { redirect_back_or_admin_user(alert: _('There was an error removing the e-mail.')) }
+ format.json { render json: _('There was an error removing the e-mail.'), status: :bad_request }
end
end
end
@@ -177,11 +179,13 @@ class Admin::UsersController < Admin::ApplicationController
user == current_user
end
- # rubocop: disable CodeReuse/ActiveRecord
def user
- @user ||= User.find_by!(username: params[:id])
+ @user ||= find_routable!(User, params[:id])
+ end
+
+ def build_canonical_path(user)
+ url_for(safe_params.merge(id: user.to_param))
end
- # rubocop: enable CodeReuse/ActiveRecord
def redirect_back_or_admin_user(options = {})
redirect_back_or_default(default: default_route, options: options)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 7c8c1392c1c..4cbab6811bc 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -12,9 +12,6 @@ class ApplicationController < ActionController::Base
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
include SessionlessAuthentication
- # this can be removed after switching to rails 5
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/51908
- include InvalidUTF8ErrorHandler unless Gitlab.rails5?
before_action :authenticate_user!
before_action :enforce_terms!, if: :should_enforce_terms?
@@ -30,6 +27,7 @@ class ApplicationController < ActionController::Base
before_action :check_impersonation_availability
around_action :set_locale
+ around_action :set_session_storage
after_action :set_page_title_header, if: :json_request?
after_action :limit_unauthenticated_session_times
@@ -46,7 +44,10 @@ class ApplicationController < ActionController::Base
:git_import_enabled?, :gitlab_project_import_enabled?,
:manifest_import_enabled?
+ # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
+ # concerns due to caching private data.
DEFAULT_GITLAB_CACHE_CONTROL = "#{ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL}, no-store".freeze
+ DEFAULT_GITLAB_CONTROL_NO_CACHE = "#{DEFAULT_GITLAB_CACHE_CONTROL}, no-cache".freeze
rescue_from Encoding::CompatibilityError do |exception|
log_exception(exception)
@@ -79,7 +80,7 @@ class ApplicationController < ActionController::Base
end
def redirect_back_or_default(default: root_path, options: {})
- redirect_to request.referer.present? ? :back : default, options
+ redirect_back(fallback_location: default, **options)
end
def not_found
@@ -128,7 +129,7 @@ class ApplicationController < ActionController::Base
payload[:ua] = request.env["HTTP_USER_AGENT"]
payload[:remote_ip] = request.remote_ip
- payload[Gitlab::CorrelationId::LOG_KEY] = Gitlab::CorrelationId.current_id
+ payload[Labkit::Correlation::CorrelationId::LOG_KEY] = Labkit::Correlation::CorrelationId.current_id
logged_user = auth_user
@@ -140,6 +141,8 @@ class ApplicationController < ActionController::Base
if response.status == 422 && response.body.present? && response.content_type == 'application/json'.freeze
payload[:response] = response.body
end
+
+ payload[:queue_duration] = request.env[::Gitlab::Middleware::RailsQueueDuration::GITLAB_RAILS_QUEUE_DURATION_KEY]
end
##
@@ -157,7 +160,7 @@ class ApplicationController < ActionController::Base
def log_exception(exception)
Gitlab::Sentry.track_acceptable_exception(exception)
- backtrace_cleaner = Gitlab.rails5? ? request.env["action_dispatch.backtrace_cleaner"] : env
+ backtrace_cleaner = request.env["action_dispatch.backtrace_cleaner"]
application_trace = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).application_trace
application_trace.map! { |t| " #{t}\n" }
logger.error "\n#{exception.class.name} (#{exception.message}):\n#{application_trace.join}"
@@ -180,11 +183,17 @@ class ApplicationController < ActionController::Base
# hide existence of the resource, rather tell them they cannot access it using
# the provided message
status ||= message.present? ? :forbidden : :not_found
+ template =
+ if status == :not_found
+ "errors/not_found"
+ else
+ "errors/access_denied"
+ end
respond_to do |format|
format.any { head status }
format.html do
- render "errors/access_denied",
+ render template,
layout: "errors",
status: status,
locals: { message: message }
@@ -230,9 +239,9 @@ class ApplicationController < ActionController::Base
end
def no_cache_headers
- response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate"
- response.headers["Pragma"] = "no-cache"
- response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT"
+ headers['Cache-Control'] = DEFAULT_GITLAB_CONTROL_NO_CACHE
+ headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
+ headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT'
end
def default_headers
@@ -242,10 +251,16 @@ class ApplicationController < ActionController::Base
headers['X-Content-Type-Options'] = 'nosniff'
if current_user
- # Adds `no-store` to the DEFAULT_CACHE_CONTROL, to prevent security
- # concerns due to caching private data.
- headers['Cache-Control'] = DEFAULT_GITLAB_CACHE_CONTROL
- headers["Pragma"] = "no-cache" # HTTP 1.0 compatibility
+ headers['Cache-Control'] = default_cache_control
+ headers['Pragma'] = 'no-cache' # HTTP 1.0 compatibility
+ end
+ end
+
+ def default_cache_control
+ if request.xhr?
+ ActionDispatch::Http::Cache::Response::DEFAULT_CACHE_CONTROL
+ else
+ DEFAULT_GITLAB_CACHE_CONTROL
end
end
@@ -279,7 +294,7 @@ class ApplicationController < ActionController::Base
unless Gitlab::Auth::LDAP::Access.allowed?(current_user)
sign_out current_user
- flash[:alert] = "Access denied for your LDAP account."
+ flash[:alert] = _("Access denied for your LDAP account.")
redirect_to new_user_session_path
end
end
@@ -326,7 +341,7 @@ class ApplicationController < ActionController::Base
def require_email
if current_user && current_user.temp_oauth_email? && session[:impersonator_id].nil?
- return redirect_to profile_path, notice: 'Please complete your profile with email address'
+ return redirect_to profile_path, notice: _('Please complete your profile with email address')
end
end
@@ -406,7 +421,7 @@ class ApplicationController < ActionController::Base
end
def manifest_import_enabled?
- Group.supports_nested_groups? && Gitlab::CurrentSettings.import_sources.include?('manifest')
+ Group.supports_nested_objects? && Gitlab::CurrentSettings.import_sources.include?('manifest')
end
# U2F (universal 2nd factor) devices need a unique identifier for the application
@@ -420,6 +435,10 @@ class ApplicationController < ActionController::Base
Gitlab::I18n.with_user_locale(current_user, &block)
end
+ def set_session_storage(&block)
+ Gitlab::Session.with_session(session, &block)
+ end
+
def set_page_title_header
# Per https://tools.ietf.org/html/rfc5987, headers need to be ISO-8859-1, not UTF-8
response.headers['Page-Title'] = URI.escape(page_title('GitLab'))
diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb
index 0d5c8657c9e..091327931c2 100644
--- a/app/controllers/autocomplete_controller.rb
+++ b/app/controllers/autocomplete_controller.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class AutocompleteController < ApplicationController
- skip_before_action :authenticate_user!, only: [:users, :award_emojis]
+ skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches]
def users
project = Autocomplete::ProjectFinder
@@ -38,4 +38,11 @@ class AutocompleteController < ApplicationController
def award_emojis
render json: AwardedEmojiFinder.new(current_user).execute
end
+
+ def merge_request_target_branches
+ merge_requests = MergeRequestsFinder.new(current_user, params).execute
+ target_branches = merge_requests.recent_target_branches
+
+ render json: target_branches.map { |target_branch| { title: target_branch } }
+ end
end
diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb
index c4e7fc950f9..16c2365f85d 100644
--- a/app/controllers/clusters/applications_controller.rb
+++ b/app/controllers/clusters/applications_controller.rb
@@ -3,26 +3,54 @@
class Clusters::ApplicationsController < Clusters::BaseController
before_action :cluster
before_action :authorize_create_cluster!, only: [:create]
+ before_action :authorize_update_cluster!, only: [:update]
+ before_action :authorize_admin_cluster!, only: [:destroy]
def create
- Clusters::Applications::CreateService
- .new(@cluster, current_user, create_cluster_application_params)
- .execute(request)
+ request_handler do
+ Clusters::Applications::CreateService
+ .new(@cluster, current_user, cluster_application_params)
+ .execute(request)
+ end
+ end
+
+ def update
+ request_handler do
+ Clusters::Applications::UpdateService
+ .new(@cluster, current_user, cluster_application_params)
+ .execute(request)
+ end
+ end
+
+ def destroy
+ request_handler do
+ Clusters::Applications::DestroyService
+ .new(@cluster, current_user, cluster_application_destroy_params)
+ .execute(request)
+ end
+ end
+
+ private
+
+ def request_handler
+ yield
head :no_content
- rescue Clusters::Applications::CreateService::InvalidApplicationError
+ rescue Clusters::Applications::BaseService::InvalidApplicationError
render_404
rescue StandardError
head :bad_request
end
- private
-
def cluster
@cluster ||= clusterable.clusters.find(params[:id]) || render_404
end
- def create_cluster_application_params
+ def cluster_application_params
params.permit(:application, :hostname, :email)
end
+
+ def cluster_application_destroy_params
+ params.permit(:application)
+ end
end
diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb
index 9aa8b758539..73ebd4e0e42 100644
--- a/app/controllers/clusters/clusters_controller.rb
+++ b/app/controllers/clusters/clusters_controller.rb
@@ -12,14 +12,29 @@ class Clusters::ClustersController < Clusters::BaseController
before_action :authorize_update_cluster!, only: [:update]
before_action :authorize_admin_cluster!, only: [:destroy]
before_action :update_applications_status, only: [:cluster_status]
+ before_action only: [:show] do
+ push_frontend_feature_flag(:metrics_time_window)
+ end
helper_method :token_in_session
STATUS_POLLING_INTERVAL = 10_000
def index
- clusters = ClustersFinder.new(clusterable, current_user, :all).execute
- @clusters = clusters.page(params[:page]).per(20)
+ finder = ClusterAncestorsFinder.new(clusterable.subject, current_user)
+ clusters = finder.execute
+
+ # Note: We are paginating through an array here but this should OK as:
+ #
+ # In CE, we can have a maximum group nesting depth of 21, so including
+ # project cluster, we can have max 22 clusters for a group hierarchy.
+ # In EE (Premium) we can have any number, as multiple clusters are
+ # supported, but the number of clusters are fairly low currently.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-ce/issues/55260 also.
+ @clusters = Kaminari.paginate_array(clusters).page(params[:page]).per(20)
+
+ @has_ancestor_clusters = finder.has_ancestor_clusters?
end
def new
@@ -111,23 +126,25 @@ class Clusters::ClustersController < Clusters::BaseController
private
def update_params
- if cluster.managed?
+ if cluster.provided_by_user?
params.require(:cluster).permit(
:enabled,
+ :name,
:environment_scope,
+ :base_domain,
platform_kubernetes_attributes: [
+ :api_url,
+ :token,
+ :ca_cert,
:namespace
]
)
else
params.require(:cluster).permit(
:enabled,
- :name,
:environment_scope,
+ :base_domain,
platform_kubernetes_attributes: [
- :api_url,
- :token,
- :ca_cert,
:namespace
]
)
@@ -139,6 +156,7 @@ class Clusters::ClustersController < Clusters::BaseController
:enabled,
:name,
:environment_scope,
+ :managed,
provider_gcp_attributes: [
:gcp_project_id,
:zone,
@@ -157,6 +175,7 @@ class Clusters::ClustersController < Clusters::BaseController
:enabled,
:name,
:environment_scope,
+ :managed,
platform_kubernetes_attributes: [
:namespace,
:api_url,
diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb
index 5507328f8ae..4926062f9ca 100644
--- a/app/controllers/concerns/authenticates_with_two_factor.rb
+++ b/app/controllers/concerns/authenticates_with_two_factor.rb
@@ -8,13 +8,6 @@
module AuthenticatesWithTwoFactor
extend ActiveSupport::Concern
- included do
- # This action comes from DeviseController, but because we call `sign_in`
- # manually, not skipping this action would cause a "You are already signed
- # in." error message to be shown upon successful login.
- skip_before_action :require_no_authentication, only: [:create], raise: false
- end
-
# Store the user's ID in the session for later retrieval and render the
# two factor code prompt
#
@@ -36,7 +29,7 @@ module AuthenticatesWithTwoFactor
end
def locked_user_redirect(user)
- flash.now[:alert] = 'Invalid Login or password'
+ flash.now[:alert] = _('Invalid Login or password')
render 'devise/sessions/new'
end
@@ -66,7 +59,7 @@ module AuthenticatesWithTwoFactor
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
- flash.now[:alert] = 'Invalid two-factor code.'
+ flash.now[:alert] = _('Invalid two-factor code.')
prompt_for_two_factor(user)
end
end
@@ -83,7 +76,7 @@ module AuthenticatesWithTwoFactor
else
user.increment_failed_attempts!
Gitlab::AppLogger.info("Failed Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
- flash.now[:alert] = 'Authentication via U2F device failed.'
+ flash.now[:alert] = _('Authentication via U2F device failed.')
prompt_for_two_factor(user)
end
end
diff --git a/app/controllers/concerns/boards_actions.rb b/app/controllers/concerns/boards_actions.rb
new file mode 100644
index 00000000000..ed7ea2f0e04
--- /dev/null
+++ b/app/controllers/concerns/boards_actions.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module BoardsActions
+ include Gitlab::Utils::StrongMemoize
+ extend ActiveSupport::Concern
+
+ included do
+ include BoardsResponses
+
+ before_action :boards, only: :index
+ before_action :board, only: :show
+ end
+
+ def index
+ respond_with_boards
+ end
+
+ def show
+ # Add / update the board in the recent visits table
+ Boards::Visits::CreateService.new(parent, current_user).execute(board) if request.format.html?
+
+ respond_with_board
+ end
+
+ private
+
+ def boards
+ strong_memoize(:boards) do
+ Boards::ListService.new(parent, current_user).execute
+ end
+ end
+
+ def board
+ strong_memoize(:board) do
+ boards.find(params[:id])
+ end
+ end
+end
diff --git a/app/controllers/concerns/boards_responses.rb b/app/controllers/concerns/boards_responses.rb
index 3cdf4ddf8bb..8b191c86397 100644
--- a/app/controllers/concerns/boards_responses.rb
+++ b/app/controllers/concerns/boards_responses.rb
@@ -34,15 +34,11 @@ module BoardsResponses
end
def authorize_read_list
- ability = board.group_board? ? :read_group : :read_list
-
- authorize_action_for!(board.parent, ability)
+ authorize_action_for!(board, :read_list)
end
def authorize_read_issue
- ability = board.group_board? ? :read_group : :read_issue
-
- authorize_action_for!(board.parent, ability)
+ authorize_action_for!(board, :read_issue)
end
def authorize_update_issue
@@ -57,7 +53,7 @@ module BoardsResponses
end
def authorize_admin_list
- authorize_action_for!(board.parent, :admin_list)
+ authorize_action_for!(board, :admin_list)
end
def authorize_action_for!(resource, ability)
diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb
index f0e6adf4dec..54c0510497f 100644
--- a/app/controllers/concerns/continue_params.rb
+++ b/app/controllers/concerns/continue_params.rb
@@ -6,7 +6,7 @@ module ContinueParams
def continue_params
continue_params = params[:continue]
- return nil unless continue_params
+ return unless continue_params
continue_params = continue_params.permit(:to, :notice, :notice_now)
continue_params[:to] = safe_redirect_path(continue_params[:to])
diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb
index b3777fd2b0f..e8e681ce649 100644
--- a/app/controllers/concerns/creates_commit.rb
+++ b/app/controllers/concerns/creates_commit.rb
@@ -31,7 +31,7 @@ module CreatesCommit
respond_to do |format|
format.html { redirect_to success_path }
- format.json { render json: { message: "success", filePath: success_path } }
+ format.json { render json: { message: _("success"), filePath: success_path } }
end
else
flash[:alert] = result[:message]
@@ -45,7 +45,7 @@ module CreatesCommit
redirect_to failure_path
end
end
- format.json { render json: { message: "failed", filePath: failure_path } }
+ format.json { render json: { message: _("failed"), filePath: failure_path } }
end
end
end
@@ -60,15 +60,22 @@ module CreatesCommit
private
def update_flash_notice(success_notice)
- flash[:notice] = success_notice || "Your changes have been successfully committed."
+ flash[:notice] = success_notice || _("Your changes have been successfully committed.")
if create_merge_request?
- if merge_request_exists?
- flash[:notice] = nil
- else
- target = different_project? ? "project" : "branch"
- flash[:notice] = flash[:notice] + " You can now submit a merge request to get this change into the original #{target}."
- end
+ flash[:notice] =
+ if merge_request_exists?
+ nil
+ else
+ mr_message =
+ if different_project?
+ _("You can now submit a merge request to get this change into the original project.")
+ else
+ _("You can now submit a merge request to get this change into the original branch.")
+ end
+
+ flash[:notice] += " " + mr_message
+ end
end
end
diff --git a/app/controllers/concerns/enforces_admin_authentication.rb b/app/controllers/concerns/enforces_admin_authentication.rb
new file mode 100644
index 00000000000..3ef92730df6
--- /dev/null
+++ b/app/controllers/concerns/enforces_admin_authentication.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+# == EnforcesAdminAuthentication
+#
+# Controller concern to enforce that users are authenticated as admins
+#
+# Upon inclusion, adds `authenticate_admin!` as a before_action
+#
+module EnforcesAdminAuthentication
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :authenticate_admin!
+ end
+
+ def authenticate_admin!
+ render_404 unless current_user.admin?
+ end
+end
diff --git a/app/controllers/concerns/enforces_two_factor_authentication.rb b/app/controllers/concerns/enforces_two_factor_authentication.rb
index 71bdef8ce03..0fddf15d197 100644
--- a/app/controllers/concerns/enforces_two_factor_authentication.rb
+++ b/app/controllers/concerns/enforces_two_factor_authentication.rb
@@ -16,7 +16,7 @@ module EnforcesTwoFactorAuthentication
end
def check_two_factor_requirement
- if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
+ if two_factor_authentication_required? && current_user && !current_user.temp_oauth_email? && !current_user.two_factor_enabled? && !skip_two_factor?
redirect_to profile_two_factor_auth_path
end
end
diff --git a/app/controllers/concerns/group_tree.rb b/app/controllers/concerns/group_tree.rb
index 4f56346832c..e9a7d6a3152 100644
--- a/app/controllers/concerns/group_tree.rb
+++ b/app/controllers/concerns/group_tree.rb
@@ -32,14 +32,14 @@ module GroupTree
def filtered_groups_with_ancestors(groups)
filtered_groups = groups.search(params[:filter]).page(params[:page])
- if Group.supports_nested_groups?
+ if Group.supports_nested_objects?
# We find the ancestors by ID of the search results here.
# Otherwise the ancestors would also have filters applied,
# which would cause them not to be preloaded.
#
# Pagination needs to be applied before loading the ancestors to
# make sure ancestors are not cut off by pagination.
- Gitlab::GroupHierarchy.new(Group.where(id: filtered_groups.select(:id)))
+ Gitlab::ObjectHierarchy.new(Group.where(id: filtered_groups.select(:id)))
.base_and_ancestors
else
filtered_groups
diff --git a/app/controllers/concerns/invalid_utf8_error_handler.rb b/app/controllers/concerns/invalid_utf8_error_handler.rb
deleted file mode 100644
index 44c6d6b0da0..00000000000
--- a/app/controllers/concerns/invalid_utf8_error_handler.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module InvalidUTF8ErrorHandler
- extend ActiveSupport::Concern
-
- included do
- rescue_from ArgumentError, with: :handle_invalid_utf8
- end
-
- private
-
- def handle_invalid_utf8(error)
- if error.message == "invalid byte sequence in UTF-8"
- render_412
- else
- raise(error)
- end
- end
-
- def render_412
- respond_to do |format|
- format.html { render "errors/precondition_failed", layout: "errors", status: 412 }
- format.js { render json: { error: 'Invalid UTF-8' }, status: :precondition_failed, content_type: 'application/json' }
- format.any { head :precondition_failed }
- end
- end
-end
diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb
index ad9cc0925b7..065d2d3a4ec 100644
--- a/app/controllers/concerns/issuable_actions.rb
+++ b/app/controllers/concerns/issuable_actions.rb
@@ -5,9 +5,11 @@ module IssuableActions
include Gitlab::Utils::StrongMemoize
included do
- before_action :labels, only: [:show, :new, :edit]
before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update
+ before_action only: :show do
+ push_frontend_feature_flag(:scoped_labels, default_enabled: true)
+ end
end
def permitted_keys
@@ -25,7 +27,10 @@ module IssuableActions
def show
respond_to do |format|
- format.html
+ format.html do
+ @issuable_sidebar = serializer.represent(issuable, serializer: 'sidebar') # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
format.json do
render json: serializer.represent(issuable, serializer: params[:serializer])
end
@@ -56,7 +61,8 @@ module IssuableActions
title_text: issuable.title,
description: view_context.markdown_field(issuable, :description),
description_text: issuable.description,
- task_status: issuable.task_status
+ task_status: issuable.task_status,
+ lock_version: issuable.lock_version
}
if issuable.edited?
@@ -168,10 +174,6 @@ module IssuableActions
end
end
- def labels
- @labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables
- end
-
def authorize_destroy_issuable!
unless can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable)
return access_denied!
@@ -190,12 +192,7 @@ module IssuableActions
def bulk_update_params
permitted_keys_array = permitted_keys.dup
-
- if resource_name == 'issue'
- permitted_keys_array << { assignee_ids: [] }
- else
- permitted_keys_array.unshift(:assignee_id)
- end
+ permitted_keys_array << { assignee_ids: [] }
params.require(:update).permit(permitted_keys_array)
end
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index 789e0dc736e..91e875dca54 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -91,6 +91,7 @@ module IssuableCollections
options = {
scope: params[:scope],
state: params[:state],
+ confidential: Gitlab::Utils.to_boolean(params[:confidential]),
sort: set_sort_order
}
@@ -99,6 +100,7 @@ module IssuableCollections
if @project
options[:project_id] = @project.id
+ options[:attempt_project_search_optimizations] = true
elsif @group
options[:group_id] = @group.id
options[:include_subgroups] = true
@@ -129,13 +131,13 @@ module IssuableCollections
return sort_param if Gitlab::Database.read_only?
if user_preference[issuable_sorting_field] != sort_param
- user_preference.update_attribute(issuable_sorting_field, sort_param)
+ user_preference.update(issuable_sorting_field => sort_param)
end
sort_param
end
- # Implement default_sorting_field method on controllers
+ # Implement issuable_sorting_field method on controllers
# to choose which column to store the sorting parameter.
def issuable_sorting_field
nil
@@ -188,15 +190,15 @@ module IssuableCollections
end
end
+ # rubocop:disable Gitlab/ModuleWithInstanceVariables
def preload_for_collection
+ common_attributes = [:author, :assignees, :labels, :milestone]
@preload_for_collection ||= case collection_type
when 'Issue'
- [:project, :author, :assignees, :labels, :milestone, project: :namespace]
+ common_attributes + [:project, project: :namespace]
when 'MergeRequest'
- [
- :target_project, :author, :assignee, :labels, :milestone,
- source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
- ]
+ common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits]
end
end
+ # rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/issues_action.rb b/app/controllers/concerns/issuable_collections_action.rb
index a75590457d6..18ed4027eac 100644
--- a/app/controllers/concerns/issues_action.rb
+++ b/app/controllers/concerns/issuable_collections_action.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-module IssuesAction
+module IssuableCollectionsAction
extend ActiveSupport::Concern
include IssuableCollections
include IssuesCalendar
@@ -18,6 +18,12 @@ module IssuesAction
format.atom { render layout: 'xml.atom' }
end
end
+
+ def merge_requests
+ @merge_requests = issuables_collection.page(params[:page])
+
+ @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
+ end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def issues_calendar
@@ -26,8 +32,29 @@ module IssuesAction
private
+ def issuable_sorting_field
+ case action_name
+ when 'issues'
+ Issue::SORTING_PREFERENCE_FIELD
+ when 'merge_requests'
+ MergeRequest::SORTING_PREFERENCE_FIELD
+ else
+ nil
+ end
+ end
+
def finder_type
- (super if defined?(super)) ||
- (IssuesFinder if %w(issues issues_calendar).include?(action_name))
+ case action_name
+ when 'issues', 'issues_calendar'
+ IssuesFinder
+ when 'merge_requests'
+ MergeRequestsFinder
+ else
+ nil
+ end
+ end
+
+ def finder_options
+ super.merge(non_archived: true)
end
end
diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb
index 9576eb14fdd..f7137a04437 100644
--- a/app/controllers/concerns/lfs_request.rb
+++ b/app/controllers/concerns/lfs_request.rb
@@ -26,7 +26,7 @@ module LfsRequest
render(
json: {
- message: 'Git LFS is not enabled on this GitLab server, contact your admin.',
+ message: _('Git LFS is not enabled on this GitLab server, contact your admin.'),
documentation_url: help_url
},
status: :not_implemented
@@ -51,7 +51,7 @@ module LfsRequest
def render_lfs_forbidden
render(
json: {
- message: 'Access forbidden. Check your access level.',
+ message: _('Access forbidden. Check your access level.'),
documentation_url: help_url
},
content_type: CONTENT_TYPE,
@@ -62,7 +62,7 @@ module LfsRequest
def render_lfs_not_found
render(
json: {
- message: 'Not found.',
+ message: _('Not found.'),
documentation_url: help_url
},
content_type: CONTENT_TYPE,
@@ -94,6 +94,7 @@ module LfsRequest
def lfs_upload_access?
return false unless project.lfs_enabled?
return false unless has_authentication_ability?(:push_code)
+ return false if limit_exceeded?
lfs_deploy_token? || can?(user, :push_code, project)
end
@@ -121,4 +122,9 @@ module LfsRequest
def has_authentication_ability?(capability)
(authentication_abilities || []).include?(capability)
end
+
+ # Overridden in EE
+ def limit_exceeded?
+ false
+ end
end
diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb
index ca713192c9e..0b2756c0c6a 100644
--- a/app/controllers/concerns/membership_actions.rb
+++ b/app/controllers/concerns/membership_actions.rb
@@ -9,7 +9,7 @@ module MembershipActions
result = Members::CreateService.new(current_user, create_params).execute(membershipable)
if result[:status] == :success
- redirect_to members_page_url, notice: 'Users were successfully added.'
+ redirect_to members_page_url, notice: _('Users were successfully added.')
else
redirect_to members_page_url, alert: result[:message]
end
@@ -35,7 +35,16 @@ module MembershipActions
respond_to do |format|
format.html do
- message = "User was successfully removed from #{source_type}."
+ message =
+ begin
+ case membershipable
+ when Namespace
+ _("User was successfully removed from group and any subresources.")
+ else
+ _("User was successfully removed from project.")
+ end
+ end
+
redirect_to members_page_url, notice: message
end
@@ -47,7 +56,7 @@ module MembershipActions
membershipable.request_access(current_user)
redirect_to polymorphic_path(membershipable),
- notice: 'Your request for access has been queued for review.'
+ notice: _('Your request for access has been queued for review.')
end
def approve_access_request
@@ -66,9 +75,9 @@ module MembershipActions
notice =
if member.request?
- "Your access request to the #{source_type} has been withdrawn."
+ _("Your access request to the %{source_type} has been withdrawn.") % { source_type: source_type }
else
- "You left the \"#{membershipable.human_name}\" #{source_type}."
+ _("You left the \"%{membershipable_human_name}\" %{source_type}.") % { membershipable_human_name: membershipable.human_name, source_type: source_type }
end
respond_to do |format|
@@ -88,9 +97,9 @@ module MembershipActions
if member.invite?
member.resend_invite
- redirect_to members_page_url, notice: 'The invitation was successfully resent.'
+ redirect_to members_page_url, notice: _('The invitation was successfully resent.')
else
- redirect_to members_page_url, alert: 'The invitation has already been accepted.'
+ redirect_to members_page_url, alert: _('The invitation has already been accepted.')
end
end
@@ -123,6 +132,16 @@ module MembershipActions
end
def source_type
- @source_type ||= membershipable.class.to_s.humanize(capitalize: false)
+ @source_type ||=
+ begin
+ case membershipable
+ when Namespace
+ _("group")
+ when Project
+ _("project")
+ else
+ raise "Unknown membershipable type: #{membershipable}!"
+ end
+ end
end
end
diff --git a/app/controllers/concerns/merge_requests_action.rb b/app/controllers/concerns/merge_requests_action.rb
deleted file mode 100644
index ed10f32512e..00000000000
--- a/app/controllers/concerns/merge_requests_action.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# frozen_string_literal: true
-
-module MergeRequestsAction
- extend ActiveSupport::Concern
- include IssuableCollections
-
- # rubocop:disable Gitlab/ModuleWithInstanceVariables
- def merge_requests
- @merge_requests = issuables_collection.page(params[:page])
-
- @issuable_meta_data = issuable_meta_data(@merge_requests, collection_type)
- end
- # rubocop:enable Gitlab/ModuleWithInstanceVariables
-
- private
-
- def finder_type
- (super if defined?(super)) ||
- (MergeRequestsFinder if action_name == 'merge_requests')
- end
-
- def finder_options
- super.merge(non_archived: true)
- end
-end
diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb
index eccbe35577b..cfff154c3dd 100644
--- a/app/controllers/concerns/milestone_actions.rb
+++ b/app/controllers/concerns/milestone_actions.rb
@@ -8,7 +8,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_merge_requests_tab", {
- merge_requests: @milestone.sorted_merge_requests, # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ merge_requests: @milestone.sorted_merge_requests(current_user), # rubocop:disable Gitlab/ModuleWithInstanceVariables
show_project_name: true
})
end
@@ -31,7 +31,7 @@ module MilestoneActions
format.html { redirect_to milestone_redirect_path }
format.json do
render json: tabs_json("shared/milestones/_labels_tab", {
- labels: @milestone.labels # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ labels: @milestone.labels.map { |label| label.present(issuable_subject: @milestone.parent) } # rubocop:disable Gitlab/ModuleWithInstanceVariables
})
end
end
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index 0319948a12f..f96d1821095 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -6,7 +6,6 @@ module NotesActions
extend ActiveSupport::Concern
included do
- prepend_before_action :normalize_create_params, only: [:create]
before_action :set_polling_interval_header, only: [:index]
before_action :require_noteable!, only: [:index, :create]
before_action :authorize_admin_note!, only: [:update, :destroy]
@@ -44,17 +43,12 @@ module NotesActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def create
- 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(note_project, current_user, create_params).execute
+ @note = Notes::CreateService.new(note_project, current_user, create_note_params).execute
respond_to do |format|
format.json do
json = {
- commands_changes: @note.commands_changes
+ commands_changes: @note.commands_changes&.slice(:emoji_award, :time_estimate, :spend_time)
}
if @note.persisted? && return_discussion?
@@ -78,7 +72,7 @@ module NotesActions
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def update
- @note = Notes::UpdateService.new(project, current_user, note_params).execute(note)
+ @note = Notes::UpdateService.new(project, current_user, update_note_params).execute(note)
prepare_notes_for_rendering([@note])
respond_to do |format|
@@ -196,24 +190,36 @@ module NotesActions
return access_denied! unless can?(current_user, :admin_note, note)
end
- def note_params
+ def create_note_params
params.require(:note).permit(
- :project_id,
- :noteable_type,
- :noteable_id,
- :commit_id,
- :noteable,
:type,
-
:note,
- :attachment,
+ :line_code, # LegacyDiffNote
+ :position # DiffNote
+ ).tap do |create_params|
+ create_params.merge!(
+ params.permit(:merge_request_diff_head_sha, :in_reply_to_discussion_id)
+ )
- # LegacyDiffNote
- :line_code,
+ # These params are also sent by the client but we need to set these based on
+ # target_type and target_id because we're checking permissions based on that
+ create_params[:noteable_type] = params[:target_type].classify
+
+ case params[:target_type]
+ when 'commit'
+ create_params[:commit_id] = params[:target_id]
+ when 'merge_request'
+ create_params[:noteable_id] = params[:target_id]
+ # Notes on MergeRequest can have an extra `commit_id` context
+ create_params[:commit_id] = params.dig(:note, :commit_id)
+ else
+ create_params[:noteable_id] = params[:target_id]
+ end
+ end
+ end
- # DiffNote
- :position
- )
+ def update_note_params
+ params.require(:note).permit(:note)
end
def set_polling_interval_header
@@ -248,15 +254,6 @@ module NotesActions
DiscussionSerializer.new(project: project, noteable: noteable, current_user: current_user, note_entity: ProjectNoteEntity)
end
- # Avoids checking permissions in the wrong object - this ensures that the object we checked permissions for
- # is the object we're actually creating a note in.
- def normalize_create_params
- params[:note].try do |note|
- note[:noteable_id] = params[:target_id]
- note[:noteable_type] = params[:target_type].classify
- end
- end
-
def note_project
strong_memoize(:note_project) do
next nil unless project
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index 4b0f0b8255c..2a9729b6ffd 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -16,13 +16,11 @@ module PreviewMarkdown
else {}
end
- markdown_params[:markdown_engine] = result[:markdown_engine]
-
render json: {
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
- suggestions: result[:suggestions],
+ suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]),
commands: view_context.markdown(result[:commands])
}
}
diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb
index f59440dbc59..7238840440f 100644
--- a/app/controllers/concerns/project_unauthorized.rb
+++ b/app/controllers/concerns/project_unauthorized.rb
@@ -1,10 +1,21 @@
# frozen_string_literal: true
module ProjectUnauthorized
- extend ActiveSupport::Concern
+ module ControllerActions
+ def self.on_routable_not_found
+ lambda do |routable|
+ return unless routable.is_a?(Project)
- # EE would override this
- def project_unauthorized_proc
- # no-op
+ label = routable.external_authorization_classification_label
+ rejection_reason = nil
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label)
+ rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label)
+ rejection_reason ||= _('External authorization denied access to this project')
+ end
+
+ access_denied!(rejection_reason) if rejection_reason
+ end
+ end
end
end
diff --git a/app/controllers/concerns/record_user_last_activity.rb b/app/controllers/concerns/record_user_last_activity.rb
new file mode 100644
index 00000000000..372c803278d
--- /dev/null
+++ b/app/controllers/concerns/record_user_last_activity.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+# == RecordUserLastActivity
+#
+# Controller concern that updates the `last_activity_on` field of `users`
+# for any authenticated GET request. The DB update will only happen once per day.
+#
+# In order to determine if you should include this concern or not, please check the
+# description and discussion on this issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/54947
+module RecordUserLastActivity
+ include CookiesHelper
+ extend ActiveSupport::Concern
+
+ included do
+ before_action :set_user_last_activity
+ end
+
+ def set_user_last_activity
+ return unless request.get?
+ return unless Feature.enabled?(:set_user_last_activity, default_enabled: true)
+ return if Gitlab::Database.read_only?
+
+ if current_user && current_user.last_activity_on != Date.today
+ Users::ActivityService.new(current_user, "visited #{request.path}").execute
+ end
+ end
+end
diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb
index ce36da6b715..18015b1de88 100644
--- a/app/controllers/concerns/renders_notes.rb
+++ b/app/controllers/concerns/renders_notes.rb
@@ -16,7 +16,7 @@ module RendersNotes
private
def preload_max_access_for_authors(notes, project)
- return nil unless project
+ return unless project
user_ids = notes.map(&:author_id)
project.team.max_member_access_for_user_ids(user_ids)
diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb
index 5624eb3aa45..ff9b0332c97 100644
--- a/app/controllers/concerns/routable_actions.rb
+++ b/app/controllers/concerns/routable_actions.rb
@@ -3,15 +3,13 @@
module RoutableActions
extend ActiveSupport::Concern
- def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil, not_found_or_authorized_proc: nil)
+ def find_routable!(routable_klass, requested_full_path, extra_authorization_proc: nil)
routable = routable_klass.find_by_full_path(requested_full_path, follow_redirects: request.get?)
if routable_authorized?(routable, extra_authorization_proc)
ensure_canonical_path(routable, requested_full_path)
routable
else
- if not_found_or_authorized_proc
- not_found_or_authorized_proc.call(routable)
- end
+ perform_not_found_actions(routable, not_found_actions)
route_not_found unless performed?
@@ -19,6 +17,18 @@ module RoutableActions
end
end
+ def not_found_actions
+ [ProjectUnauthorized::ControllerActions.on_routable_not_found]
+ end
+
+ def perform_not_found_actions(routable, actions)
+ actions.each do |action|
+ break if performed?
+
+ instance_exec(routable, &action)
+ end
+ end
+
def routable_authorized?(routable, extra_authorization_proc)
return false unless routable
diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb
index 515a9eede8e..28e4cece548 100644
--- a/app/controllers/concerns/send_file_upload.rb
+++ b/app/controllers/concerns/send_file_upload.rb
@@ -3,16 +3,19 @@
module SendFileUpload
def send_upload(file_upload, send_params: {}, redirect_params: {}, attachment: nil, proxy: false, disposition: 'attachment')
if attachment
+ response_disposition = ::Gitlab::ContentDisposition.format(disposition: disposition, filename: attachment)
+
# Response-Content-Type will not override an existing Content-Type in
# Google Cloud Storage, so the metadata needs to be cleared on GCS for
# this to work. However, this override works with AWS.
- redirect_params[:query] = { "response-content-disposition" => "#{disposition};filename=#{attachment.inspect}",
+ redirect_params[:query] = { "response-content-disposition" => response_disposition,
"response-content-type" => guess_content_type(attachment) }
# By default, Rails will send uploads with an extension of .js with a
# content-type of text/javascript, which will trigger Rails'
# cross-origin JavaScript protection.
send_params[:content_type] = 'text/plain' if File.extname(attachment) == '.js'
- send_params.merge!(filename: attachment, disposition: disposition)
+
+ send_params.merge!(filename: attachment, disposition: utf8_encoded_disposition(disposition, attachment))
end
if file_upload.file_storage?
@@ -25,6 +28,18 @@ module SendFileUpload
end
end
+ # Since Rails 5 doesn't properly support support non-ASCII filenames,
+ # we have to add our own to ensure RFC 5987 compliance. However, Rails
+ # 5 automatically appends `filename#{filename}` here:
+ # https://github.com/rails/rails/blob/v5.0.7/actionpack/lib/action_controller/metal/data_streaming.rb#L137
+ # Rails 6 will have https://github.com/rails/rails/pull/33829, so we
+ # can get rid of this special case handling when we upgrade.
+ def utf8_encoded_disposition(disposition, filename)
+ content = ::Gitlab::ContentDisposition.new(disposition: disposition, filename: filename)
+
+ "#{disposition}; #{content.utf8_filename}"
+ end
+
def guess_content_type(filename)
types = MIME::Types.type_for(filename)
diff --git a/app/controllers/concerns/service_params.rb b/app/controllers/concerns/service_params.rb
index 8bd93a349ef..48451bedcc2 100644
--- a/app/controllers/concerns/service_params.rb
+++ b/app/controllers/concerns/service_params.rb
@@ -70,9 +70,9 @@ module ServiceParams
def service_params
dynamic_params = @service.event_channel_names + @service.event_names # rubocop:disable Gitlab/ModuleWithInstanceVariables
- service_params = params.permit(:id, service: ALLOWED_PARAMS_CE + dynamic_params)
+ service_params = params.permit(:id, service: allowed_service_params + dynamic_params)
- if service_params[:service].is_a?(Hash)
+ if service_params[:service].is_a?(ActionController::Parameters)
FILTER_BLANK_PARAMS.each do |param|
service_params[:service].delete(param) if service_params[:service][param].blank?
end
@@ -80,4 +80,8 @@ module ServiceParams
service_params
end
+
+ def allowed_service_params
+ ALLOWED_PARAMS_CE
+ end
end
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index c3a1b12af84..a8ffa33f1c7 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -12,9 +12,9 @@ module SpammableActions
def mark_as_spam
if SpamService.new(spammable).mark_as_spam!
- redirect_to spammable_path, notice: "#{spammable.spammable_entity_type.titlecase} was submitted to Akismet successfully."
+ redirect_to spammable_path, notice: _("%{spammable_titlecase} was submitted to Akismet successfully.") % { spammable_titlecase: spammable.spammable_entity_type.titlecase }
else
- redirect_to spammable_path, alert: 'Error with Akismet. Please check the logs for more info.'
+ redirect_to spammable_path, alert: _('Error with Akismet. Please check the logs for more info.')
end
end
@@ -33,7 +33,7 @@ module SpammableActions
ensure_spam_config_loaded!
if params[:recaptcha_verification]
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
end
respond_to do |format|
diff --git a/app/controllers/concerns/uploads_actions.rb b/app/controllers/concerns/uploads_actions.rb
index 0eea0cdd50f..59f6d3452a3 100644
--- a/app/controllers/concerns/uploads_actions.rb
+++ b/app/controllers/concerns/uploads_actions.rb
@@ -7,16 +7,16 @@ module UploadsActions
UPLOAD_MOUNTS = %w(avatar attachment file logo header_logo favicon).freeze
def create
- link_to_file = UploadService.new(model, params[:file], uploader_class).execute
+ uploader = UploadService.new(model, params[:file], uploader_class).execute
respond_to do |format|
- if link_to_file
+ if uploader
format.json do
- render json: { link: link_to_file }
+ render json: { link: uploader.to_h }
end
else
format.json do
- render json: 'Invalid file.', status: :unprocessable_entity
+ render json: _('Invalid file.'), status: :unprocessable_entity
end
end
end
@@ -29,7 +29,13 @@ module UploadsActions
def show
return render_404 unless uploader&.exists?
- expires_in 0.seconds, must_revalidate: true, private: true
+ if cache_publicly?
+ # We need to reset caching from the applications controller to get rid of the no-store value
+ headers['Cache-Control'] = ''
+ expires_in 5.minutes, public: true, must_revalidate: false
+ else
+ expires_in 0.seconds, must_revalidate: true, private: true
+ end
disposition = uploader.image_or_video? ? 'inline' : 'attachment'
@@ -51,7 +57,7 @@ module UploadsActions
render json: authorized
rescue SocketError
- render json: "Error uploading file", status: :internal_server_error
+ render json: _("Error uploading file"), status: :internal_server_error
end
private
@@ -114,6 +120,10 @@ module UploadsActions
nil
end
+ def cache_publicly?
+ false
+ end
+
def model
strong_memoize(:model) { find_model }
end
diff --git a/app/controllers/confirmations_controller.rb b/app/controllers/confirmations_controller.rb
index 2c4aab67448..2ae500a2fdf 100644
--- a/app/controllers/confirmations_controller.rb
+++ b/app/controllers/confirmations_controller.rb
@@ -22,7 +22,7 @@ class ConfirmationsController < Devise::ConfirmationsController
after_sign_in(resource)
else
Gitlab::AppLogger.info("Email Confirmed: username=#{resource.username} email=#{resource.email} ip=#{request.remote_ip}")
- flash[:notice] = flash[:notice] + " Please sign in."
+ flash[:notice] = flash[:notice] + _(" Please sign in.")
new_session_path(:user, anchor: 'login-pane')
end
end
diff --git a/app/controllers/dashboard/application_controller.rb b/app/controllers/dashboard/application_controller.rb
index cee0753a021..0e9fdc60363 100644
--- a/app/controllers/dashboard/application_controller.rb
+++ b/app/controllers/dashboard/application_controller.rb
@@ -2,6 +2,7 @@
class Dashboard::ApplicationController < ApplicationController
include ControllerWithCrossProjectAccessCheck
+ include RecordUserLastActivity
layout 'dashboard'
diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb
index 3802aa5f40f..912036da0ea 100644
--- a/app/controllers/dashboard/milestones_controller.rb
+++ b/app/controllers/dashboard/milestones_controller.rb
@@ -25,9 +25,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
private
def group_milestones
- groups = GroupsFinder.new(current_user, all_available: false).execute
-
- DashboardGroupMilestone.build_collection(groups)
+ DashboardGroupMilestone.build_collection(groups, params)
end
# See [#39545](https://gitlab.com/gitlab-org/gitlab-ce/issues/39545) for info about the deprecation of dynamic milestones
@@ -45,6 +43,6 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController
end
def groups
- @groups ||= GroupsFinder.new(current_user, state_all: true).execute
+ @groups ||= GroupsFinder.new(current_user, all_available: false).execute
end
end
diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb
index f073b6de444..70811f5ea59 100644
--- a/app/controllers/dashboard/projects_controller.rb
+++ b/app/controllers/dashboard/projects_controller.rb
@@ -13,14 +13,19 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
@projects = load_projects(params.merge(non_public: true))
respond_to do |format|
- format.html
+ format.html do
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ render
+ end
+ end
format.atom do
load_events
render layout: 'xml.atom'
end
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("dashboard/projects/_projects", projects: @projects)
}
end
end
@@ -37,7 +42,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("dashboard/projects/_projects", projects: @projects)
}
end
end
@@ -53,6 +58,9 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def load_projects(finder_params)
+ @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
+ @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
+
projects = ProjectsFinder
.new(params: finder_params, current_user: current_user)
.execute
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 3fa582cf25b..f173c263474 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -21,7 +21,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
format.html do
redirect_to dashboard_todos_path,
status: 302,
- notice: 'Todo was successfully marked as done.'
+ notice: _('Todo was successfully marked as done.')
end
format.js { head :ok }
format.json { render json: todos_counts }
@@ -32,7 +32,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController
updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user)
respond_to do |format|
- format.html { redirect_to dashboard_todos_path, status: 302, notice: 'All todos were marked as done.' }
+ format.html { redirect_to dashboard_todos_path, status: 302, notice: _('All todos were marked as done.') }
format.js { head :ok }
format.json { render json: todos_counts.merge(updated_ids: updated_ids) }
end
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index be2d9512c01..1a97b39d3ae 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -1,8 +1,7 @@
# frozen_string_literal: true
class DashboardController < Dashboard::ApplicationController
- include IssuesAction
- include MergeRequestsAction
+ include IssuableCollectionsAction
prepend_before_action(only: [:issues]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:issues_calendar]) { authenticate_sessionless_user!(:ics) }
@@ -47,7 +46,10 @@ class DashboardController < Dashboard::ApplicationController
end
def check_filters_presence!
- @no_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) }
+ no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) }
+
+ @no_filters_set = no_scalar_filters_set && no_array_filters_set
return unless @no_filters_set
diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb
index 778fdda8dbd..ef86d5f981a 100644
--- a/app/controllers/explore/projects_controller.rb
+++ b/app/controllers/explore/projects_controller.rb
@@ -15,7 +15,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
@@ -30,7 +30,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
@@ -44,7 +44,7 @@ class Explore::ProjectsController < Explore::ApplicationController
format.html
format.json do
render json: {
- html: view_to_html_string("dashboard/projects/_projects", locals: { projects: @projects })
+ html: view_to_html_string("explore/projects/_projects", projects: @projects)
}
end
end
@@ -55,6 +55,9 @@ class Explore::ProjectsController < Explore::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def load_projects
+ @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute
+ @total_starred_projects_count = ProjectsFinder.new(params: { starred: true }, current_user: current_user).execute
+
projects = ProjectsFinder.new(current_user: current_user, params: params)
.execute
.includes(:route, :creator, :group, namespace: [:route, :owner])
diff --git a/app/controllers/google_api/authorizations_controller.rb b/app/controllers/google_api/authorizations_controller.rb
index dd9f5af61b3..ed0995e7ffd 100644
--- a/app/controllers/google_api/authorizations_controller.rb
+++ b/app/controllers/google_api/authorizations_controller.rb
@@ -2,6 +2,10 @@
module GoogleApi
class AuthorizationsController < ApplicationController
+ include Gitlab::Utils::StrongMemoize
+
+ before_action :validate_session_key!
+
def callback
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
@@ -11,21 +15,27 @@ module GoogleApi
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
expires_at.to_s
- state_redirect_uri = redirect_uri_from_session_key(params[:state])
-
- if state_redirect_uri
- redirect_to state_redirect_uri
- else
- redirect_to root_path
- end
+ redirect_to redirect_uri_from_session
end
private
- def redirect_uri_from_session_key(state)
- key = GoogleApi::CloudPlatform::Client
- .session_key_for_redirect_uri(params[:state])
- session[key] if key
+ def validate_session_key!
+ access_denied! unless redirect_uri_from_session.present?
+ end
+
+ def redirect_uri_from_session
+ strong_memoize(:redirect_uri_from_session) do
+ if params[:state].present?
+ session[session_key_for_redirect_uri(params[:state])]
+ else
+ nil
+ end
+ end
+ end
+
+ def session_key_for_redirect_uri(state)
+ GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(state)
end
end
end
diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb
index 3ef03bc9622..e8f38899647 100644
--- a/app/controllers/graphql_controller.rb
+++ b/app/controllers/graphql_controller.rb
@@ -3,18 +3,21 @@
class GraphqlController < ApplicationController
# Unauthenticated users have access to the API for public data
skip_before_action :authenticate_user!
- prepend_before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
+
+ # Allow missing CSRF tokens, this would mean that if a CSRF is invalid or missing,
+ # the user won't be authenticated but can proceed as an anonymous user.
+ #
+ # If a CSRF is valid, the user is authenticated. This makes it easier to play
+ # around in GraphiQL.
+ protect_from_forgery with: :null_session, only: :execute
before_action :check_graphql_feature_flag!
+ before_action :authorize_access_api!
+ before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
def execute
- variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h
- query = params[:query]
- operation_name = params[:operationName]
- context = {
- current_user: current_user
- }
- result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+ result = multiplex? ? execute_multiplex : execute_query
+
render json: result
end
@@ -30,6 +33,47 @@ class GraphqlController < ApplicationController
private
+ def execute_multiplex
+ GitlabSchema.multiplex(multiplex_queries, context: context)
+ end
+
+ def execute_query
+ variables = build_variables(params[:variables])
+ operation_name = params[:operationName]
+
+ GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
+ end
+
+ def query
+ params[:query]
+ end
+
+ def multiplex_queries
+ params[:_json].map do |single_query_info|
+ {
+ query: single_query_info[:query],
+ variables: build_variables(single_query_info[:variables]),
+ operation_name: single_query_info[:operationName]
+ }
+ end
+ end
+
+ def context
+ @context ||= { current_user: current_user }
+ end
+
+ def build_variables(variable_info)
+ Gitlab::Graphql::Variables.new(variable_info).to_h
+ end
+
+ def multiplex?
+ params[:_json].present?
+ end
+
+ def authorize_access_api!
+ access_denied!("API not accessible for user.") unless can?(current_user, :access_api)
+ end
+
# Overridden from the ApplicationController to make the response look like
# a GraphQL response. That is nicely picked up in Graphiql.
def render_404
diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb
index cdc6f53df8e..40b8d5ed72c 100644
--- a/app/controllers/groups/boards_controller.rb
+++ b/app/controllers/groups/boards_controller.rb
@@ -1,52 +1,16 @@
# frozen_string_literal: true
class Groups::BoardsController < Groups::ApplicationController
- include BoardsResponses
+ include BoardsActions
+ include RecordUserLastActivity
before_action :assign_endpoint_vars
- before_action :boards, only: :index
- before_action :redirect_to_recent_board, only: :index
-
- def index
- respond_with_boards
- end
-
- def show
- @board = boards.find(params[:id])
-
- # add/update the board in the recent visited table
- Boards::Visits::CreateService.new(@board.group, current_user).execute(@board) if request.format.html?
-
- respond_with_board
- end
private
- def boards
- @boards ||= Boards::ListService.new(group, current_user).execute
- end
-
def assign_endpoint_vars
@boards_endpoint = group_boards_url(group)
@namespace_path = group.to_param
@labels_endpoint = group_labels_url(group)
end
-
- def serialize_as_json(resource)
- resource.as_json(only: [:id])
- end
-
- def includes_board?(board_id)
- boards.any? { |board| board.id == board_id }
- end
-
- def redirect_to_recent_board
- return if request.format.json?
-
- recently_visited = Boards::Visits::LatestService.new(group, current_user).execute
-
- if recently_visited && includes_board?(recently_visited.board_id)
- redirect_to(group_board_path(id: recently_visited.board_id), status: :found)
- end
- end
end
diff --git a/app/controllers/groups/children_controller.rb b/app/controllers/groups/children_controller.rb
index d549f793ad7..236a19a8dc4 100644
--- a/app/controllers/groups/children_controller.rb
+++ b/app/controllers/groups/children_controller.rb
@@ -35,7 +35,7 @@ module Groups
def setup_children(parent)
@children = GroupDescendantsFinder.new(current_user: current_user,
parent_group: parent,
- params: params).execute
+ params: params.to_unsafe_h).execute
@children = @children.page(params[:page])
end
end
diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb
index 0bc082246a1..f1d6fb00cfc 100644
--- a/app/controllers/groups/group_members_controller.rb
+++ b/app/controllers/groups/group_members_controller.rb
@@ -12,6 +12,7 @@ class Groups::GroupMembersController < Groups::ApplicationController
# Authorize
before_action :authorize_admin_group_member!, except: admin_not_required_endpoints
+ skip_before_action :check_two_factor_requirement, only: :leave
skip_cross_project_access_check :index, :create, :update, :destroy, :request_access,
:approve_access_request, :leave, :resend_invite,
:override
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb
index b42116b0f36..7ed4384089b 100644
--- a/app/controllers/groups/milestones_controller.rb
+++ b/app/controllers/groups/milestones_controller.rb
@@ -43,14 +43,7 @@ class Groups::MilestonesController < Groups::ApplicationController
def update
# Keep this compatible with legacy group milestones where we have to update
# all projects milestones states at once.
- if @milestone.legacy_group_milestone?
- update_params = milestone_params.select { |key| key == "state_event" }
- milestones = @milestone.milestones
- else
- update_params = milestone_params
- milestones = [@milestone]
- end
-
+ milestones, update_params = get_milestones_for_update
milestones.each do |milestone|
Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
end
@@ -71,6 +64,14 @@ class Groups::MilestonesController < Groups::ApplicationController
private
+ def get_milestones_for_update
+ if @milestone.legacy_group_milestone?
+ [@milestone.milestones, legacy_milestone_params]
+ else
+ [[@milestone], milestone_params]
+ end
+ end
+
def authorize_admin_milestones!
return render_404 unless can?(current_user, :admin_milestone, group)
end
@@ -79,6 +80,10 @@ class Groups::MilestonesController < Groups::ApplicationController
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end
+ def legacy_milestone_params
+ params.require(:milestone).permit(:state_event)
+ end
+
def milestone_path
if @milestone.legacy_group_milestone?
group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
@@ -110,6 +115,6 @@ class Groups::MilestonesController < Groups::ApplicationController
end
def search_params
- params.permit(:state).merge(group_ids: group.id)
+ params.permit(:state, :search_title).merge(group_ids: group.id)
end
end
diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb
index dd8fbf7a029..f8e32451b02 100644
--- a/app/controllers/groups/runners_controller.rb
+++ b/app/controllers/groups/runners_controller.rb
@@ -16,7 +16,7 @@ class Groups::RunnersController < Groups::ApplicationController
def update
if Ci::UpdateRunnerService.new(@runner).update(runner_params)
- redirect_to group_runner_path(@group, @runner), notice: 'Runner was successfully updated.'
+ redirect_to group_runner_path(@group, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
end
@@ -30,17 +30,17 @@ class Groups::RunnersController < Groups::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: 'Runner was successfully updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), notice: _('Runner was successfully updated.')
else
- redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: 'Runner was not updated.'
+ redirect_to group_settings_ci_cd_path(@group, anchor: 'runners-settings'), alert: _('Runner was not updated.')
end
end
diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb
index c1dcc463de7..c465e622de0 100644
--- a/app/controllers/groups/settings/ci_cd_controller.rb
+++ b/app/controllers/groups/settings/ci_cd_controller.rb
@@ -4,7 +4,7 @@ module Groups
module Settings
class CiCdController < Groups::ApplicationController
skip_cross_project_access_check :show
- before_action :authorize_admin_pipeline!
+ before_action :authorize_admin_group!
def show
define_ci_variables
@@ -13,7 +13,17 @@ module Groups
def reset_registration_token
@group.reset_runners_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('GroupSettings|New runners registration token has been generated!')
+ redirect_to group_settings_ci_cd_path
+ end
+
+ def update_auto_devops
+ if auto_devops_service.execute
+ flash[:notice] = s_('GroupSettings|Auto DevOps pipeline was updated for the group')
+ else
+ flash[:alert] = s_("GroupSettings|There was a problem updating Auto DevOps pipeline: %{error_messages}." % { error_messages: group.errors.full_messages })
+ end
+
redirect_to group_settings_ci_cd_path
end
@@ -26,8 +36,16 @@ module Groups
.map { |variable| variable.present(current_user: current_user) }
end
- def authorize_admin_pipeline!
- return render_404 unless can?(current_user, :admin_pipeline, group)
+ def authorize_admin_group!
+ return render_404 unless can?(current_user, :admin_group, group)
+ end
+
+ def auto_devops_params
+ params.require(:group).permit(:auto_devops_enabled)
+ end
+
+ def auto_devops_service
+ Groups::AutoDevopsService.new(group, current_user, auto_devops_params)
end
end
end
diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb
index 4f641de0357..11e3cfb01e4 100644
--- a/app/controllers/groups/variables_controller.rb
+++ b/app/controllers/groups/variables_controller.rb
@@ -41,7 +41,7 @@ module Groups
end
def variable_params_attributes
- %i[id key secret_value protected _destroy]
+ %i[id variable_type key secret_value protected masked _destroy]
end
def authorize_admin_build!
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index c5d8ac2ed77..e936d771502 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -2,10 +2,10 @@
class GroupsController < Groups::ApplicationController
include API::Helpers::RelatedResourcesHelpers
- include IssuesAction
- include MergeRequestsAction
+ include IssuableCollectionsAction
include ParamsBackwardCompatibility
include PreviewMarkdown
+ include RecordUserLastActivity
respond_to :html
@@ -58,11 +58,24 @@ class GroupsController < Groups::ApplicationController
def show
respond_to do |format|
- format.html
+ format.html do
+ render_show_html
+ end
format.atom do
- load_events
- render layout: 'xml.atom'
+ render_details_view_atom
+ end
+ end
+ end
+
+ def details
+ respond_to do |format|
+ format.html do
+ render_details_html
+ end
+
+ format.atom do
+ render_details_view_atom
end
end
end
@@ -111,14 +124,27 @@ class GroupsController < Groups::ApplicationController
flash[:notice] = "Group '#{@group.name}' was successfully transferred."
redirect_to group_path(@group)
else
- flash.now[:alert] = service.error
- render :edit
+ flash[:alert] = service.error
+ redirect_to edit_group_path(@group)
end
end
# rubocop: enable CodeReuse/ActiveRecord
protected
+ def render_show_html
+ render 'groups/show'
+ end
+
+ def render_details_html
+ render 'groups/show'
+ end
+
+ def render_details_view_atom
+ load_events
+ render layout: 'xml.atom', template: 'groups/show'
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def authorize_create_group!
allowed = if params[:parent_id].present?
@@ -161,7 +187,8 @@ class GroupsController < Groups::ApplicationController
:create_chat_team,
:chat_team_name,
:require_two_factor_authentication,
- :two_factor_grace_period
+ :two_factor_grace_period,
+ :project_creation_level
]
end
@@ -178,8 +205,8 @@ class GroupsController < Groups::ApplicationController
.includes(:namespace)
@events = EventCollection
- .new(@projects, offset: params[:offset].to_i, filter: event_filter)
- .to_a
+ .new(@projects, offset: params[:offset].to_i, filter: event_filter)
+ .to_a
Events::RenderService
.new(current_user)
diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb
index e5a1fc9d6ff..837c26c630a 100644
--- a/app/controllers/help_controller.rb
+++ b/app/controllers/help_controller.rb
@@ -7,21 +7,22 @@ class HelpController < ApplicationController
# Taken from Jekyll
# https://github.com/jekyll/jekyll/blob/3.5-stable/lib/jekyll/document.rb#L13
- YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
+ YAML_FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m.freeze
def index
# Remove YAML frontmatter so that it doesn't look weird
@help_index = File.read(Rails.root.join('doc', 'README.md')).sub(YAML_FRONT_MATTER_REGEXP, '')
- # Prefix Markdown links with `help/` unless they are external links
- # See http://rubular.com/r/X3baHTbPO2
- @help_index.gsub!(%r{(?<delim>\]\()(?!.+://)(?!/)(?<link>[^\)\(]+\))}) do
+ # Prefix Markdown links with `help/` unless they are external links.
+ # '//' not necessarily part of URL, e.g., mailto:mail@example.com
+ # See https://rubular.com/r/DFHZl5w8d3bpzV
+ @help_index.gsub!(%r{(?<delim>\]\()(?!\w+:)(?!/)(?<link>[^\)\(]+\))}) do
"#{$~[:delim]}#{Gitlab.config.gitlab.relative_url_root}/help/#{$~[:link]}"
end
end
def show
- @path = clean_path_info(path_params[:path])
+ @path = Rack::Utils.clean_path_info(path_params[:path])
respond_to do |format|
format.any(:markdown, :md, :html) do
@@ -74,35 +75,4 @@ class HelpController < ApplicationController
params
end
-
- PATH_SEPS = Regexp.union(*[::File::SEPARATOR, ::File::ALT_SEPARATOR].compact)
-
- # Taken from ActionDispatch::FileHandler
- # Cleans up the path, to prevent directory traversal outside the doc folder.
- def clean_path_info(path_info)
- parts = path_info.split(PATH_SEPS)
-
- clean = []
-
- # Walk over each part of the path
- parts.each do |part|
- # Turn `one//two` or `one/./two` into `one/two`.
- next if part.empty? || part == '.'
-
- if part == '..'
- # Turn `one/two/../` into `one`
- clean.pop
- else
- # Add simple folder names to the clean path.
- clean << part
- end
- end
-
- # If the path was an absolute path (i.e. `/` or `/one/two`),
- # add `/` to the front of the clean path.
- clean.unshift '/' if parts.empty? || parts.first.empty?
-
- # Join all the clean path parts by the path separator.
- ::File.join(*clean)
- end
end
diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb
index 042b6b1264f..9b45be6db99 100644
--- a/app/controllers/import/base_controller.rb
+++ b/app/controllers/import/base_controller.rb
@@ -18,6 +18,7 @@ class Import::BaseController < ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
+ # deprecated: being replaced by app/services/import/base_service.rb
def find_or_create_namespace(names, owner)
names = params[:target_namespace].presence || names
@@ -32,6 +33,7 @@ class Import::BaseController < ApplicationController
current_user.namespace
end
+ # deprecated: being replaced by app/services/import/base_service.rb
def project_save_error(project)
project.errors.full_messages.join(', ')
end
diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb
index 1b30b4dda36..293d76ea765 100644
--- a/app/controllers/import/bitbucket_controller.rb
+++ b/app/controllers/import/bitbucket_controller.rb
@@ -8,7 +8,7 @@ class Import::BitbucketController < Import::BaseController
rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized
def callback
- response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url)
+ response = client.auth_code.get_token(params[:code], redirect_uri: users_import_bitbucket_callback_url)
session[:bitbucket_token] = response.token
session[:bitbucket_expires_at] = response.expires_at
@@ -62,7 +62,7 @@ class Import::BitbucketController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
end
@@ -89,7 +89,7 @@ class Import::BitbucketController < Import::BaseController
end
def go_to_bitbucket_for_permissions
- redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url)
+ redirect_to client.auth_code.authorize_url(redirect_uri: users_import_bitbucket_callback_url)
end
def bitbucket_unauthorized
diff --git a/app/controllers/import/bitbucket_server_controller.rb b/app/controllers/import/bitbucket_server_controller.rb
index 575c40d5f6f..f71ea8642cd 100644
--- a/app/controllers/import/bitbucket_server_controller.rb
+++ b/app/controllers/import/bitbucket_server_controller.rb
@@ -13,7 +13,10 @@ class Import::BitbucketServerController < Import::BaseController
# Repository names are limited to 128 characters. They must start with a
# letter or number and may contain spaces, hyphens, underscores, and periods.
# (https://community.atlassian.com/t5/Answers-Developer-Questions/stash-repository-names/qaq-p/499054)
- VALID_BITBUCKET_CHARS = /\A[\w\-_\.\s]+\z/
+ #
+ # Bitbucket Server starts personal project names with a tilde.
+ VALID_BITBUCKET_PROJECT_CHARS = /\A~?[\w\-\.\s]+\z/.freeze
+ VALID_BITBUCKET_CHARS = /\A[\w\-\.\s]+\z/.freeze
def new
end
@@ -22,7 +25,7 @@ class Import::BitbucketServerController < Import::BaseController
repo = bitbucket_client.repo(@project_key, @repo_slug)
unless repo
- return render json: { errors: "Project #{@project_key}/#{@repo_slug} could not be found" }, status: :unprocessable_entity
+ return render json: { errors: _("Project %{project_repo} could not be found") % { project_repo: "#{@project_key}/#{@repo_slug}" } }, status: :unprocessable_entity
end
project_name = params[:new_name].presence || repo.name
@@ -38,10 +41,10 @@ class Import::BitbucketServerController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
- rescue BitbucketServer::Client::ServerError => e
- render json: { errors: "Unable to connect to server: #{e}" }, status: :unprocessable_entity
+ rescue BitbucketServer::Connection::ConnectionError => error
+ render json: { errors: _("Unable to connect to server: %{error}") % { error: error } }, status: :unprocessable_entity
end
def configure
@@ -62,8 +65,8 @@ class Import::BitbucketServerController < Import::BaseController
already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.reject! { |repo| already_added_projects_names.include?(repo.browse_url) }
- rescue BitbucketServer::Connection::ConnectionError, BitbucketServer::Client::ServerError => e
- flash[:alert] = "Unable to connect to server: #{e}"
+ rescue BitbucketServer::Connection::ConnectionError => error
+ flash[:alert] = _("Unable to connect to server: %{error}") % { error: error }
clear_session_data
redirect_to new_import_bitbucket_server_path
end
@@ -91,7 +94,7 @@ class Import::BitbucketServerController < Import::BaseController
return render_validation_error('Missing project key') unless @project_key.present? && @repo_slug.present?
return render_validation_error('Missing repository slug') unless @repo_slug.present?
- return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_CHARS
+ return render_validation_error('Invalid project key') unless @project_key =~ VALID_BITBUCKET_PROJECT_CHARS
return render_validation_error('Invalid repository slug') unless @repo_slug =~ VALID_BITBUCKET_CHARS
end
diff --git a/app/controllers/import/fogbugz_controller.rb b/app/controllers/import/fogbugz_controller.rb
index 5a439e6de78..a37ba682b91 100644
--- a/app/controllers/import/fogbugz_controller.rb
+++ b/app/controllers/import/fogbugz_controller.rb
@@ -14,7 +14,7 @@ class Import::FogbugzController < Import::BaseController
res = Gitlab::FogbugzImport::Client.new(import_params.symbolize_keys)
rescue
# If the URI is invalid various errors can occur
- return redirect_to new_import_fogbugz_path, alert: 'Could not connect to FogBugz, check your URL'
+ return redirect_to new_import_fogbugz_path, alert: _('Could not connect to FogBugz, check your URL')
end
session[:fogbugz_token] = res.get_token
session[:fogbugz_uri] = params[:uri]
@@ -29,14 +29,14 @@ class Import::FogbugzController < Import::BaseController
user_map = params[:users]
unless user_map.is_a?(Hash) && user_map.all? { |k, v| !v[:name].blank? }
- flash.now[:alert] = 'All users must have a name.'
+ flash.now[:alert] = _('All users must have a name.')
return render 'new_user_map'
end
session[:fogbugz_user_map] = user_map
- flash[:notice] = 'The user map has been saved. Continue by selecting the projects you want to import.'
+ flash[:notice] = _('The user map has been saved. Continue by selecting the projects you want to import.')
redirect_to status_import_fogbugz_path
end
diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb
index f067ef625aa..a23b2f8139e 100644
--- a/app/controllers/import/gitea_controller.rb
+++ b/app/controllers/import/gitea_controller.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
class Import::GiteaController < Import::GithubController
+ extend ::Gitlab::Utils::Override
+
def new
- if session[access_token_key].present? && session[host_key].present?
+ if session[access_token_key].present? && provider_url.present?
redirect_to status_import_url
end
end
@@ -12,8 +14,8 @@ class Import::GiteaController < Import::GithubController
super
end
+ # Must be defined or it will 404
def status
- @gitea_host_url = session[host_key]
super
end
@@ -23,25 +25,33 @@ class Import::GiteaController < Import::GithubController
:"#{provider}_host_url"
end
- # Overridden methods
+ override :provider
def provider
:gitea
end
+ override :provider_url
+ def provider_url
+ session[host_key]
+ end
+
# Gitea is not yet an OAuth provider
# See https://github.com/go-gitea/gitea/issues/27
+ override :logged_in_with_provider?
def logged_in_with_provider?
false
end
+ override :provider_auth
def provider_auth
- if session[access_token_key].blank? || session[host_key].blank?
+ if session[access_token_key].blank? || provider_url.blank?
redirect_to new_import_gitea_url,
- alert: 'You need to specify both an Access Token and a Host URL.'
+ alert: _('You need to specify both an Access Token and a Host URL.')
end
end
+ override :client_options
def client_options
- { host: session[host_key], api_version: 'v1' }
+ { host: provider_url, api_version: 'v1' }
end
end
diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb
index d4c26fa0709..aa4aa0fbdac 100644
--- a/app/controllers/import/github_controller.rb
+++ b/app/controllers/import/github_controller.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
class Import::GithubController < Import::BaseController
+ include ImportHelper
+
before_action :verify_import_enabled
- before_action :provider_auth, only: [:status, :jobs, :create]
+ before_action :provider_auth, only: [:status, :realtime_changes, :create]
+ before_action :expire_etag_cache, only: [:status, :create]
rescue_from Octokit::Unauthorized, with: :provider_unauthorized
@@ -24,47 +27,86 @@ class Import::GithubController < Import::BaseController
redirect_to status_import_url
end
- # rubocop: disable CodeReuse/ActiveRecord
def status
- @repos = client.repos
- @already_added_projects = find_already_added_projects(provider)
- already_added_projects_names = @already_added_projects.pluck(:import_source)
-
- @repos.reject! { |repo| already_added_projects_names.include? repo.full_name }
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def jobs
- render json: find_jobs(provider)
+ # Request repos to display error page if provider token is invalid
+ # Improving in https://gitlab.com/gitlab-org/gitlab-ce/issues/55585
+ client_repos
+
+ respond_to do |format|
+ format.json do
+ render json: { imported_projects: serialized_imported_projects,
+ provider_repos: serialized_provider_repos,
+ namespaces: serialized_namespaces }
+ end
+ format.html
+ end
end
def create
- repo = client.repo(params[:repo_id].to_i)
- project_name = params[:new_name].presence || repo.name
- namespace_path = params[:target_namespace].presence || current_user.namespace_path
- target_namespace = find_or_create_namespace(namespace_path, current_user.namespace_path)
-
- if can?(current_user, :create_projects, target_namespace)
- project = Gitlab::LegacyGithubImport::ProjectCreator
- .new(repo, project_name, target_namespace, current_user, access_params, type: provider)
- .execute(extra_project_attrs)
-
- if project.persisted?
- render json: ProjectSerializer.new.represent(project)
- else
- render json: { errors: project_save_error(project) }, status: :unprocessable_entity
- end
+ result = Import::GithubService.new(client, current_user, import_params).execute(access_params, provider)
+
+ if result[:status] == :success
+ render json: serialized_imported_projects(result[:project])
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: result[:message] }, status: result[:http_status]
end
end
+ def realtime_changes
+ Gitlab::PollingInterval.set_header(response, interval: 3_000)
+
+ render json: find_jobs(provider)
+ end
+
private
+ def import_params
+ params.permit(permitted_import_params)
+ end
+
+ def permitted_import_params
+ [:repo_id, :new_name, :target_namespace]
+ end
+
+ def serialized_imported_projects(projects = already_added_projects)
+ ProjectSerializer.new.represent(projects, serializer: :import, provider_url: provider_url)
+ end
+
+ def serialized_provider_repos
+ repos = client_repos.reject { |repo| already_added_project_names.include? repo.full_name }
+ ProviderRepoSerializer.new(current_user: current_user).represent(repos, provider: provider, provider_url: provider_url)
+ end
+
+ def serialized_namespaces
+ NamespaceSerializer.new.represent(namespaces)
+ end
+
+ def already_added_projects
+ @already_added_projects ||= find_already_added_projects(provider)
+ end
+
+ def already_added_project_names
+ @already_added_projects_names ||= already_added_projects.pluck(:import_source) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
+ def namespaces
+ current_user.manageable_groups_with_routes
+ end
+
+ def expire_etag_cache
+ Gitlab::EtagCaching::Store.new.tap do |store|
+ store.touch(realtime_changes_path)
+ end
+ end
+
def client
@client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options)
end
+ def client_repos
+ @client_repos ||= client.repos
+ end
+
def verify_import_enabled
render_404 unless import_enabled?
end
@@ -77,6 +119,10 @@ class Import::GithubController < Import::BaseController
__send__("#{provider}_import_enabled?") # rubocop:disable GitlabSecurity/PublicSend
end
+ def realtime_changes_path
+ public_send("realtime_changes_import_#{provider}_path", format: :json) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def new_import_url
public_send("new_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -86,7 +132,7 @@ class Import::GithubController < Import::BaseController
end
def callback_import_url
- public_send("callback_import_#{provider}_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
+ public_send("users_import_#{provider}_callback_url", extra_import_params) # rubocop:disable GitlabSecurity/PublicSend
end
def provider_unauthorized
@@ -108,6 +154,14 @@ class Import::GithubController < Import::BaseController
:github
end
+ def provider_url
+ strong_memoize(:provider_url) do
+ provider = Gitlab::Auth::OAuth::Provider.config_for('github')
+
+ provider&.dig('url').presence || 'https://github.com'
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def logged_in_with_provider?
current_user.identities.exists?(provider: provider)
@@ -124,10 +178,6 @@ class Import::GithubController < Import::BaseController
{}
end
- def extra_project_attrs
- {}
- end
-
def extra_import_params
{}
end
diff --git a/app/controllers/import/gitlab_controller.rb b/app/controllers/import/gitlab_controller.rb
index 498de0b07b8..5ec8e9e6fc5 100644
--- a/app/controllers/import/gitlab_controller.rb
+++ b/app/controllers/import/gitlab_controller.rb
@@ -42,7 +42,7 @@ class Import::GitlabController < Import::BaseController
render json: { errors: project_save_error(project) }, status: :unprocessable_entity
end
else
- render json: { errors: 'This namespace has already been taken! Please choose another one.' }, status: :unprocessable_entity
+ render json: { errors: _('This namespace has already been taken! Please choose another one.') }, status: :unprocessable_entity
end
end
diff --git a/app/controllers/import/gitlab_projects_controller.rb b/app/controllers/import/gitlab_projects_controller.rb
index 354fba5d204..89889141be6 100644
--- a/app/controllers/import/gitlab_projects_controller.rb
+++ b/app/controllers/import/gitlab_projects_controller.rb
@@ -13,7 +13,7 @@ class Import::GitlabProjectsController < Import::BaseController
def create
unless file_is_valid?
- return redirect_back_or_default(options: { alert: "You need to upload a GitLab project export archive (ending in .gz)." })
+ return redirect_back_or_default(options: { alert: _("You need to upload a GitLab project export archive (ending in .gz).") })
end
@project = ::Projects::GitlabProjectsImportService.new(current_user, project_params).execute
@@ -21,7 +21,7 @@ class Import::GitlabProjectsController < Import::BaseController
if @project.saved?
redirect_to(
project_path(@project),
- notice: "Project '#{@project.name}' is being imported."
+ notice: _("Project '%{project_name}' is being imported.") % { project_name: @project.name }
)
else
redirect_back_or_default(options: { alert: "Project could not be imported: #{@project.errors.full_messages.join(', ')}" })
diff --git a/app/controllers/import/google_code_controller.rb b/app/controllers/import/google_code_controller.rb
index 331f06c3dd6..4dddfbcd20d 100644
--- a/app/controllers/import/google_code_controller.rb
+++ b/app/controllers/import/google_code_controller.rb
@@ -11,18 +11,18 @@ class Import::GoogleCodeController < Import::BaseController
dump_file = params[:dump_file]
unless dump_file.respond_to?(:read)
- return redirect_back_or_default(options: { alert: "You need to upload a Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("You need to upload a Google Takeout archive.") })
end
begin
dump = JSON.parse(dump_file.read)
rescue
- return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
end
client = Gitlab::GoogleCodeImport::Client.new(dump)
unless client.valid?
- return redirect_back_or_default(options: { alert: "The uploaded file is not a valid Google Takeout archive." })
+ return redirect_back_or_default(options: { alert: _("The uploaded file is not a valid Google Takeout archive.") })
end
session[:google_code_dump] = dump
@@ -44,13 +44,13 @@ class Import::GoogleCodeController < Import::BaseController
begin
user_map = JSON.parse(user_map_json)
rescue
- flash.now[:alert] = "The entered user map is not a valid JSON user map."
+ flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
return render "new_user_map"
end
unless user_map.is_a?(Hash) && user_map.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
- flash.now[:alert] = "The entered user map is not a valid JSON user map."
+ flash.now[:alert] = _("The entered user map is not a valid JSON user map.")
return render "new_user_map"
end
@@ -62,7 +62,7 @@ class Import::GoogleCodeController < Import::BaseController
session[:google_code_user_map] = user_map
- flash[:notice] = "The user map has been saved. Continue by selecting the projects you want to import."
+ flash[:notice] = _("The user map has been saved. Continue by selecting the projects you want to import.")
redirect_to status_import_google_code_path
end
diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb
index 315d1375e02..a78d87eceea 100644
--- a/app/controllers/invites_controller.rb
+++ b/app/controllers/invites_controller.rb
@@ -13,9 +13,9 @@ class InvitesController < ApplicationController
if member.accept_invite!(current_user)
label, path = source_info(member.source)
- redirect_to path, notice: "You have been granted #{member.human_access} access to #{label}."
+ redirect_to path, notice: _("You have been granted %{member_human_access} access to %{label}.") % { member_human_access: member.human_access, label: label }
else
- redirect_back_or_default(options: { alert: "The invitation could not be accepted." })
+ redirect_back_or_default(options: { alert: _("The invitation could not be accepted.") })
end
end
@@ -30,9 +30,9 @@ class InvitesController < ApplicationController
new_user_session_path
end
- redirect_to path, notice: "You have declined the invitation to join #{label}."
+ redirect_to path, notice: _("You have declined the invitation to join %{label}.") % { label: label }
else
- redirect_back_or_default(options: { alert: "The invitation could not be declined." })
+ redirect_back_or_default(options: { alert: _("The invitation could not be declined.") })
end
end
diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb
index f9008a5b67e..5ecf4f114cf 100644
--- a/app/controllers/jwt_controller.rb
+++ b/app/controllers/jwt_controller.rb
@@ -22,7 +22,7 @@ class JwtController < ApplicationController
private
def authenticate_project_or_user
- @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_authentication_abilities)
+ @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_only_authentication_abilities)
authenticate_with_http_basic do |login, password|
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
@@ -39,9 +39,9 @@ class JwtController < ApplicationController
render json: {
errors: [
{ code: 'UNAUTHORIZED',
- message: "HTTP Basic: Access denied\n" \
- "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
- "You can generate one at #{profile_personal_access_tokens_url}" }
+ message: _('HTTP Basic: Access denied\n' \
+ 'You must use a personal access token with \'api\' scope for Git over HTTP.\n' \
+ 'You can generate one at %{profile_personal_access_tokens_url}') % { profile_personal_access_tokens_url: profile_personal_access_tokens_url } }
]
}, status: :unauthorized
end
diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb
index 5e872804448..9a5a45939e0 100644
--- a/app/controllers/ldap/omniauth_callbacks_controller.rb
+++ b/app/controllers/ldap/omniauth_callbacks_controller.rb
@@ -26,7 +26,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
override :fail_login
def fail_login(user)
- flash[:alert] = 'Access denied for your LDAP account.'
+ flash[:alert] = _('Access denied for your LDAP account.')
redirect_to new_user_session_path
end
diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb
index 384f308269a..43c4f4d220e 100644
--- a/app/controllers/notification_settings_controller.rb
+++ b/app/controllers/notification_settings_controller.rb
@@ -17,7 +17,8 @@ class NotificationSettingsController < ApplicationController
@saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source))
if params[:hide_label].present?
- render_response("projects/buttons/_notifications")
+ btn_class = params[:project_id].present? ? 'btn-xs' : ''
+ render_response("shared/notifications/_new_button", btn_class)
else
render_response
end
@@ -41,9 +42,9 @@ class NotificationSettingsController < ApplicationController
can?(current_user, ability_name, resource)
end
- def render_response(response_template = "shared/notifications/_button")
+ def render_response(response_template = "shared/notifications/_button", btn_class = nil)
render json: {
- html: view_to_html_string(response_template, notification_setting: @notification_setting),
+ html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class),
saved: @saved
}
end
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index 30be50d4595..2a8dd997d04 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -3,8 +3,9 @@
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
include AuthenticatesWithTwoFactor
include Devise::Controllers::Rememberable
+ include AuthHelper
- protect_from_forgery except: [:kerberos, :saml, :cas3], prepend: true
+ protect_from_forgery except: [:kerberos, :saml, :cas3, :failure], with: :exception, prepend: true
def handle_omniauth
omniauth_flow(Gitlab::Auth::OAuth)
@@ -75,12 +76,18 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
private
def omniauth_flow(auth_module, identity_linker: nil)
+ if fragment = request.env.dig('omniauth.params', 'redirect_fragment').presence
+ store_redirect_fragment(fragment)
+ end
+
if current_user
+ return render_403 unless link_provider_allowed?(oauth['provider'])
+
log_audit_event(current_user, with: oauth['provider'])
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth)
- identity_linker.link
+ link_identity(identity_linker)
if identity_linker.changed?
redirect_identity_linked
@@ -94,16 +101,20 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
+ def link_identity(identity_linker)
+ identity_linker.link
+ end
+
def redirect_identity_exists
redirect_to after_sign_in_path_for(current_user)
end
def redirect_identity_link_failed(error_message)
- redirect_to profile_account_path, notice: "Authentication failed: #{error_message}"
+ redirect_to profile_account_path, notice: _("Authentication failed: %{error_message}") % { error_message: error_message }
end
def redirect_identity_linked
- redirect_to profile_account_path, notice: 'Authentication method updated'
+ redirect_to profile_account_path, notice: _('Authentication method updated')
end
def handle_service_ticket(provider, ticket)
@@ -112,8 +123,12 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
session[:service_tickets][provider] = ticket
end
+ def build_auth_user(auth_user_class)
+ auth_user_class.new(oauth)
+ end
+
def sign_in_user_flow(auth_user_class)
- auth_user = auth_user_class.new(oauth)
+ auth_user = build_auth_user(auth_user_class)
user = auth_user.find_and_update!
if auth_user.valid_sign_in?
@@ -137,10 +152,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def handle_signup_error
label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
- message = ["Signing in using your #{label} account without a pre-existing GitLab account is not allowed."]
+ message = [_("Signing in using your %{label} account without a pre-existing GitLab account is not allowed.") % { label: label }]
if Gitlab::CurrentSettings.allow_signup?
- message << "Create a GitLab account first, and then connect it to your #{label} account."
+ message << _("Create a GitLab account first, and then connect it to your %{label} account.") % { label: label }
end
flash[:notice] = message.join(' ')
@@ -158,14 +173,14 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def fail_auth0_login
- flash[:alert] = 'Wrong extern UID provided. Make sure Auth0 is configured correctly.'
+ flash[:alert] = _('Wrong extern UID provided. Make sure Auth0 is configured correctly.')
redirect_to new_user_session_path
end
def handle_disabled_provider
label = Gitlab::Auth::OAuth::Provider.label_for(oauth['provider'])
- flash[:alert] = "Signing in using #{label} has been disabled"
+ flash[:alert] = _("Signing in using %{label} has been disabled") % { label: label }
redirect_to new_user_session_path
end
@@ -189,4 +204,13 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
request_params = request.env['omniauth.params']
(request_params['remember_me'] == '1') if request_params.present?
end
+
+ def store_redirect_fragment(redirect_fragment)
+ key = stored_location_key_for(:user)
+ location = session[key]
+ if uri = parse_uri(location)
+ uri.fragment = redirect_fragment
+ store_location_for(:user, uri.to_s)
+ end
+ end
end
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
index 2912a22411e..77de5cb45c9 100644
--- a/app/controllers/passwords_controller.rb
+++ b/app/controllers/passwords_controller.rb
@@ -5,7 +5,7 @@ class PasswordsController < Devise::PasswordsController
before_action :resource_from_email, only: [:create]
before_action :check_password_authentication_available, only: [:create]
- before_action :throttle_reset, only: [:create]
+ before_action :throttle_reset, only: [:create]
# rubocop: disable CodeReuse/ActiveRecord
def edit
@@ -22,7 +22,7 @@ class PasswordsController < Devise::PasswordsController
).first_or_initialize
unless user.reset_password_period_valid?
- flash[:alert] = 'Your password reset token has expired.'
+ flash[:alert] = _('Your password reset token has expired.')
redirect_to(new_user_password_url(user_email: user['email']))
end
end
@@ -52,7 +52,7 @@ class PasswordsController < Devise::PasswordsController
end
redirect_to after_sending_reset_password_instructions_path_for(resource_name),
- alert: "Password authentication is unavailable."
+ alert: _("Password authentication is unavailable.")
end
def throttle_reset
diff --git a/app/controllers/profiles/accounts_controller.rb b/app/controllers/profiles/accounts_controller.rb
index b0d65f284af..0d2a6145d0e 100644
--- a/app/controllers/profiles/accounts_controller.rb
+++ b/app/controllers/profiles/accounts_controller.rb
@@ -14,7 +14,7 @@ class Profiles::AccountsController < Profiles::ApplicationController
return render_404 unless identity
- if unlink_allowed?(provider)
+ if unlink_provider_allowed?(provider)
identity.destroy
else
flash[:alert] = "You are not allowed to unlink your primary login account"
diff --git a/app/controllers/profiles/active_sessions_controller.rb b/app/controllers/profiles/active_sessions_controller.rb
index efe7ede5efa..c473023cacb 100644
--- a/app/controllers/profiles/active_sessions_controller.rb
+++ b/app/controllers/profiles/active_sessions_controller.rb
@@ -2,15 +2,6 @@
class Profiles::ActiveSessionsController < Profiles::ApplicationController
def index
- @sessions = ActiveSession.list(current_user)
- end
-
- def destroy
- ActiveSession.destroy(current_user, params[:id])
-
- respond_to do |format|
- format.html { redirect_to profile_active_sessions_url, status: :found }
- format.js { head :ok }
- end
+ @sessions = ActiveSession.list(current_user).reject(&:is_impersonated)
end
end
diff --git a/app/controllers/profiles/chat_names_controller.rb b/app/controllers/profiles/chat_names_controller.rb
index 2e78b9e6dc7..80b8279e91e 100644
--- a/app/controllers/profiles/chat_names_controller.rb
+++ b/app/controllers/profiles/chat_names_controller.rb
@@ -15,9 +15,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
new_chat_name = current_user.chat_names.new(chat_name_params)
if new_chat_name.save
- flash[:notice] = "Authorized #{new_chat_name.chat_name}"
+ flash[:notice] = _("Authorized %{new_chat_name}") % { new_chat_name: new_chat_name.chat_name }
else
- flash[:alert] = "Could not authorize chat nickname. Try again!"
+ flash[:alert] = _("Could not authorize chat nickname. Try again!")
end
delete_chat_name_token
@@ -27,7 +27,7 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
def deny
delete_chat_name_token
- flash[:notice] = "Denied authorization of chat nickname #{chat_name_params[:user_name]}."
+ flash[:notice] = _("Denied authorization of chat nickname %{user_name}.") % { user_name: chat_name_params[:user_name] }
redirect_to profile_chat_names_path
end
@@ -36,9 +36,9 @@ class Profiles::ChatNamesController < Profiles::ApplicationController
@chat_name = chat_names.find(params[:id])
if @chat_name.destroy
- flash[:notice] = "Deleted chat nickname: #{@chat_name.chat_name}!"
+ flash[:notice] = _("Deleted chat nickname: %{chat_name}!") % { chat_name: @chat_name.chat_name }
else
- flash[:alert] = "Could not delete chat nickname #{@chat_name.chat_name}."
+ flash[:alert] = _("Could not delete chat nickname %{chat_name}.") % { chat_name: @chat_name.chat_name }
end
redirect_to profile_chat_names_path, status: :found
diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb
index dcee8eb7e6e..055d900eece 100644
--- a/app/controllers/profiles/keys_controller.rb
+++ b/app/controllers/profiles/keys_controller.rb
@@ -40,7 +40,6 @@ class Profiles::KeysController < Profiles::ApplicationController
begin
user = UserFinder.new(params[:username]).find_by_username
if user.present?
- headers['Content-Disposition'] = 'attachment'
render plain: user.all_ssh_keys.join("\n")
else
return render_404
diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb
index b719b70c56e..617e5bb7cb3 100644
--- a/app/controllers/profiles/notifications_controller.rb
+++ b/app/controllers/profiles/notifications_controller.rb
@@ -14,9 +14,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
if result[:status] == :success
- flash[:notice] = "Notification settings saved"
+ flash[:notice] = _("Notification settings saved")
else
- flash[:alert] = "Failed to save new settings"
+ flash[:alert] = _("Failed to save new settings")
end
redirect_back_or_default(default: profile_notifications_path)
diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb
index a0391d677c4..7038447581c 100644
--- a/app/controllers/profiles/passwords_controller.rb
+++ b/app/controllers/profiles/passwords_controller.rb
@@ -55,7 +55,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController
flash[:notice] = "Password was successfully updated. Please login with it"
redirect_to new_user_session_path
else
- @user.reload
+ @user.reset
render 'edit'
end
end
diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb
index 4b6ec2697b7..f1c07cd9a1d 100644
--- a/app/controllers/profiles/personal_access_tokens_controller.rb
+++ b/app/controllers/profiles/personal_access_tokens_controller.rb
@@ -11,7 +11,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
if @personal_access_token.save
PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token)
- redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created."
+ redirect_to profile_personal_access_tokens_path, notice: _("Your new personal access token has been created.")
else
set_index_vars
render :index
@@ -22,9 +22,9 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
@personal_access_token = finder.find(params[:id])
if @personal_access_token.revoke!
- flash[:notice] = "Revoked personal access token #{@personal_access_token.name}!"
+ flash[:notice] = _("Revoked personal access token %{personal_access_token_name}!") % { personal_access_token_name: @personal_access_token.name }
else
- flash[:alert] = "Could not revoke personal access token #{@personal_access_token.name}."
+ flash[:alert] = _("Could not revoke personal access token %{personal_access_token_name}.") % { personal_access_token_name: @personal_access_token.name }
end
redirect_to profile_personal_access_tokens_path
@@ -42,7 +42,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def set_index_vars
- @scopes = Gitlab::Auth.available_scopes(current_user)
+ @scopes = Gitlab::Auth.available_scopes_for(current_user)
@inactive_personal_access_tokens = finder(state: 'inactive').execute
@active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at)
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 37ac11dc6a1..62f98d9e549 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -11,13 +11,13 @@ class Profiles::PreferencesController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, preferences_params.merge(user: user)).execute
if result[:status] == :success
- flash[:notice] = 'Preferences saved.'
+ flash[:notice] = _('Preferences saved.')
else
- flash[:alert] = 'Failed to save preferences.'
+ flash[:alert] = _('Failed to save preferences.')
end
rescue ArgumentError => e
# Raised when `dashboard` is given an invalid value.
- flash[:alert] = "Failed to save preferences (#{e.message})."
+ flash[:alert] = _("Failed to save preferences (%{error_message}).") % { error_message: e.message }
end
respond_to do |format|
@@ -33,12 +33,20 @@ class Profiles::PreferencesController < Profiles::ApplicationController
end
def preferences_params
- params.require(:user).permit(
+ params.require(:user).permit(preferences_param_names)
+ end
+
+ def preferences_param_names
+ [
:color_scheme_id,
:layout,
:dashboard,
:project_view,
- :theme_id
- )
+ :theme_id,
+ :first_day_of_week,
+ :preferred_language,
+ :time_display_relative,
+ :time_format_in_24h
+ ]
end
end
diff --git a/app/controllers/profiles/two_factor_auths_controller.rb b/app/controllers/profiles/two_factor_auths_controller.rb
index ba94196b2f9..83e14275a8b 100644
--- a/app/controllers/profiles/two_factor_auths_controller.rb
+++ b/app/controllers/profiles/two_factor_auths_controller.rb
@@ -18,21 +18,16 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
two_factor_authentication_reason(
global: lambda do
flash.now[:alert] =
- 'The global settings require you to enable Two-Factor Authentication for your account.'
+ s_('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
+ flash.now[:alert] = groups_notification(groups)
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] = flash.now[:alert] + " You need to do this before #{l(grace_period_deadline)}."
+ flash.now[:alert] = flash.now[:alert] + s_(" You need to do this before %{grace_period_deadline}.") % { grace_period_deadline: l(grace_period_deadline) }
end
end
@@ -49,7 +44,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
render 'create'
else
- @error = 'Invalid pin code'
+ @error = s_('Invalid pin code')
@qr_code = build_qr_code
setup_u2f_registration
render 'show'
@@ -63,7 +58,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
if @u2f_registration.persisted?
session.delete(:challenges)
- redirect_to profile_two_factor_auth_path, notice: "Your U2F device was registered!"
+ redirect_to profile_two_factor_auth_path, notice: s_("Your U2F device was registered!")
else
@qr_code = build_qr_code
setup_u2f_registration
@@ -85,7 +80,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def skip
if two_factor_grace_period_expired?
- redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
+ redirect_to new_profile_two_factor_auth_path, alert: s_('Cannot skip two factor authentication setup')
else
session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
redirect_to root_path
@@ -126,4 +121,12 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
def u2f_registration_params
params.require(:u2f_registration).permit(:device_response, :name)
end
+
+ def groups_notification(groups)
+ group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
+ leave_group_links = groups.map { |group| view_context.link_to (s_("leave %{group_name}") % { group_name: group.full_name }), leave_group_members_path(group), remote: false, method: :delete}.to_sentence
+
+ s_(%{The group settings for %{group_links} require you to enable Two-Factor Authentication for your account. You can %{leave_group_links}.})
+ .html_safe % { group_links: group_links.html_safe, leave_group_links: leave_group_links.html_safe }
+ end
end
diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb
index e6a154fb6aa..866c4dee6e2 100644
--- a/app/controllers/profiles/u2f_registrations_controller.rb
+++ b/app/controllers/profiles/u2f_registrations_controller.rb
@@ -4,6 +4,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController
def destroy
u2f_registration = current_user.u2f_registrations.find(params[:id])
u2f_registration.destroy
- redirect_to profile_two_factor_auth_path, status: 302, notice: "Successfully deleted U2F device."
+ redirect_to profile_two_factor_auth_path, status: 302, notice: _("Successfully deleted U2F device.")
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index 15248d2d08f..1d16ddb1608 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -15,7 +15,7 @@ class ProfilesController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute
if result[:status] == :success
- message = "Profile was successfully updated"
+ message = s_("Profiles|Profile was successfully updated")
format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) }
format.json { render json: { message: message } }
@@ -31,7 +31,7 @@ class ProfilesController < Profiles::ApplicationController
user.reset_incoming_email_token!
end
- flash[:notice] = "Incoming email token was successfully reset"
+ flash[:notice] = s_("Profiles|Incoming email token was successfully reset")
redirect_to profile_personal_access_tokens_path
end
@@ -41,7 +41,7 @@ class ProfilesController < Profiles::ApplicationController
user.reset_feed_token!
end
- flash[:notice] = 'Feed token was successfully reset'
+ flash[:notice] = s_('Profiles|Feed token was successfully reset')
redirect_to profile_personal_access_tokens_path
end
@@ -104,9 +104,9 @@ class ProfilesController < Profiles::ApplicationController
:username,
:website_url,
:organization,
- :preferred_language,
:private_profile,
:include_private_contributions,
+ :timezone,
status: [:emoji, :message]
)
end
diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb
index e0677ce3fbc..80e4f54bbf4 100644
--- a/app/controllers/projects/application_controller.rb
+++ b/app/controllers/projects/application_controller.rb
@@ -3,7 +3,6 @@
class Projects::ApplicationController < ApplicationController
include CookiesHelper
include RoutableActions
- include ProjectUnauthorized
include ChecksCollaboration
skip_before_action :authenticate_user!
@@ -17,12 +16,12 @@ class Projects::ApplicationController < ApplicationController
def project
return @project if @project
- return nil unless params[:project_id] || params[:id]
+ return unless params[:project_id] || params[:id]
path = File.join(params[:namespace_id], params[:project_id] || params[:id])
auth_proc = ->(project) { !project.pending_delete? }
- @project = find_routable!(Project, path, extra_authorization_proc: auth_proc, not_found_or_authorized_proc: project_unauthorized_proc)
+ @project = find_routable!(Project, path, extra_authorization_proc: auth_proc)
end
def build_canonical_path(project)
@@ -88,4 +87,10 @@ class Projects::ApplicationController < ApplicationController
def check_issues_available!
return render_404 unless @project.feature_available?(:issues, current_user)
end
+
+ def allow_gitaly_ref_name_caching
+ ::Gitlab::GitalyClient.allow_ref_name_caching do
+ yield
+ end
+ end
end
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 1a91e07b97f..2ef18d900f2 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -85,20 +85,15 @@ class Projects::ArtifactsController < Projects::ApplicationController
end
end
- # rubocop: disable CodeReuse/ActiveRecord
def build_from_id
- project.builds.find_by(id: params[:job_id]) if params[:job_id]
+ project.builds.find_by_id(params[:job_id]) if params[:job_id]
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def build_from_ref
return unless @ref_name
- builds = project.latest_successful_builds_for(@ref_name)
- builds.find_by(name: params[:job])
+ project.latest_successful_build_for(params[:job], @ref_name)
end
- # rubocop: enable CodeReuse/ActiveRecord
def artifacts_file
@artifacts_file ||= build&.artifacts_file_for_type(params[:file_type] || :archive)
diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb
index 9c130af8394..0e3f13045ce 100644
--- a/app/controllers/projects/autocomplete_sources_controller.rb
+++ b/app/controllers/projects/autocomplete_sources_controller.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class Projects::AutocompleteSourcesController < Projects::ApplicationController
+ before_action :authorize_read_milestone!, only: :milestones
+
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end
diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb
index c24bf211760..09a384e89ab 100644
--- a/app/controllers/projects/badges_controller.rb
+++ b/app/controllers/projects/badges_controller.rb
@@ -21,11 +21,22 @@ class Projects::BadgesController < Projects::ApplicationController
private
+ def badge_layout
+ case params[:style]
+ when 'flat'
+ 'badge'
+ when 'flat-square'
+ 'badge_flat-square'
+ else
+ 'badge'
+ end
+ end
+
def render_badge(badge)
respond_to do |format|
format.html { render_404 }
format.svg do
- render 'badge', locals: { badge: badge.template }
+ render badge_layout, locals: { badge: badge.template }
end
end
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 60fabd15333..b04ffe80db4 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -9,6 +9,8 @@ class Projects::BlobController < Projects::ApplicationController
include ActionView::Helpers::SanitizeHelper
prepend_before_action :authenticate_user!, only: [:edit]
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :authorize_download_code!
@@ -29,7 +31,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def create
- create_commit(Files::CreateService, success_notice: "The file has been successfully created.",
+ create_commit(Files::CreateService, success_notice: _("The file has been successfully created."),
success_path: -> { project_blob_path(@project, File.join(@branch_name, @file_path)) },
failure_view: :new,
failure_path: project_new_blob_path(@project, @ref))
@@ -81,7 +83,7 @@ class Projects::BlobController < Projects::ApplicationController
end
def destroy
- create_commit(Files::DeleteService, success_notice: "The file has been successfully deleted.",
+ create_commit(Files::DeleteService, success_notice: _("The file has been successfully deleted."),
success_path: -> { after_delete_path },
failure_view: :show,
failure_path: project_blob_path(@project, @id))
@@ -90,65 +92,21 @@ class Projects::BlobController < Projects::ApplicationController
def diff
apply_diff_view_cookie!
- @blob.load_all_data!
- @lines = @blob.present.highlight.lines
-
- @form = UnfoldForm.new(params)
-
- @lines = @lines[@form.since - 1..@form.to - 1].map(&:html_safe)
-
- if @form.bottom?
- @match_line = ''
- else
- lines_length = @lines.length - 1
- line = [@form.since, lines_length].join(',')
- @match_line = "@@ -#{line}+#{line} @@"
- end
+ @form = Blobs::UnfoldPresenter.new(blob, params.to_unsafe_h)
- # We can keep only 'render_diff_lines' from this conditional when
+ # keep only json rendering when
# https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done
if rendered_for_merge_request?
- render_diff_lines
+ render json: DiffLineSerializer.new.represent(@form.diff_lines)
else
+ @lines = @form.lines
+ @match_line = @form.match_line_text
render layout: false
end
end
private
- # Converts a String array to Gitlab::Diff::Line array
- def render_diff_lines
- @lines.map! do |line|
- # These are marked as context lines but are loaded from blobs.
- # We also have context lines loaded from diffs in other places.
- diff_line = Gitlab::Diff::Line.new(line, nil, nil, nil, nil)
- diff_line.rich_text = line
- diff_line
- end
-
- add_match_line
-
- render json: DiffLineSerializer.new.represent(@lines)
- end
-
- def add_match_line
- return unless @form.unfold?
-
- if @form.bottom? && @form.to < @blob.lines.size
- old_pos = @form.to - @form.offset
- new_pos = @form.to
- elsif @form.since != 1
- old_pos = new_pos = @form.since
- end
-
- # Match line is not needed when it reaches the top limit or bottom limit of the file.
- return unless new_pos
-
- @match_line = Gitlab::Diff::Line.new(@match_line, 'match', nil, old_pos, new_pos)
-
- @form.bottom? ? @lines.push(@match_line) : @lines.unshift(@match_line)
- end
-
def blob
@blob ||= @repository.blob_at(@commit.id, @path)
@@ -216,8 +174,7 @@ class Projects::BlobController < Projects::ApplicationController
end
if params[:file].present?
- params[:content] = Base64.encode64(params[:file].read)
- params[:encoding] = 'base64'
+ params[:content] = params[:file]
end
@commit_params = {
@@ -231,6 +188,8 @@ class Projects::BlobController < Projects::ApplicationController
end
def validate_diff_params
+ return if params[:full]
+
if [:since, :to, :offset].any? { |key| params[key].blank? }
head :ok
end
@@ -260,7 +219,7 @@ class Projects::BlobController < Projects::ApplicationController
extension: blob.extension,
size: blob.raw_size,
mime_type: blob.mime_type,
- binary: blob.raw_binary?,
+ binary: blob.binary?,
simple_viewer: blob.simple_viewer&.class&.partial_name,
rich_viewer: blob.rich_viewer&.class&.partial_name,
show_viewer_switcher: !!blob.show_viewer_switcher?,
diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb
index 8189b5d182a..95897aaf980 100644
--- a/app/controllers/projects/boards_controller.rb
+++ b/app/controllers/projects/boards_controller.rb
@@ -1,34 +1,15 @@
# frozen_string_literal: true
class Projects::BoardsController < Projects::ApplicationController
- include BoardsResponses
+ include BoardsActions
include IssuableCollections
before_action :check_issues_available!
before_action :authorize_read_board!, only: [:index, :show]
- before_action :boards, only: :index
before_action :assign_endpoint_vars
- before_action :redirect_to_recent_board, only: :index
-
- def index
- respond_with_boards
- end
-
- def show
- @board = boards.find(params[:id])
-
- # add/update the board in the recent visited table
- Boards::Visits::CreateService.new(@board.project, current_user).execute(@board) if request.format.html?
-
- respond_with_board
- end
private
- def boards
- @boards ||= Boards::ListService.new(project, current_user).execute
- end
-
def assign_endpoint_vars
@boards_endpoint = project_boards_path(project)
@bulk_issues_path = bulk_update_project_issues_path(project)
@@ -39,22 +20,4 @@ class Projects::BoardsController < Projects::ApplicationController
def authorize_read_board!
access_denied! unless can?(current_user, :read_board, project)
end
-
- def serialize_as_json(resource)
- resource.as_json(only: [:id])
- end
-
- def includes_board?(board_id)
- boards.any? { |board| board.id == board_id }
- end
-
- def redirect_to_recent_board
- return if request.format.json?
-
- recently_visited = Boards::Visits::LatestService.new(project, current_user).execute
-
- if recently_visited && includes_board?(recently_visited.board_id)
- redirect_to(namespace_project_board_path(id: recently_visited.board_id), status: :found)
- end
- end
end
diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb
index a6bfb913900..fc708400657 100644
--- a/app/controllers/projects/branches_controller.rb
+++ b/app/controllers/projects/branches_controller.rb
@@ -25,11 +25,12 @@ class Projects::BranchesController < Projects::ApplicationController
@refs_pipelines = @project.ci_pipelines.latest_successful_for_refs(@branches.map(&:name))
@merged_branch_names = repository.merged_branch_names(@branches.map(&:name))
- # n+1: https://gitlab.com/gitlab-org/gitaly/issues/992
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/48097
Gitlab::GitalyClient.allow_n_plus_1_calls do
@max_commits = @branches.reduce(0) do |memo, branch|
diverging_commit_counts = repository.diverging_commit_counts(branch)
- [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max
+ [memo, diverging_commit_counts.values_at(:behind, :ahead, :distance)]
+ .flatten.compact.max
end
end
@@ -52,7 +53,7 @@ class Projects::BranchesController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def create
- branch_name = sanitize(strip_tags(params[:branch_name]))
+ branch_name = strip_tags(sanitize(params[:branch_name]))
branch_name = Addressable::URI.unescape(branch_name)
redirect_to_autodeploy = project.empty_repo? && project.deployment_platform.present?
@@ -99,14 +100,14 @@ class Projects::BranchesController < Projects::ApplicationController
respond_to do |format|
format.html do
- flash_type = result[:status] == :error ? :alert : :notice
- flash[flash_type] = result[:message]
+ flash_type = result.error? ? :alert : :notice
+ flash[flash_type] = result.message
redirect_to project_branches_path(@project), status: :see_other
end
- format.js { head result[:return_code] }
- format.json { render json: { message: result[:message] }, status: result[:return_code] }
+ format.js { head result.http_status }
+ format.json { render json: { message: result.message }, status: result.http_status }
end
end
@@ -114,14 +115,14 @@ class Projects::BranchesController < Projects::ApplicationController
DeleteMergedBranchesService.new(@project, current_user).async_execute
redirect_to project_branches_path(@project),
- notice: 'Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.'
+ notice: _('Merged branches are being deleted. This can take some time depending on the number of branches. Please refresh the page to see changes.')
end
private
def ref
if params[:ref]
- ref_escaped = sanitize(strip_tags(params[:ref]))
+ ref_escaped = strip_tags(sanitize(params[:ref]))
Addressable::URI.unescape(ref_escaped)
else
@project.default_branch || 'master'
@@ -142,7 +143,7 @@ class Projects::BranchesController < Projects::ApplicationController
def redirect_for_legacy_index_sort_or_search
# Normalize a legacy URL with redirect
if request.format != :json && !params[:state].presence && [:sort, :search, :page].any? { |key| params[key].presence }
- redirect_to project_branches_filtered_path(@project, state: 'all'), notice: 'Update your bookmarked URLs as filtered/sorted branches URL has been changed.'
+ redirect_to project_branches_filtered_path(@project, state: 'all'), notice: _('Update your bookmarked URLs as filtered/sorted branches URL has been changed.')
end
end
diff --git a/app/controllers/projects/build_artifacts_controller.rb b/app/controllers/projects/build_artifacts_controller.rb
index 7d4d566499c..4274c356227 100644
--- a/app/controllers/projects/build_artifacts_controller.rb
+++ b/app/controllers/projects/build_artifacts_controller.rb
@@ -44,18 +44,13 @@ class Projects::BuildArtifactsController < Projects::ApplicationController
@job ||= job_from_id || job_from_ref
end
- # rubocop: disable CodeReuse/ActiveRecord
def job_from_id
- project.builds.find_by(id: params[:build_id]) if params[:build_id]
+ project.builds.find_by_id(params[:build_id]) if params[:build_id]
end
- # rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def job_from_ref
return unless @ref_name
- jobs = project.latest_successful_builds_for(@ref_name)
- jobs.find_by(name: params[:job])
+ project.latest_successful_build_for(params[:job], @ref_name)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb
index 2090af0a111..d7a0b7ece14 100644
--- a/app/controllers/projects/ci/lints_controller.rb
+++ b/app/controllers/projects/ci/lints_controller.rb
@@ -8,7 +8,7 @@ class Projects::Ci::LintsController < Projects::ApplicationController
def create
@content = params[:content]
- @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options)
+ @error = Gitlab::Ci::YamlProcessor.validation_message(@content, yaml_processor_options)
@status = @error.blank?
if @error.blank?
@@ -24,6 +24,10 @@ class Projects::Ci::LintsController < Projects::ApplicationController
private
def yaml_processor_options
- { project: @project, sha: project.repository.commit.sha }
+ {
+ project: @project,
+ user: current_user,
+ sha: project.repository.commit.sha
+ }
end
end
diff --git a/app/controllers/projects/clusters/applications_controller.rb b/app/controllers/projects/clusters/applications_controller.rb
index c7b6218d007..2a04b007304 100644
--- a/app/controllers/projects/clusters/applications_controller.rb
+++ b/app/controllers/projects/clusters/applications_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Projects::Clusters::ApplicationsController < Clusters::ApplicationsController
- include ProjectUnauthorized
-
prepend_before_action :project
private
@@ -12,6 +10,6 @@ class Projects::Clusters::ApplicationsController < Clusters::ApplicationsControl
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]))
end
end
diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb
index feda6deeaa6..cb02581da37 100644
--- a/app/controllers/projects/clusters_controller.rb
+++ b/app/controllers/projects/clusters_controller.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class Projects::ClustersController < Clusters::ClustersController
- include ProjectUnauthorized
-
prepend_before_action :project
before_action :repository
@@ -15,7 +13,7 @@ class Projects::ClustersController < Clusters::ClustersController
end
def project
- @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]), not_found_or_authorized_proc: project_unauthorized_proc)
+ @project ||= find_routable!(Project, File.join(params[:namespace_id], params[:project_id]))
end
def repository
diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb
index 32fc5140366..939a09d4fd2 100644
--- a/app/controllers/projects/commit_controller.rb
+++ b/app/controllers/projects/commit_controller.rb
@@ -24,10 +24,10 @@ class Projects::CommitController < Projects::ApplicationController
apply_diff_view_cookie!
respond_to do |format|
- format.html do
+ format.html do
render
end
- format.diff do
+ format.diff do
send_git_diff(@project.repository, @commit.diff_refs)
end
format.patch do
@@ -65,7 +65,11 @@ class Projects::CommitController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def merge_requests
- @merge_requests = @commit.merge_requests.map do |mr|
+ @merge_requests = MergeRequestsFinder.new(
+ current_user,
+ project_id: @project.id,
+ commit_sha: @commit.sha
+ ).execute.map do |mr|
{ iid: mr.iid, path: merge_request_path(mr), title: mr.title }
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index 2510a31c9b3..f540ccee386 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -7,6 +7,7 @@ class Projects::CommitsController < Projects::ApplicationController
include RendersCommits
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching
before_action :whitelist_query_limiting, except: :commits_root
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 0a593bd35b6..514b03e23b5 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -24,7 +24,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
end
def create
- @key = DeployKeys::CreateService.new(current_user, create_params).execute
+ @key = DeployKeys::CreateService.new(current_user, create_params).execute(project: @project)
unless @key.valid?
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
@@ -38,7 +38,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
def update
if deploy_key.update(update_params)
- flash[:notice] = 'Deploy key was successfully updated.'
+ flash[:notice] = _('Deploy key was successfully updated.')
redirect_to_repository_settings(@project, anchor: 'js-deploy-keys-settings')
else
render 'edit'
diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb
index b62606067c0..028390c7e2a 100644
--- a/app/controllers/projects/discussions_controller.rb
+++ b/app/controllers/projects/discussions_controller.rb
@@ -40,7 +40,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
def render_json_with_discussions_serializer
render json:
- DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity)
+ DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity)
.represent(discussion, context: self, render_truncated_diff_lines: true)
end
diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb
new file mode 100644
index 00000000000..f8ef23cd83e
--- /dev/null
+++ b/app/controllers/projects/environments/prometheus_api_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+class Projects::Environments::PrometheusApiController < Projects::ApplicationController
+ before_action :authorize_read_prometheus!
+ before_action :environment
+
+ def proxy
+ result = Prometheus::ProxyService.new(
+ environment,
+ proxy_method,
+ proxy_path,
+ proxy_params
+ ).execute
+
+ if result.nil?
+ return render status: :accepted, json: {
+ status: _('processing'),
+ message: _('Not ready yet. Try again later.')
+ }
+ end
+
+ if result[:status] == :success
+ render status: result[:http_status], json: result[:body]
+ else
+ render(
+ status: result[:http_status] || :bad_request,
+ json: { status: result[:status], message: result[:message] }
+ )
+ end
+ end
+
+ private
+
+ def query_context
+ Gitlab::Prometheus::QueryVariables.call(environment)
+ end
+
+ def environment
+ @environment ||= project.environments.find(params[:id])
+ end
+
+ def proxy_method
+ request.method
+ end
+
+ def proxy_path
+ params[:proxy_path]
+ end
+
+ def proxy_params
+ substitute_query_variables(params).permit!
+ end
+
+ def substitute_query_variables(params)
+ query = params[:query]
+ return params unless query
+
+ params.merge(query: query % query_context)
+ end
+end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index a63eea0ca0e..c342e1c80b0 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -10,9 +10,11 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
-
- before_action do
- push_frontend_feature_flag(:area_chart, project)
+ before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do
+ push_frontend_feature_flag(:metrics_time_window)
+ push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint)
+ push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards)
+ push_frontend_feature_flag(:grafana_dashboard_link)
end
def index
@@ -25,11 +27,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
Gitlab::PollingInterval.set_header(response, interval: 3_000)
render json: {
- environments: EnvironmentSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .within_folders
- .represent(@environments),
+ environments: serialize_environments(request, response, params[:nested]),
available_count: project.environments.available.count,
stopped_count: project.environments.stopped.count
}
@@ -37,6 +35,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ # Returns all environments for a given folder
# rubocop: disable CodeReuse/ActiveRecord
def folder
folder_environments = project.environments.where(environment_type: params[:id])
@@ -48,10 +47,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
format.html
format.json do
render json: {
- environments: EnvironmentSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@environments),
+ environments: serialize_environments(request, response),
available_count: folder_environments.available.count,
stopped_count: folder_environments.stopped.count
}
@@ -124,7 +120,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
terminal = environment.terminals.try(:first)
if terminal
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(terminal)
+ render json: Gitlab::Workhorse.channel_websocket(terminal)
else
render html: 'Not found', status: :not_found
end
@@ -141,13 +137,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
def metrics
- # Currently, this acts as a hint to load the metrics details into the cache
- # if they aren't there already
- @metrics = environment.metrics || {}
-
respond_to do |format|
format.html
format.json do
+ # Currently, this acts as a hint to load the metrics details into the cache
+ # if they aren't there already
+ @metrics = environment.metrics || {}
+
render json: @metrics, status: @metrics.any? ? :ok : :no_content
end
end
@@ -156,13 +152,50 @@ class Projects::EnvironmentsController < Projects::ApplicationController
def additional_metrics
respond_to do |format|
format.json do
- additional_metrics = environment.additional_metrics || {}
+ additional_metrics = environment.additional_metrics(*metrics_params) || {}
render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content
end
end
end
+ def metrics_dashboard
+ return render_403 unless Feature.enabled?(:environment_metrics_use_prometheus_endpoint, project)
+
+ if Feature.enabled?(:environment_metrics_show_multiple_dashboards, project)
+ result = dashboard_finder.find(project, current_user, environment, params[:dashboard])
+
+ result[:all_dashboards] = project.repository.metrics_dashboard_paths
+ else
+ result = dashboard_finder.find(project, current_user, environment)
+ end
+
+ respond_to do |format|
+ if result[:status] == :success
+ format.json do
+ render status: :ok, json: result.slice(:all_dashboards, :dashboard, :status)
+ end
+ else
+ format.json do
+ render(
+ status: result[:http_status],
+ json: result.slice(:all_dashboards, :message, :status)
+ )
+ end
+ end
+ end
+ end
+
+ def search
+ respond_to do |format|
+ format.json do
+ environment_names = search_environment_names
+
+ render json: environment_names, status: environment_names.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
@@ -186,6 +219,30 @@ class Projects::EnvironmentsController < Projects::ApplicationController
@environment ||= project.environments.find(params[:id])
end
+ def metrics_params
+ return unless Feature.enabled?(:metrics_time_window, project)
+
+ params.require([:start, :end])
+ end
+
+ def dashboard_finder
+ Gitlab::Metrics::Dashboard::Finder
+ end
+
+ def search_environment_names
+ return [] unless params[:query]
+
+ project.environments.for_name_like(params[:query]).pluck_names
+ end
+
+ def serialize_environments(request, response, nested = false)
+ EnvironmentSerializer
+ .new(project: @project, current_user: @current_user)
+ .tap { |serializer| serializer.within_folders if nested }
+ .with_pagination(request, response)
+ .represent(@environments)
+ end
+
def authorize_stop_environment!
access_denied! unless can?(current_user, :stop_environment, environment)
end
diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb
new file mode 100644
index 00000000000..88d0755f41f
--- /dev/null
+++ b/app/controllers/projects/error_tracking_controller.rb
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+class Projects::ErrorTrackingController < Projects::ApplicationController
+ before_action :authorize_read_sentry_issue!
+
+ POLLING_INTERVAL = 10_000
+
+ def index
+ respond_to do |format|
+ format.html
+ format.json do
+ set_polling_interval
+ render_index_json
+ end
+ end
+ end
+
+ def list_projects
+ respond_to do |format|
+ format.json do
+ render_project_list_json
+ end
+ end
+ end
+
+ private
+
+ def render_index_json
+ service = ErrorTracking::ListIssuesService.new(project, current_user)
+ result = service.execute
+
+ unless result[:status] == :success
+ return render json: { message: result[:message] },
+ status: result[:http_status] || :bad_request
+ end
+
+ render json: {
+ errors: serialize_errors(result[:issues]),
+ external_url: service.external_url
+ }
+ end
+
+ def render_project_list_json
+ service = ErrorTracking::ListProjectsService.new(
+ project,
+ current_user,
+ list_projects_params
+ )
+ result = service.execute
+
+ if result[:status] == :success
+ render json: {
+ projects: serialize_projects(result[:projects])
+ }
+ else
+ return render(
+ status: result[:http_status] || :bad_request,
+ json: {
+ message: result[:message]
+ }
+ )
+ end
+ end
+
+ def list_projects_params
+ params.require(:error_tracking_setting).permit([:api_host, :token])
+ end
+
+ def set_polling_interval
+ Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
+ end
+
+ def serialize_errors(errors)
+ ErrorTracking::ErrorSerializer
+ .new(project: project, user: current_user)
+ .represent(errors)
+ end
+
+ def serialize_projects(projects)
+ ErrorTracking::ProjectSerializer
+ .new(project: project, user: current_user)
+ .represent(projects)
+ end
+end
diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb
index d439db97252..7a80da53025 100644
--- a/app/controllers/projects/git_http_client_controller.rb
+++ b/app/controllers/projects/git_http_client_controller.rb
@@ -78,24 +78,28 @@ class Projects::GitHttpClientController < Projects::ApplicationController
end
def parse_repo_path
- @project, @wiki, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
+ @project, @repo_type, @redirected_path = Gitlab::RepoPath.parse("#{params[:namespace_id]}/#{params[:project_id]}")
end
def render_missing_personal_access_token
render plain: "HTTP Basic: Access denied\n" \
- "You must use a personal access token with 'api' scope for Git over HTTP.\n" \
+ "You must use a personal access token with 'read_repository' or 'write_repository' scope for Git over HTTP.\n" \
"You can generate one at #{profile_personal_access_tokens_url}",
status: :unauthorized
end
def repository
- wiki? ? project.wiki.repository : project.repository
+ repo_type.repository_for(project)
end
def wiki?
- parse_repo_path unless defined?(@wiki)
+ repo_type.wiki?
+ end
+
+ def repo_type
+ parse_repo_path unless defined?(@repo_type)
- @wiki
+ @repo_type
end
def handle_basic_authentication(login, password)
diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb
index c0aa39d87c6..e519cc1f158 100644
--- a/app/controllers/projects/git_http_controller.rb
+++ b/app/controllers/projects/git_http_controller.rb
@@ -4,6 +4,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
include WorkhorseRequest
before_action :access_check
+ prepend_before_action :deny_head_requests, only: [:info_refs]
rescue_from Gitlab::GitAccess::UnauthorizedError, with: :render_403
rescue_from Gitlab::GitAccess::NotFoundError, with: :render_404
@@ -20,6 +21,8 @@ class Projects::GitHttpController < Projects::GitHttpClientController
# POST /foo/bar.git/git-upload-pack (git pull)
def git_upload_pack
+ enqueue_fetch_statistics_update
+
render_ok
end
@@ -30,6 +33,10 @@ class Projects::GitHttpController < Projects::GitHttpClientController
private
+ def deny_head_requests
+ head :forbidden if request.head?
+ end
+
def download_request?
upload_pack?
end
@@ -48,7 +55,7 @@ class Projects::GitHttpController < Projects::GitHttpClientController
def render_ok
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.git_http_ok(repository, wiki?, user, action_name)
+ render json: Gitlab::Workhorse.git_http_ok(repository, repo_type, user, action_name)
end
def render_403(exception)
@@ -67,6 +74,13 @@ class Projects::GitHttpController < Projects::GitHttpClientController
render plain: exception.message, status: :service_unavailable
end
+ def enqueue_fetch_statistics_update
+ return if wiki?
+ return unless project.daily_statistics_enabled?
+
+ ProjectDailyStatisticsWorker.perform_async(project.id)
+ end
+
def access
@access ||= access_klass.new(access_actor, project,
'http', authentication_abilities: authentication_abilities,
@@ -80,14 +94,12 @@ class Projects::GitHttpController < Projects::GitHttpClientController
end
def access_check
- # Use the magic string '_any' to indicate we do not know what the
- # changes are. This is also what gitlab-shell does.
- access.check(git_command, '_any')
+ access.check(git_command, Gitlab::GitAccess::ANY)
@project ||= access.project
end
def access_klass
- @access_klass ||= wiki? ? Gitlab::GitAccessWiki : Gitlab::GitAccess
+ @access_klass ||= repo_type.access_checker_class
end
def project_path
diff --git a/app/controllers/projects/graphs_controller.rb b/app/controllers/projects/graphs_controller.rb
index 925b6ed9bfd..67d3f49af18 100644
--- a/app/controllers/projects/graphs_controller.rb
+++ b/app/controllers/projects/graphs_controller.rb
@@ -45,7 +45,10 @@ class Projects::GraphsController < Projects::ApplicationController
end
def get_languages
- @languages = @project.repository.languages
+ @languages =
+ ::Projects::RepositoryLanguagesService.new(@project, current_user).execute.map do |lang|
+ { value: lang.share, label: lang.name, color: lang.color, highlight: lang.color }
+ end
end
def fetch_graph
diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb
index 7c713c19762..dc65f9959db 100644
--- a/app/controllers/projects/group_links_controller.rb
+++ b/app/controllers/projects/group_links_controller.rb
@@ -13,11 +13,12 @@ class Projects::GroupLinksController < Projects::ApplicationController
group = Group.find(params[:link_group_id]) if params[:link_group_id].present?
if group
- return render_404 unless can?(current_user, :read_group, group)
+ result = Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
+ return render_404 if result[:http_status] == 404
- Projects::GroupLinks::CreateService.new(project, current_user, group_link_create_params).execute(group)
+ flash[:alert] = result[:message] if result[:http_status] == 409
else
- flash[:alert] = 'Please select a group.'
+ flash[:alert] = _('Please select a group.')
end
redirect_to project_project_members_path(project)
diff --git a/app/controllers/projects/hooks_controller.rb b/app/controllers/projects/hooks_controller.rb
index bc84418b79f..5fa0339f44d 100644
--- a/app/controllers/projects/hooks_controller.rb
+++ b/app/controllers/projects/hooks_controller.rb
@@ -32,7 +32,7 @@ class Projects::HooksController < Projects::ApplicationController
def update
if hook.update(hook_params)
- flash[:notice] = 'Hook was successfully updated.'
+ flash[:notice] = _('Hook was successfully updated.')
redirect_to project_settings_integrations_path(@project)
else
render 'edit'
diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb
index a10e159ea1e..4640be015de 100644
--- a/app/controllers/projects/imports_controller.rb
+++ b/app/controllers/projects/imports_controller.rb
@@ -13,8 +13,8 @@ class Projects::ImportsController < Projects::ApplicationController
end
def create
- if @project.update(safe_import_params)
- @project.import_state.reload.schedule
+ if @project.update(import_params)
+ @project.import_state.reset.schedule
end
redirect_to project_import_path(@project)
@@ -42,9 +42,9 @@ class Projects::ImportsController < Projects::ApplicationController
def finished_notice
if @project.forked?
- 'The project was successfully forked.'
+ _('The project was successfully forked.')
else
- 'The project was successfully imported.'
+ _('The project was successfully imported.')
end
end
@@ -66,11 +66,11 @@ class Projects::ImportsController < Projects::ApplicationController
end
end
- def import_params
- params.require(:project).permit(:import_url)
+ def import_params_attributes
+ [:import_url]
end
- def safe_import_params
- import_params
+ def import_params
+ params.require(:project).permit(import_params_attributes)
end
end
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 5ed46fc0545..b4d89db20c5 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -8,25 +8,26 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableCollections
include IssuesCalendar
include SpammableActions
+ include RecordUserLastActivity
- def self.issue_except_actions
- %i[index calendar new create bulk_update]
+ def issue_except_actions
+ %i[index calendar new create bulk_update import_csv]
end
- def self.set_issuables_index_only_actions
+ def set_issuables_index_only_actions
%i[index calendar]
end
prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) }
prepend_before_action(only: [:calendar]) { authenticate_sessionless_user!(:ics) }
- prepend_before_action :authenticate_new_issue!, only: [:new]
+ prepend_before_action :authenticate_user!, only: [:new]
prepend_before_action :store_uri, only: [:new, :show]
before_action :whitelist_query_limiting, only: [:create, :create_merge_request, :move, :bulk_update]
before_action :check_issues_available!
- before_action :issue, except: issue_except_actions
+ before_action :issue, unless: ->(c) { c.issue_except_actions.include?(c.action_name.to_sym) }
- before_action :set_issuables_index, only: set_issuables_index_only_actions
+ before_action :set_issuables_index, if: ->(c) { c.set_issuables_index_only_actions.include?(c.action_name.to_sym) }
# Allow write(create) issue
before_action :authorize_create_issue!, only: [:new, :create]
@@ -37,6 +38,9 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
+ before_action :authorize_import_issues!, only: [:import_csv]
+ before_action :authorize_download_code!, only: [:related_branches]
+
before_action :set_suggested_issues_feature_flags, only: [:new]
respond_to :html
@@ -92,9 +96,9 @@ class Projects::IssuesController < Projects::ApplicationController
if service.discussions_to_resolve.count(&:resolved?) > 0
flash[:notice] = if service.discussion_to_resolve_id
- "Resolved 1 discussion."
+ _("Resolved 1 discussion.")
else
- "Resolved all discussions."
+ _("Resolved all discussions.")
end
end
@@ -128,18 +132,6 @@ class Projects::IssuesController < Projects::ApplicationController
render_conflict_response
end
- def referenced_merge_requests
- @merge_requests, @closed_by_merge_requests = ::Issues::ReferencedMergeRequestsService.new(project, current_user).execute(issue)
-
- respond_to do |format|
- format.json do
- render json: {
- html: view_to_html_string('projects/issues/_merge_requests')
- }
- end
- end
- end
-
def related_branches
@related_branches = Issues::RelatedBranchesService.new(project, current_user).execute(issue)
@@ -175,8 +167,24 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
+ def import_csv
+ if uploader = UploadService.new(project, params[:file]).execute
+ ImportIssuesCsvWorker.perform_async(current_user.id, project.id, uploader.upload.id)
+
+ flash[:notice] = _("Your issues are being imported. Once finished, you'll get a confirmation email.")
+ else
+ flash[:alert] = _("File upload error.")
+ end
+
+ redirect_to project_issues_path(project)
+ end
+
protected
+ def issuable_sorting_field
+ Issue::SORTING_PREFERENCE_FIELD
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def issue
return @issue if defined?(@issue)
@@ -228,15 +236,7 @@ class Projects::IssuesController < Projects::ApplicationController
task_num
lock_version
discussion_locked
- ] + [{ label_ids: [], assignee_ids: [] }]
- end
-
- def authenticate_new_issue!
- return if current_user
-
- notice = "Please sign in to create the new issue."
-
- redirect_to new_user_session_path, notice: notice
+ ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }]
end
def store_uri
diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb
index c58b30eace7..2a4933e7bc2 100644
--- a/app/controllers/projects/jobs_controller.rb
+++ b/app/controllers/projects/jobs_controller.rb
@@ -4,12 +4,12 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
- before_action :build, except: [:index, :cancel_all]
+ before_action :build, except: [:index]
before_action :authorize_read_build!
before_action :authorize_update_build!,
- except: [:index, :show, :status, :raw, :trace, :cancel_all, :erase]
+ except: [:index, :show, :status, :raw, :trace, :erase]
before_action :authorize_erase_build!, only: [:erase]
- before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_workhorse_authorize]
+ before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize]
before_action :verify_api_request!, only: :terminal_websocket_authorize
layout 'project'
@@ -39,16 +39,6 @@ class Projects::JobsController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- def cancel_all
- return access_denied! unless can?(current_user, :update_build, project)
-
- @project.builds.running_or_pending.each do |build|
- build.cancel if can?(current_user, :update_build, build)
- end
-
- redirect_to project_jobs_path(project)
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def show
@pipeline = @build.pipeline
@@ -132,7 +122,7 @@ class Projects::JobsController < Projects::ApplicationController
def erase
if @build.erase(erased_by: current_user)
redirect_to project_job_path(project, @build),
- notice: "Job has been successfully erased!"
+ notice: _("Job has been successfully erased!")
else
respond_422
end
@@ -167,7 +157,7 @@ class Projects::JobsController < Projects::ApplicationController
# GET .../terminal.ws : implemented in gitlab-workhorse
def terminal_websocket_authorize
set_workhorse_internal_api_content_type
- render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification)
+ render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification)
end
private
diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb
index 640038818f2..386a1f00bd2 100644
--- a/app/controllers/projects/labels_controller.rb
+++ b/app/controllers/projects/labels_controller.rb
@@ -132,7 +132,7 @@ class Projects::LabelsController < Projects::ApplicationController
respond_to do |format|
format.html do
redirect_to(project_labels_path(@project),
- notice: 'Failed to promote label due to internal error. Please contact administrators.')
+ notice: _('Failed to promote label due to internal error. Please contact administrators.'))
end
format.js
end
diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb
index be40077d389..42c415757f9 100644
--- a/app/controllers/projects/lfs_api_controller.rb
+++ b/app/controllers/projects/lfs_api_controller.rb
@@ -26,7 +26,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
def deprecated
render(
json: {
- message: 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.',
+ message: _('Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.'),
documentation_url: "#{Gitlab.config.gitlab.url}/help"
},
status: :not_implemented
@@ -62,7 +62,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController
else
object[:error] = {
code: 404,
- message: "Object does not exist on the server or you don't have permissions to access it"
+ message: _("Object does not exist on the server or you don't have permissions to access it")
}
end
end
diff --git a/app/controllers/projects/lfs_locks_api_controller.rb b/app/controllers/projects/lfs_locks_api_controller.rb
index fc67cd72faa..6aacb9d9a56 100644
--- a/app/controllers/projects/lfs_locks_api_controller.rb
+++ b/app/controllers/projects/lfs_locks_api_controller.rb
@@ -4,19 +4,19 @@ class Projects::LfsLocksApiController < Projects::GitHttpClientController
include LfsRequest
def create
- @result = Lfs::LockFileService.new(project, user, params).execute
+ @result = Lfs::LockFileService.new(project, user, lfs_params).execute
render_json(@result[:lock])
end
def unlock
- @result = Lfs::UnlockFileService.new(project, user, params).execute
+ @result = Lfs::UnlockFileService.new(project, user, lfs_params).execute
render_json(@result[:lock])
end
def index
- @result = Lfs::LocksFinderService.new(project, user, params).execute
+ @result = Lfs::LocksFinderService.new(project, user, lfs_params).execute
render_json(@result[:locks])
end
@@ -69,4 +69,8 @@ class Projects::LfsLocksApiController < Projects::GitHttpClientController
def upload_request?
%w(create unlock verify).include?(params[:action])
end
+
+ def lfs_params
+ params.permit(:id, :path, :force)
+ end
end
diff --git a/app/controllers/projects/lfs_storage_controller.rb b/app/controllers/projects/lfs_storage_controller.rb
index babeee48ef3..013e01b82aa 100644
--- a/app/controllers/projects/lfs_storage_controller.rb
+++ b/app/controllers/projects/lfs_storage_controller.rb
@@ -5,7 +5,7 @@ class Projects::LfsStorageController < Projects::GitHttpClientController
include WorkhorseRequest
include SendFileUpload
- skip_before_action :verify_workhorse_api!, only: [:download, :upload_finalize]
+ skip_before_action :verify_workhorse_api!, only: :download
def download
lfs_object = LfsObject.find_by_oid(oid)
diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb
index 368ee89ff5c..eb469d2d714 100644
--- a/app/controllers/projects/merge_requests/application_controller.rb
+++ b/app/controllers/projects/merge_requests/application_controller.rb
@@ -20,7 +20,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
def merge_request_params_attributes
[
:allow_collaboration,
- :assignee_id,
:description,
:force_remove_source_branch,
:lock_version,
@@ -34,13 +33,18 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
:task_num,
:title,
:discussion_locked,
- label_ids: []
+ label_ids: [],
+ assignee_ids: [],
+ update_task: [:index, :checked, :line_number, :line_source]
]
end
def set_pipeline_variables
- @pipelines = @merge_request.all_pipelines
- @pipeline = @merge_request.head_pipeline
- @statuses_count = @pipeline.present? ? @pipeline.statuses.relevant.count : 0
+ @pipelines =
+ if can?(current_user, :read_pipeline, @project)
+ @merge_request.all_pipelines
+ else
+ Ci::Pipeline.none
+ end
end
end
diff --git a/app/controllers/projects/merge_requests/conflicts_controller.rb b/app/controllers/projects/merge_requests/conflicts_controller.rb
index ac1969adc6e..011ac9a42f8 100644
--- a/app/controllers/projects/merge_requests/conflicts_controller.rb
+++ b/app/controllers/projects/merge_requests/conflicts_controller.rb
@@ -8,7 +8,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
def show
respond_to do |format|
format.html do
- labels
+ @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
end
format.json do
@@ -16,12 +16,12 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
render json: @conflicts_list
elsif @merge_request.can_be_merged?
render json: {
- message: 'The merge conflicts for this merge request have already been resolved. Please return to the merge request.',
+ message: _('The merge conflicts for this merge request have already been resolved. Please return to the merge request.'),
type: 'error'
}
else
render json: {
- message: 'The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.',
+ message: _('The merge conflicts for this merge request cannot be resolved through GitLab. Please try to resolve them locally.'),
type: 'error'
}
end
@@ -43,7 +43,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
return render_404 unless @conflicts_list.can_be_resolved_in_ui?
if @merge_request.can_be_merged?
- render status: :bad_request, json: { message: 'The merge conflicts for this merge request have already been resolved.' }
+ render status: :bad_request, json: { message: _('The merge conflicts for this merge request have already been resolved.') }
return
end
@@ -52,7 +52,7 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
.new(merge_request)
.execute(current_user, params)
- flash[:notice] = 'All merge conflicts were resolved. The merge request can now be merged.'
+ flash[:notice] = _('All merge conflicts were resolved. The merge request can now be merged.')
render json: { redirect_to: project_merge_request_url(@project, @merge_request, resolved_conflicts: true) }
rescue Gitlab::Git::Conflict::Resolver::ResolutionError => e
@@ -60,9 +60,15 @@ class Projects::MergeRequests::ConflictsController < Projects::MergeRequests::Ap
end
end
+ private
+
def authorize_can_resolve_conflicts!
@conflicts_list = ::MergeRequests::Conflicts::ListService.new(@merge_request)
return render_404 unless @conflicts_list.can_be_resolved_by?(current_user)
end
+
+ def serializer
+ MergeRequestSerializer.new(current_user: current_user, project: project)
+ end
end
diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb
index 5639402a1e9..32cefe54613 100644
--- a/app/controllers/projects/merge_requests/creations_controller.rb
+++ b/app/controllers/projects/merge_requests/creations_controller.rb
@@ -89,7 +89,11 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
def build_merge_request
params[:merge_request] ||= ActionController::Parameters.new(source_project: @project)
- @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
+
+ # Gitaly N+1 issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/58096
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ @merge_request = ::MergeRequests::BuildService.new(project, current_user, merge_request_params.merge(diff_options: diff_options)).execute
+ end
end
def define_new_vars
diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb
index ddffbb17ace..456d2c34768 100644
--- a/app/controllers/projects/merge_requests/diffs_controller.rb
+++ b/app/controllers/projects/merge_requests/diffs_controller.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class Projects::MergeRequests::DiffsController < Projects::MergeRequests::ApplicationController
- include DiffForPath
include DiffHelper
include RendersNotes
@@ -47,8 +46,8 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
# rubocop: disable CodeReuse/ActiveRecord
def commit
- return nil unless commit_id = params[:commit_id].presence
- return nil unless @merge_request.all_commits.exists?(sha: commit_id)
+ return unless commit_id = params[:commit_id].presence
+ return unless @merge_request.all_commits.exists?(sha: commit_id)
@commit ||= @project.commit(commit_id)
end
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index da9316d5f22..8f177895b08 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -7,6 +7,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
include RendersCommits
include ToggleAwardEmoji
include IssuableCollections
+ include RecordUserLastActivity
skip_before_action :merge_request, only: [:index, :bulk_update]
before_action :whitelist_query_limiting, only: [:assign_related_issues, :update]
@@ -15,6 +16,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :authenticate_user!, only: [:assign_related_issues]
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
def index
@merge_requests = @issuables
@@ -22,8 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
format.html
format.json do
render json: {
- html: view_to_html_string("projects/merge_requests/_merge_requests"),
- labels: @labels.as_json(methods: :text_color)
+ html: view_to_html_string("projects/merge_requests/_merge_requests")
}
end
end
@@ -43,8 +45,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
@noteable = @merge_request
@commits_count = @merge_request.commits_count
-
- labels
+ @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar')
set_pipeline_variables
@@ -57,7 +58,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
render json: serializer.represent(@merge_request, serializer: params[:serializer])
end
- format.patch do
+ format.patch do
break render_404 unless @merge_request.diff_refs
send_git_patch @project.repository, @merge_request.diff_refs
@@ -97,20 +98,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def test_reports
- result = @merge_request.compare_test_reports
-
- case result[:status]
- when :parsing
- Gitlab::PollingInterval.set_header(response, interval: 3000)
-
- render json: '', status: :no_content
- when :parsed
- render json: result[:data].to_json, status: :ok
- when :error
- render json: { status_reason: result[:status_reason] }, status: :bad_request
- else
- render json: { status_reason: 'Unknown error' }, status: :internal_server_error
- end
+ reports_response(@merge_request.compare_test_reports)
end
def edit
@@ -220,18 +208,28 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
head :ok
end
+ def discussions
+ merge_request.preload_discussions_diff_highlight
+
+ super
+ end
+
protected
alias_method :subscribable_resource, :merge_request
alias_method :issuable, :merge_request
alias_method :awardable, :merge_request
+ def issuable_sorting_field
+ MergeRequest::SORTING_PREFERENCE_FIELD
+ end
+
def merge_params
params.permit(merge_params_attributes)
end
def merge_params_attributes
- [:should_remove_source_branch, :commit_message, :squash]
+ [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash]
end
def merge_when_pipeline_succeeds_active?
@@ -342,4 +340,19 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42441
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42438')
end
+
+ def reports_response(report_comparison)
+ case report_comparison[:status]
+ when :parsing
+ ::Gitlab::PollingInterval.set_header(response, interval: 3000)
+
+ render json: '', status: :no_content
+ when :parsed
+ render json: report_comparison[:data].to_json, status: :ok
+ when :error
+ render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request
+ else
+ render json: { status_reason: 'Unknown error' }, status: :internal_server_error
+ end
+ end
end
diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb
index 8e68014a30d..f6f61b6e5fb 100644
--- a/app/controllers/projects/milestones_controller.rb
+++ b/app/controllers/projects/milestones_controller.rb
@@ -144,9 +144,9 @@ class Projects::MilestonesController < Projects::ApplicationController
def search_params
if request.format.json? && project_group && can?(current_user, :read_group, project_group)
- groups = project_group.self_and_ancestors_ids
+ groups = project_group.self_and_ancestors.select(:id)
end
- params.permit(:state).merge(project_ids: @project.id, group_ids: groups)
+ params.permit(:state, :search_title).merge(project_ids: @project.id, group_ids: groups)
end
end
diff --git a/app/controllers/projects/mirrors_controller.rb b/app/controllers/projects/mirrors_controller.rb
index ab7ab13657a..6c6adc233b7 100644
--- a/app/controllers/projects/mirrors_controller.rb
+++ b/app/controllers/projects/mirrors_controller.rb
@@ -18,7 +18,7 @@ class Projects::MirrorsController < Projects::ApplicationController
result = ::Projects::UpdateService.new(project, current_user, mirror_params).execute
if result[:status] == :success
- flash[:notice] = 'Mirroring settings were successfully updated.'
+ flash[:notice] = _('Mirroring settings were successfully updated.')
else
flash[:alert] = project.errors.full_messages.join(', ').html_safe
end
@@ -38,7 +38,7 @@ class Projects::MirrorsController < Projects::ApplicationController
def update_now
if params[:sync_remote]
project.update_remote_mirrors
- flash[:notice] = "The remote repository is being updated..."
+ flash[:notice] = _("The remote repository is being updated...")
end
redirect_to_repository_settings(project, anchor: 'js-push-remote-settings')
@@ -81,6 +81,7 @@ class Projects::MirrorsController < Projects::ApplicationController
password
ssh_known_hosts
regenerate_ssh_private_key
+ _destroy
]
]
end
diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb
index c1ad6707c97..73e629ab7c3 100644
--- a/app/controllers/projects/pages_controller.rb
+++ b/app/controllers/projects/pages_controller.rb
@@ -5,7 +5,8 @@ class Projects::PagesController < Projects::ApplicationController
before_action :require_pages_enabled!
before_action :authorize_read_pages!, only: [:show]
- before_action :authorize_update_pages!, except: [:show]
+ before_action :authorize_update_pages!, except: [:show, :destroy]
+ before_action :authorize_remove_pages!, only: [:destroy]
# rubocop: disable CodeReuse/ActiveRecord
def show
@@ -18,7 +19,7 @@ class Projects::PagesController < Projects::ApplicationController
project.pages_domains.destroy_all # rubocop: disable DestroyAll
respond_to do |format|
- format.html do
+ format.html do
redirect_to project_pages_path(@project),
status: 302,
notice: 'Pages were removed'
diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb
index 439ec9b1731..58b1bc54181 100644
--- a/app/controllers/projects/pages_domains_controller.rb
+++ b/app/controllers/projects/pages_domains_controller.rb
@@ -4,7 +4,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController
layout 'project_settings'
before_action :require_pages_enabled!
- before_action :authorize_update_pages!, except: [:show]
+ before_action :authorize_update_pages!
before_action :domain, except: [:new, :create]
def show
diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb
index acf56f0eb6a..72e939a3310 100644
--- a/app/controllers/projects/pipeline_schedules_controller.rb
+++ b/app/controllers/projects/pipeline_schedules_controller.rb
@@ -50,9 +50,11 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
job_id = RunPipelineScheduleWorker.perform_async(schedule.id, current_user.id)
if job_id
- flash[:notice] = "Successfully scheduled a pipeline to run. Go to the <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details.".html_safe
+ pipelines_link_start = "<a href=\"#{project_pipelines_path(@project)}\">"
+ message = _("Successfully scheduled a pipeline to run. Go to the %{pipelines_link_start}Pipelines page%{pipelines_link_end} for details.") % { pipelines_link_start: pipelines_link_start, pipelines_link_end: "</a>" }
+ flash[:notice] = message.html_safe
else
- flash[:alert] = 'Unable to schedule a pipeline to run immediately'
+ flash[:alert] = _('Unable to schedule a pipeline to run immediately')
end
redirect_to pipeline_schedules_path(@project)
@@ -85,7 +87,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
return unless limiter.throttled?([current_user, schedule], 1)
- flash[:alert] = 'You cannot play this scheduled pipeline at the moment. Please wait a minute.'
+ flash[:alert] = _('You cannot play this scheduled pipeline at the moment. Please wait a minute.')
redirect_to pipeline_schedules_path(@project)
end
@@ -96,7 +98,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController
def schedule_params
params.require(:schedule)
.permit(:description, :cron, :cron_timezone, :ref, :active,
- variables_attributes: [:id, :key, :secret_value, :_destroy] )
+ variables_attributes: [:id, :variable_type, :key, :secret_value, :_destroy] )
end
def authorize_play_pipeline_schedule!
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 67827b1d3bb..db3b7c8b177 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -4,9 +4,12 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :authorize_read_pipeline!
+ before_action :authorize_read_build!, only: [:index]
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
wrap_parameters Ci::Pipeline
POLLING_INTERVAL = 10_000
@@ -30,10 +33,7 @@ class Projects::PipelinesController < Projects::ApplicationController
Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL)
render json: {
- pipelines: PipelineSerializer
- .new(project: @project, current_user: @current_user)
- .with_pagination(request, response)
- .represent(@pipelines, disable_coverage: true, preload: true),
+ pipelines: serialize_pipelines,
count: {
all: @pipelines_count,
running: @running_count,
@@ -69,7 +69,7 @@ class Projects::PipelinesController < Projects::ApplicationController
render json: PipelineSerializer
.new(project: @project, current_user: @current_user)
- .represent(@pipeline, grouped: true)
+ .represent(@pipeline, show_represent_params)
end
end
end
@@ -149,6 +149,13 @@ class Projects::PipelinesController < Projects::ApplicationController
private
+ def serialize_pipelines
+ PipelineSerializer
+ .new(project: @project, current_user: @current_user)
+ .with_pagination(request, response)
+ .represent(@pipelines, disable_coverage: true, preload: true)
+ end
+
def render_show
respond_to do |format|
format.html do
@@ -157,8 +164,12 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
+ def show_represent_params
+ { grouped: true }
+ end
+
def create_params
- params.require(:pipeline).permit(:ref, variables_attributes: %i[key secret_value])
+ params.require(:pipeline).permit(:ref, variables_attributes: %i[key variable_type secret_value])
end
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb
index 192e6d38f36..7f988110977 100644
--- a/app/controllers/projects/pipelines_settings_controller.rb
+++ b/app/controllers/projects/pipelines_settings_controller.rb
@@ -4,6 +4,6 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
before_action :authorize_admin_pipeline!
def show
- redirect_to project_settings_ci_cd_path(@project, params: params)
+ redirect_to project_settings_ci_cd_path(@project, params: params.to_unsafe_h)
end
end
diff --git a/app/controllers/projects/protected_branches_controller.rb b/app/controllers/projects/protected_branches_controller.rb
index a860be83e95..c5454883060 100644
--- a/app/controllers/projects/protected_branches_controller.rb
+++ b/app/controllers/projects/protected_branches_controller.rb
@@ -15,6 +15,10 @@ class Projects::ProtectedBranchesController < Projects::ProtectedRefsController
@protected_ref = @project.protected_branches.find(params[:id])
end
+ def access_levels
+ [:merge_access_levels, :push_access_levels]
+ end
+
def protected_ref_params
params.require(:protected_branch).permit(:name,
merge_access_levels_attributes: access_level_attributes,
diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb
index 3a3a29ddd0d..4e2a9df5576 100644
--- a/app/controllers/projects/protected_refs_controller.rb
+++ b/app/controllers/projects/protected_refs_controller.rb
@@ -32,7 +32,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
@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
+ render json: @protected_ref, status: :ok, include: access_levels
else
render json: @protected_ref.errors, status: :unprocessable_entity
end
@@ -62,6 +62,6 @@ class Projects::ProtectedRefsController < Projects::ApplicationController
end
def access_level_attributes
- %i(access_level id)
+ %i[access_level id]
end
end
diff --git a/app/controllers/projects/protected_tags_controller.rb b/app/controllers/projects/protected_tags_controller.rb
index 01cedba95ac..41191639c2b 100644
--- a/app/controllers/projects/protected_tags_controller.rb
+++ b/app/controllers/projects/protected_tags_controller.rb
@@ -15,6 +15,10 @@ class Projects::ProtectedTagsController < Projects::ProtectedRefsController
@protected_ref = @project.protected_tags.find(params[:id])
end
+ def access_levels
+ [:create_access_levels]
+ end
+
def protected_ref_params
params.require(:protected_tag).permit(:name, create_access_levels_attributes: access_level_attributes)
end
diff --git a/app/controllers/projects/refs_controller.rb b/app/controllers/projects/refs_controller.rb
index b97fbe19bbf..b3447812ef2 100644
--- a/app/controllers/projects/refs_controller.rb
+++ b/app/controllers/projects/refs_controller.rb
@@ -4,6 +4,8 @@ class Projects::RefsController < Projects::ApplicationController
include ExtractsPath
include TreeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:logs_tree]
+
before_action :require_non_empty_project
before_action :validate_ref_id
before_action :assign_ref_vars
diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb
index 55827075896..4c39ee4045f 100644
--- a/app/controllers/projects/releases_controller.rb
+++ b/app/controllers/projects/releases_controller.rb
@@ -3,40 +3,8 @@
class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
- before_action :authorize_download_code!
- before_action :authorize_push_code!
- before_action :tag
- before_action :release
+ before_action :authorize_read_release!
- def edit
- end
-
- def update
- # Release belongs to Tag which is not active record object,
- # it exists only to save a description to each Tag.
- # If description is empty we should destroy the existing record.
- if release_params[:description].present?
- release.update(release_params)
- else
- release.destroy
- end
-
- redirect_to project_tag_path(@project, @tag.name)
- end
-
- private
-
- def tag
- @tag ||= @repository.find_tag(params[:tag_id])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def release
- @release ||= @project.releases.find_or_initialize_by(tag: @tag.name)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def release_params
- params.require(:release).permit(:description)
+ def index
end
end
diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb
index 4eeaeb860ee..3b4215b766e 100644
--- a/app/controllers/projects/repositories_controller.rb
+++ b/app/controllers/projects/repositories_controller.rb
@@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController
append_sha = false if @filename == shortname
end
- send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha
+ send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha
rescue => ex
logger.error("#{self.class.name}: #{ex}")
git_not_found!
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 91f40b90aa8..ca62f54813b 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -15,7 +15,7 @@ class Projects::RunnersController < Projects::ApplicationController
def update
if Ci::UpdateRunnerService.new(@runner).update(runner_params)
- redirect_to project_runner_path(@project, @runner), notice: 'Runner was successfully updated.'
+ redirect_to project_runner_path(@project, @runner), notice: _('Runner was successfully updated.')
else
render 'edit'
end
@@ -31,17 +31,17 @@ class Projects::RunnersController < Projects::ApplicationController
def resume
if Ci::UpdateRunnerService.new(@runner).update(active: true)
- redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.'
+ redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
- redirect_to project_runners_path(@project), alert: 'Runner was not updated.'
+ redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
end
end
def pause
if Ci::UpdateRunnerService.new(@runner).update(active: false)
- redirect_to project_runners_path(@project), notice: 'Runner was successfully updated.'
+ redirect_to project_runners_path(@project), notice: _('Runner was successfully updated.')
else
- redirect_to project_runners_path(@project), alert: 'Runner was not updated.'
+ redirect_to project_runners_path(@project), alert: _('Runner was not updated.')
end
end
diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb
index 0af2b7ef343..79030da64d3 100644
--- a/app/controllers/projects/serverless/functions_controller.rb
+++ b/app/controllers/projects/serverless/functions_controller.rb
@@ -3,25 +3,16 @@
module Projects
module Serverless
class FunctionsController < Projects::ApplicationController
- include ProjectUnauthorized
-
before_action :authorize_read_cluster!
- INDEX_PRIMING_INTERVAL = 10_000
- INDEX_POLLING_INTERVAL = 30_000
-
def index
- finder = Projects::Serverless::FunctionsFinder.new(project.clusters)
-
respond_to do |format|
format.json do
functions = finder.execute
if functions.any?
- Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
- render json: Projects::Serverless::ServiceSerializer.new(current_user: @current_user).represent(functions)
+ render json: serialize_function(functions)
else
- Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content
end
end
@@ -32,6 +23,45 @@ module Projects
end
end
end
+
+ def show
+ @service = serialize_function(finder.service(params[:environment_id], params[:id]))
+ @prometheus = finder.has_prometheus?(params[:environment_id])
+
+ return not_found if @service.nil?
+
+ respond_to do |format|
+ format.json do
+ render json: @service
+ end
+
+ format.html
+ end
+ end
+
+ def metrics
+ respond_to do |format|
+ format.json do
+ metrics = finder.invocation_metrics(params[:environment_id], params[:id])
+
+ if metrics.nil?
+ head :no_content
+ else
+ render json: metrics
+ end
+ end
+ end
+ end
+
+ private
+
+ def finder
+ Projects::Serverless::FunctionsFinder.new(project)
+ end
+
+ def serialize_function(function)
+ Projects::Serverless::ServiceSerializer.new(current_user: @current_user, project: project).represent(function)
+ end
end
end
end
diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb
index f1c9d0d0f77..e0df51590ae 100644
--- a/app/controllers/projects/services_controller.rb
+++ b/app/controllers/projects/services_controller.rb
@@ -43,20 +43,20 @@ class Projects::ServicesController < Projects::ApplicationController
if outcome[:success]
{}
else
- { error: true, message: 'Test failed.', service_response: outcome[:result].to_s, test_failed: true }
+ { error: true, message: _('Test failed.'), service_response: outcome[:result].to_s, test_failed: true }
end
else
- { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(','), test_failed: false }
+ { error: true, message: _('Validations failed.'), service_response: @service.errors.full_messages.join(','), test_failed: false }
end
rescue Gitlab::HTTP::BlockedUrlError => e
- { error: true, message: 'Test failed.', service_response: e.message, test_failed: true }
+ { error: true, message: _('Test failed.'), service_response: e.message, test_failed: true }
end
def success_message
if @service.active?
- "#{@service.title} activated."
+ _("%{service_title} activated.") % { service_title: @service.title }
else
- "#{@service.title} settings saved, but not activated."
+ _("%{service_title} settings saved, but not activated.") % { service_title: @service.title }
end
end
diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb
index 75e590f3f33..c4dff95a4b9 100644
--- a/app/controllers/projects/settings/ci_cd_controller.rb
+++ b/app/controllers/projects/settings/ci_cd_controller.rb
@@ -13,13 +13,13 @@ module Projects
Projects::UpdateService.new(project, current_user, update_params).tap do |service|
result = service.execute
if result[:status] == :success
- flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
+ flash[:notice] = _("Pipelines settings for '%{project_name}' were successfully updated.") % { project_name: @project.name }
run_autodevops_pipeline(service)
redirect_to project_settings_ci_cd_path(@project)
else
- render 'show'
+ redirect_to project_settings_ci_cd_path(@project), alert: result[:message]
end
end
end
@@ -39,7 +39,7 @@ module Projects
def reset_registration_token
@project.reset_runners_token!
- flash[:notice] = 'New runners registration token has been generated!'
+ flash[:notice] = _('New runners registration token has been generated!')
redirect_to namespace_project_settings_ci_cd_path
end
@@ -58,7 +58,7 @@ module Projects
return unless service.run_auto_devops_pipeline?
if @project.empty_repo?
- flash[:warning] = "This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch."
+ flash[:warning] = _("This repository is currently empty. A new Auto DevOps pipeline will be created after a new file has been pushed to a branch.")
return
end
@@ -99,7 +99,9 @@ module Projects
def define_triggers_variables
@triggers = @project.triggers
+ .present(current_user: current_user)
@trigger = ::Ci::Trigger.new
+ .present(current_user: current_user)
end
def define_badges_variables
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
new file mode 100644
index 00000000000..b5c77e5bbf4
--- /dev/null
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+module Projects
+ module Settings
+ class OperationsController < Projects::ApplicationController
+ before_action :authorize_update_environment!
+
+ before_action do
+ push_frontend_feature_flag(:grafana_dashboard_link)
+ end
+
+ helper_method :error_tracking_setting
+
+ def show
+ end
+
+ def update
+ result = ::Projects::Operations::UpdateService.new(project, current_user, update_params).execute
+
+ render_update_response(result)
+ end
+
+ private
+
+ # overridden in EE
+ def render_update_response(result)
+ respond_to do |format|
+ format.json do
+ render_update_json_response(result)
+ end
+ end
+ end
+
+ def render_update_json_response(result)
+ if result[:status] == :success
+ flash[:notice] = _('Your changes have been saved')
+ render json: {
+ status: result[:status]
+ }
+ else
+ render(
+ status: result[:http_status] || :bad_request,
+ json: {
+ status: result[:status],
+ message: result[:message]
+ }
+ )
+ end
+ end
+
+ def error_tracking_setting
+ @error_tracking_setting ||= project.error_tracking_setting ||
+ project.build_error_tracking_setting
+ end
+
+ def update_params
+ params.require(:project).permit(permitted_project_params)
+ end
+
+ # overridden in EE
+ def permitted_project_params
+ {
+ metrics_setting_attributes: [:external_dashboard_url],
+
+ error_tracking_setting_attributes: [
+ :enabled,
+ :api_host,
+ :token,
+ project: [:slug, :name, :organization_slug, :organization_name]
+ ]
+ }
+ end
+ end
+ end
+end
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 30724de7f6a..ac3004d069f 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -5,7 +5,6 @@ module Projects
class RepositoryController < Projects::ApplicationController
before_action :authorize_admin_project!
before_action :remote_mirror, only: [:show]
- before_action :check_cleanup_feature_flag!, only: :cleanup
def show
render_show
@@ -37,10 +36,6 @@ module Projects
private
- def check_cleanup_feature_flag!
- render_404 unless ::Feature.enabled?(:project_cleanup, project)
- end
-
def render_show
@deploy_keys = DeployKeysPresenter.new(@project, current_user: current_user)
@deploy_tokens = @project.deploy_tokens.active
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index a44acb12bdf..255f1f3569a 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -75,7 +75,14 @@ class Projects::SnippetsController < Projects::ApplicationController
format.json do
render_blob_json(blob)
end
- format.js { render 'shared/snippets/show'}
+
+ format.js do
+ if @snippet.embeddable?
+ render 'shared/snippets/show'
+ else
+ head :not_found
+ end
+ end
end
end
diff --git a/app/controllers/projects/stages_controller.rb b/app/controllers/projects/stages_controller.rb
new file mode 100644
index 00000000000..c8db5b1277f
--- /dev/null
+++ b/app/controllers/projects/stages_controller.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class Projects::StagesController < Projects::PipelinesController
+ before_action :authorize_update_pipeline!
+
+ def play_manual
+ ::Ci::PlayManualStageService
+ .new(@project, current_user, pipeline: pipeline)
+ .execute(stage)
+
+ respond_to do |format|
+ format.json do
+ render json: StageSerializer
+ .new(project: @project, current_user: @current_user)
+ .represent(stage)
+ end
+ end
+ end
+
+ private
+
+ def stage
+ @pipeline_stage ||= pipeline.find_stage_by_name!(params[:stage_name])
+ end
+end
diff --git a/app/controllers/projects/tags/releases_controller.rb b/app/controllers/projects/tags/releases_controller.rb
new file mode 100644
index 00000000000..5e4c601a693
--- /dev/null
+++ b/app/controllers/projects/tags/releases_controller.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+class Projects::Tags::ReleasesController < Projects::ApplicationController
+ # Authorize
+ before_action :require_non_empty_project
+ before_action :authorize_download_code!
+ before_action :authorize_push_code!
+ before_action :tag
+ before_action :release
+
+ def edit
+ end
+
+ def update
+ if release_params[:description].present?
+ release.update(release_params)
+ else
+ release.destroy
+ end
+
+ redirect_to project_tag_path(@project, tag.name)
+ end
+
+ private
+
+ def tag
+ @tag ||= @repository.find_tag(params[:tag_id])
+ end
+
+ def release
+ @release ||= Releases::CreateService.new(project, current_user, tag: @tag.name)
+ .find_or_build_release
+ end
+
+ def release_params
+ params.require(:release).permit(:description)
+ end
+end
diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb
index 686d66b10a3..a17c050b696 100644
--- a/app/controllers/projects/tags_controller.rb
+++ b/app/controllers/projects/tags_controller.rb
@@ -42,10 +42,23 @@ class Projects::TagsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def create
- result = Tags::CreateService.new(@project, current_user)
- .execute(params[:tag_name], params[:ref], params[:message], params[:release_description])
+ result = ::Tags::CreateService.new(@project, current_user)
+ .execute(params[:tag_name], params[:ref], params[:message])
if result[:status] == :success
+ # Release creation with Tags was deprecated in GitLab 11.7
+ if params[:release_description].present?
+ release_params = {
+ tag: params[:tag_name],
+ name: params[:tag_name],
+ description: params[:release_description]
+ }
+
+ Releases::CreateService
+ .new(@project, current_user, release_params)
+ .execute
+ end
+
@tag = result[:tag]
redirect_to project_tag_path(@project, @tag.name)
@@ -58,7 +71,7 @@ class Projects::TagsController < Projects::ApplicationController
end
def destroy
- result = Tags::DestroyService.new(project, current_user).execute(params[:id])
+ result = ::Tags::DestroyService.new(project, current_user).execute(params[:id])
respond_to do |format|
if result[:status] == :success
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 3fe300dcfc0..7509cc29a76 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -6,6 +6,8 @@ class Projects::TreeController < Projects::ApplicationController
include CreatesCommit
include ActionView::Helpers::SanitizeHelper
+ around_action :allow_gitaly_ref_name_caching, only: [:show]
+
before_action :require_non_empty_project, except: [:new, :create]
before_action :assign_ref_vars
before_action :assign_dir_vars, only: [:create_dir]
@@ -31,27 +33,13 @@ class Projects::TreeController < Projects::ApplicationController
lfs_blob_ids
@last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
end
-
- format.js do
- # Disable cache so browser history works
- no_cache_headers
- end
-
- format.json do
- page_title @path.presence || _("Files"), @ref, @project.full_name
-
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261
- Gitlab::GitalyClient.allow_n_plus_1_calls do
- render json: TreeSerializer.new(project: @project, repository: @repository, ref: @ref).represent(@tree)
- end
- end
end
end
def create_dir
return render_404 unless @commit_params.values.all?
- create_commit(Files::CreateDirService, success_notice: "The directory has been successfully created.",
+ create_commit(Files::CreateDirService, success_notice: _("The directory has been successfully created."),
success_path: project_tree_path(@project, File.join(@branch_name, @dir_name)),
failure_path: project_tree_path(@project, @ref))
end
diff --git a/app/controllers/projects/triggers_controller.rb b/app/controllers/projects/triggers_controller.rb
index f5fdfb8accc..284e119ca06 100644
--- a/app/controllers/projects/triggers_controller.rb
+++ b/app/controllers/projects/triggers_controller.rb
@@ -16,9 +16,9 @@ class Projects::TriggersController < Projects::ApplicationController
@trigger = project.triggers.create(trigger_params.merge(owner: current_user))
if @trigger.valid?
- flash[:notice] = 'Trigger was created successfully.'
+ flash[:notice] = _('Trigger was created successfully.')
else
- flash[:alert] = 'You could not create a new trigger.'
+ flash[:alert] = _('You could not create a new trigger.')
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
@@ -26,9 +26,9 @@ class Projects::TriggersController < Projects::ApplicationController
def take_ownership
if trigger.update(owner: current_user)
- flash[:notice] = 'Trigger was re-assigned.'
+ flash[:notice] = _('Trigger was re-assigned.')
else
- flash[:alert] = 'You could not take ownership of trigger.'
+ flash[:alert] = _('You could not take ownership of trigger.')
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers')
@@ -39,7 +39,7 @@ class Projects::TriggersController < Projects::ApplicationController
def update
if trigger.update(trigger_params)
- redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: 'Trigger was successfully updated.'
+ redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), notice: _('Trigger was successfully updated.')
else
render action: "edit"
end
@@ -47,9 +47,9 @@ class Projects::TriggersController < Projects::ApplicationController
def destroy
if trigger.destroy
- flash[:notice] = "Trigger removed."
+ flash[:notice] = _("Trigger removed.")
else
- flash[:alert] = "Could not remove the trigger."
+ flash[:alert] = _("Could not remove the trigger.")
end
redirect_to project_settings_ci_cd_path(@project, anchor: 'js-pipeline-triggers'), status: :found
@@ -66,12 +66,11 @@ class Projects::TriggersController < Projects::ApplicationController
end
def trigger
- @trigger ||= project.triggers.find(params[:id]) || render_404
+ @trigger ||= project.triggers.find(params[:id])
+ .present(current_user: current_user)
end
def trigger_params
- params.require(:trigger).permit(
- :description
- )
+ params.require(:trigger).permit(:description)
end
end
diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb
index bb658bfcc19..646728e8167 100644
--- a/app/controllers/projects/variables_controller.rb
+++ b/app/controllers/projects/variables_controller.rb
@@ -38,6 +38,6 @@ class Projects::VariablesController < Projects::ApplicationController
end
def variable_params_attributes
- %i[id key secret_value protected _destroy]
+ %i[id variable_type key secret_value protected masked _destroy]
end
end
diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb
index 88dd111132b..fa5bdbc7d49 100644
--- a/app/controllers/projects/wikis_controller.rb
+++ b/app/controllers/projects/wikis_controller.rb
@@ -16,7 +16,10 @@ class Projects::WikisController < Projects::ApplicationController
end
def pages
- @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page])
+ @wiki_pages = Kaminari.paginate_array(
+ @project_wiki.list_pages(sort: params[:sort], direction: params[:direction])
+ ).page(params[:page])
+
@wiki_entries = WikiPage.group_by_directory(@wiki_pages)
end
@@ -49,7 +52,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.valid?
redirect_to(
project_wiki_path(@project, @page),
- notice: 'Wiki was successfully updated.'
+ notice: _('Wiki was successfully updated.')
)
else
render 'edit'
@@ -65,7 +68,7 @@ class Projects::WikisController < Projects::ApplicationController
if @page.persisted?
redirect_to(
project_wiki_path(@project, @page),
- notice: 'Wiki was successfully updated.'
+ notice: _('Wiki was successfully updated.')
)
else
render action: "edit"
@@ -85,7 +88,7 @@ class Projects::WikisController < Projects::ApplicationController
else
redirect_to(
project_wiki_path(@project, :home),
- notice: "Page not found"
+ notice: _("Page not found")
)
end
end
@@ -95,7 +98,7 @@ class Projects::WikisController < Projects::ApplicationController
redirect_to project_wiki_path(@project, :home),
status: 302,
- notice: "Page was successfully deleted"
+ notice: _("Page was successfully deleted")
rescue Gitlab::Git::Wiki::OperationError => e
@error = e
render 'edit'
@@ -115,10 +118,10 @@ class Projects::WikisController < Projects::ApplicationController
@sidebar_page = @project_wiki.find_sidebar(params[:version_id])
unless @sidebar_page # Fallback to default sidebar
- @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.pages(limit: 15))
+ @sidebar_wiki_entries = WikiPage.group_by_directory(@project_wiki.list_pages(limit: 15))
end
rescue ProjectWiki::CouldNotCreateWikiError
- flash[:notice] = "Could not create Wiki Repository at this time. Please try again later."
+ flash[:notice] = _("Could not create Wiki Repository at this time. Please try again later.")
redirect_to project_path(@project)
false
end
@@ -155,7 +158,7 @@ class Projects::WikisController < Projects::ApplicationController
end
def set_encoding_error
- flash.now[:notice] = "The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository."
+ flash.now[:notice] = _("The content of this page is not encoded in UTF-8. Edits can only be made via the Git repository.")
end
def file_blob
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 8bf93bfd68d..e88c46144ef 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -6,19 +6,23 @@ class ProjectsController < Projects::ApplicationController
include ExtractsPath
include PreviewMarkdown
include SendFileUpload
+ include RecordUserLastActivity
prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) }
+ around_action :allow_gitaly_ref_name_caching, only: [:index, :show]
+
before_action :whitelist_query_limiting, only: [:create]
- before_action :authenticate_user!, except: [:index, :show, :activity, :refs]
+ before_action :authenticate_user!, except: [:index, :show, :activity, :refs, :resolve]
before_action :redirect_git_extension, only: [:show]
- before_action :project, except: [:index, :new, :create]
- before_action :repository, except: [:index, :new, :create]
+ before_action :project, except: [:index, :new, :create, :resolve]
+ before_action :repository, except: [:index, :new, :create, :resolve]
before_action :assign_ref_vars, only: [:show], if: :repo_exists?
before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
before_action :present_project, only: [:edit]
+ before_action :authorize_download_code!, only: [:refs]
# Authorize
before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export]
@@ -32,10 +36,10 @@ class ProjectsController < Projects::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def new
- namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
- return access_denied! if namespace && !can?(current_user, :create_projects, namespace)
+ @namespace = Namespace.find_by(id: params[:namespace_id]) if params[:namespace_id]
+ return access_denied! if @namespace && !can?(current_user, :create_projects, @namespace)
- @project = Project.new(namespace_id: namespace&.id)
+ @project = Project.new(namespace_id: @namespace&.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -45,7 +49,7 @@ class ProjectsController < Projects::ApplicationController
end
def create
- @project = ::Projects::CreateService.new(current_user, project_params).execute
+ @project = ::Projects::CreateService.new(current_user, project_params(attributes: project_params_create_attributes)).execute
if @project.saved?
cookies[:issue_board_welcome_hidden] = { path: project_path(@project), value: nil, expires: Time.at(0) }
@@ -233,7 +237,7 @@ class ProjectsController < Projects::ApplicationController
def toggle_star
current_user.toggle_star(@project)
- @project.reload
+ @project.reset
render json: {
star_count: @project.star_count
@@ -326,9 +330,9 @@ class ProjectsController < Projects::ApplicationController
end
# rubocop: enable CodeReuse/ActiveRecord
- def project_params
+ def project_params(attributes: [])
params.require(:project)
- .permit(project_params_attributes)
+ .permit(project_params_attributes + attributes)
end
def project_params_attributes
@@ -341,17 +345,17 @@ class ProjectsController < Projects::ApplicationController
:container_registry_enabled,
:default_branch,
:description,
+ :external_authorization_classification_label,
:import_url,
:issues_tracker,
:issues_tracker_id,
:last_activity_at,
:lfs_enabled,
:name,
- :namespace_id,
:only_allow_merge_if_all_discussions_are_resolved,
:only_allow_merge_if_pipeline_succeeds,
- :printing_merge_request_link_enabled,
:path,
+ :printing_merge_request_link_enabled,
:public_builds,
:request_access_enabled,
:runners_token,
@@ -373,6 +377,10 @@ class ProjectsController < Projects::ApplicationController
]
end
+ def project_params_create_attributes
+ [:namespace_id]
+ end
+
def custom_import_params
{}
end
@@ -441,4 +449,14 @@ class ProjectsController < Projects::ApplicationController
def present_project
@project = @project.present(current_user: current_user)
end
+
+ def resolve
+ @project = Project.find(params[:id])
+
+ if can?(current_user, :read_project, @project)
+ redirect_to @project
+ else
+ render_404
+ end
+ end
end
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index 8b8d87524a8..07b38371ab9 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -4,6 +4,7 @@ class RegistrationsController < Devise::RegistrationsController
include Recaptcha::Verify
include AcceptsPendingInvitations
+ prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, only: [:destroy]
before_action :ensure_terms_accepted,
if: -> { Gitlab::CurrentSettings.current_application_settings.enforce_terms? },
@@ -21,15 +22,10 @@ class RegistrationsController < Devise::RegistrationsController
params[resource_name] = params.delete(:"new_#{resource_name}")
end
- if !Gitlab::Recaptcha.load_configurations! || verify_recaptcha
- accept_pending_invitations
- super do |new_user|
- persist_accepted_terms_if_required(new_user)
- end
- else
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
- flash.delete :recaptcha_error
- render action: 'new'
+ accept_pending_invitations
+
+ super do |new_user|
+ persist_accepted_terms_if_required(new_user)
end
rescue Gitlab::Access::AccessDeniedError
redirect_to(new_user_session_path)
@@ -89,6 +85,17 @@ class RegistrationsController < Devise::RegistrationsController
private
+ def check_captcha
+ return unless Feature.enabled?(:registrations_recaptcha, default_enabled: true)
+ return unless Gitlab::Recaptcha.load_configurations!
+
+ return if verify_recaptcha
+
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
+ flash.delete :recaptcha_error
+ render action: 'new'
+ end
+
def sign_up_params
params.require(:user).permit(:username, :email, :email_confirmation, :name, :password)
end
diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb
index 7b6657e1196..f1b39125a48 100644
--- a/app/controllers/root_controller.rb
+++ b/app/controllers/root_controller.rb
@@ -15,7 +15,7 @@ class RootController < Dashboard::ProjectsController
before_action :redirect_logged_user, if: -> { current_user.present? }
def index
- # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37434
+ # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40260
Gitlab::GitalyClient.allow_n_plus_1_calls do
super
end
diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb
index 1b22907c10f..a80ab3bcd28 100644
--- a/app/controllers/search_controller.rb
+++ b/app/controllers/search_controller.rb
@@ -14,8 +14,6 @@ class SearchController < ApplicationController
layout 'search'
def show
- search_service = SearchService.new(current_user, params)
-
@project = search_service.project
@group = search_service.group
@@ -29,6 +27,7 @@ class SearchController < ApplicationController
@search_objects = search_service.search_objects
render_commits if @scope == 'commits'
+ eager_load_user_status if @scope == 'users'
check_single_commit_result
end
@@ -54,6 +53,12 @@ class SearchController < ApplicationController
@search_objects = prepare_commits_for_rendering(@search_objects)
end
+ def eager_load_user_status
+ return if Feature.disabled?(:users_search, default_enabled: true)
+
+ @search_objects = @search_objects.eager_load(:status) # rubocop:disable CodeReuse/ActiveRecord
+ end
+
def check_single_commit_result
if @search_results.single_commit_result?
only_commit = @search_results.objects('commits').first
diff --git a/app/controllers/sent_notifications_controller.rb b/app/controllers/sent_notifications_controller.rb
index 2b76921ebd8..77757c4a3ef 100644
--- a/app/controllers/sent_notifications_controller.rb
+++ b/app/controllers/sent_notifications_controller.rb
@@ -16,7 +16,7 @@ class SentNotificationsController < ApplicationController
noteable = @sent_notification.noteable
noteable.unsubscribe(@sent_notification.recipient, @sent_notification.project)
- flash[:notice] = "You have been unsubscribed from this thread."
+ flash[:notice] = _("You have been unsubscribed from this thread.")
if current_user
redirect_to noteable_path(noteable)
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 4bd7d71e264..6fea61cf45d 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -8,6 +8,8 @@ class SessionsController < Devise::SessionsController
include Recaptcha::Verify
skip_before_action :check_two_factor_requirement, only: [:destroy]
+ # replaced with :require_no_authentication_without_flash
+ skip_before_action :require_no_authentication, only: [:new, :create]
prepend_before_action :check_initial_setup, only: [:new]
prepend_before_action :authenticate_with_two_factor,
@@ -15,6 +17,8 @@ class SessionsController < Devise::SessionsController
prepend_before_action :check_captcha, only: [:create]
prepend_before_action :store_redirect_uri, only: [:new]
prepend_before_action :ldap_servers, only: [:new, :create]
+ prepend_before_action :require_no_authentication_without_flash, only: [:new, :create]
+
before_action :auto_sign_in_with_provider, only: [:new]
before_action :load_recaptcha
@@ -54,6 +58,14 @@ class SessionsController < Devise::SessionsController
private
+ def require_no_authentication_without_flash
+ require_no_authentication
+
+ if flash[:alert] == I18n.t('devise.failure.already_authenticated')
+ flash[:alert] = nil
+ end
+ end
+
def captcha_enabled?
request.headers[CAPTCHA_HEADER] && Gitlab::Recaptcha.enabled?
end
@@ -70,7 +82,7 @@ class SessionsController < Devise::SessionsController
increment_failed_login_captcha_counter
self.resource = resource_class.new
- flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
+ flash[:alert] = _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.')
flash.delete :recaptcha_error
respond_with_navigational(resource) { render :new }
@@ -122,7 +134,7 @@ class SessionsController < Devise::SessionsController
end
redirect_to edit_user_password_path(reset_password_token: @token),
- notice: "Please create a password for your new account."
+ notice: _("Please create a password for your new account.")
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/controllers/sherlock/transactions_controller.rb b/app/controllers/sherlock/transactions_controller.rb
index 46e382e594e..8d1847507cc 100644
--- a/app/controllers/sherlock/transactions_controller.rb
+++ b/app/controllers/sherlock/transactions_controller.rb
@@ -15,7 +15,7 @@ module Sherlock
def destroy_all
Gitlab::Sherlock.collection.clear
- redirect_to :back, status: :found
+ redirect_back_or_default(options: { status: :found })
end
end
end
diff --git a/app/controllers/snippets/notes_controller.rb b/app/controllers/snippets/notes_controller.rb
index 091bcb1253d..eee14b0faf4 100644
--- a/app/controllers/snippets/notes_controller.rb
+++ b/app/controllers/snippets/notes_controller.rb
@@ -26,10 +26,6 @@ class Snippets::NotesController < ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
alias_method :noteable, :snippet
- def note_params
- super.merge(noteable_id: params[:snippet_id])
- end
-
def finder_params
params.merge(last_fetched_at: last_fetched_at, target_id: snippet.id, target_type: 'personal_snippet')
end
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index dd9bf17cf0c..8ea5450b4e8 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -80,7 +80,13 @@ class SnippetsController < ApplicationController
render_blob_json(blob)
end
- format.js { render 'shared/snippets/show' }
+ format.js do
+ if @snippet.embeddable?
+ render 'shared/snippets/show'
+ else
+ head :not_found
+ end
+ end
end
end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index fa5d84633b5..5d28635232b 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -28,13 +28,13 @@ class UploadsController < ApplicationController
end
def find_model
- return nil unless params[:id]
+ return unless params[:id]
upload_model_class.find(params[:id])
end
def authorize_access!
- return nil unless model
+ return unless model
authorized =
case model
@@ -45,7 +45,7 @@ class UploadsController < ApplicationController
when Appearance
true
else
- permission = "read_#{model.class.to_s.underscore}".to_sym
+ permission = "read_#{model.class.underscore}".to_sym
can?(current_user, permission, model)
end
@@ -54,10 +54,11 @@ class UploadsController < ApplicationController
end
def authorize_create_access!
- return nil unless model
+ return unless model
- # for now we support only personal snippets comments
- authorized = can?(current_user, :comment_personal_snippet, model)
+ # for now we support only personal snippets comments. Only personal_snippet
+ # is allowed as a model to #create through routing.
+ authorized = can?(current_user, :create_note, model)
render_unauthorized unless authorized
end
@@ -70,6 +71,10 @@ class UploadsController < ApplicationController
end
end
+ def cache_publicly?
+ User === model || Appearance === model
+ end
+
def upload_model_class
MODEL_CLASSES[params[:model]] || raise(UnknownUploadModelError)
end
diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb
index fbb1cfc5c66..b2799565f57 100644
--- a/app/finders/admin/runners_finder.rb
+++ b/app/finders/admin/runners_finder.rb
@@ -11,10 +11,11 @@ class Admin::RunnersFinder < UnionFinder
search!
filter_by_status!
filter_by_runner_type!
+ filter_by_tag_list!
sort!
paginate!
- @runners
+ @runners.with_tags
end
def sort_key
@@ -44,6 +45,14 @@ class Admin::RunnersFinder < UnionFinder
filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
end
+ def filter_by_tag_list!
+ tag_list = @params[:tag_name].presence
+
+ if tag_list
+ @runners = @runners.tagged_with(tag_list)
+ end
+ end
+
def sort!
@runners = @runners.order_by(sort_key)
end
diff --git a/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
new file mode 100644
index 00000000000..f38c187799c
--- /dev/null
+++ b/app/finders/autocomplete/acts_as_taggable_on/tags_finder.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module Autocomplete
+ module ActsAsTaggableOn
+ class TagsFinder
+ LIMIT = 20
+
+ def initialize(params:)
+ @params = params
+ end
+
+ def execute
+ tags = all_tags
+ tags = filter_by_name(tags)
+ limit(tags)
+ end
+
+ private
+
+ def all_tags
+ ::ActsAsTaggableOn::Tag.all
+ end
+
+ def filter_by_name(tags)
+ return tags unless search
+ return tags.none if search.empty?
+
+ if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING
+ tags.named_like(search)
+ else
+ tags.named(search)
+ end
+ end
+
+ def limit(tags)
+ tags.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def search
+ @params[:search]
+ end
+ end
+ end
+end
diff --git a/app/finders/autocomplete/users_finder.rb b/app/finders/autocomplete/users_finder.rb
index 45955783be9..ce7d0b8699c 100644
--- a/app/finders/autocomplete/users_finder.rb
+++ b/app/finders/autocomplete/users_finder.rb
@@ -2,6 +2,8 @@
module Autocomplete
class UsersFinder
+ include Gitlab::Utils::StrongMemoize
+
# The number of users to display in the results is hardcoded to 20, and
# pagination is not supported. This ensures that performance remains
# consistent and removes the need for implementing keyset pagination to
@@ -31,7 +33,7 @@ module Autocomplete
# Include current user if available to filter by "Me"
items.unshift(current_user) if prepend_current_user?
- if prepend_author? && (author = User.find_by_id(author_id))
+ if prepend_author? && author&.active?
items.unshift(author)
end
end
@@ -41,6 +43,12 @@ module Autocomplete
private
+ def author
+ strong_memoize(:author) do
+ User.find_by_id(author_id)
+ end
+ end
+
# Returns the users based on the input parameters, as an Array.
#
# This method is separate so it is easier to extend in EE.
diff --git a/app/finders/cluster_ancestors_finder.rb b/app/finders/cluster_ancestors_finder.rb
new file mode 100644
index 00000000000..2f9709ee057
--- /dev/null
+++ b/app/finders/cluster_ancestors_finder.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+class ClusterAncestorsFinder
+ include Gitlab::Utils::StrongMemoize
+
+ def initialize(clusterable, current_user)
+ @clusterable = clusterable
+ @current_user = current_user
+ end
+
+ def execute
+ return [] unless can_read_clusters?
+
+ clusterable.clusters + ancestor_clusters
+ end
+
+ def has_ancestor_clusters?
+ ancestor_clusters.any?
+ end
+
+ private
+
+ attr_reader :clusterable, :current_user
+
+ def can_read_clusters?
+ Ability.allowed?(current_user, :read_cluster, clusterable)
+ end
+
+ # This unfortunately returns an Array, not a Relation!
+ def ancestor_clusters
+ strong_memoize(:ancestor_clusters) do
+ Clusters::Cluster.ancestor_clusters_for_clusterable(clusterable)
+ end
+ end
+end
diff --git a/app/finders/concerns/finder_methods.rb b/app/finders/concerns/finder_methods.rb
index 5290313585f..8de3276184d 100644
--- a/app/finders/concerns/finder_methods.rb
+++ b/app/finders/concerns/finder_methods.rb
@@ -3,13 +3,13 @@
module FinderMethods
# rubocop: disable CodeReuse/ActiveRecord
def find_by!(*args)
- raise_not_found_unless_authorized execute.find_by!(*args)
+ raise_not_found_unless_authorized execute.reorder(nil).find_by!(*args)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_by(*args)
- if_authorized execute.find_by(*args)
+ if_authorized execute.reorder(nil).find_by(*args)
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/concerns/finder_with_cross_project_access.rb b/app/finders/concerns/finder_with_cross_project_access.rb
index 220f62bcc7f..06ebb286086 100644
--- a/app/finders/concerns/finder_with_cross_project_access.rb
+++ b/app/finders/concerns/finder_with_cross_project_access.rb
@@ -5,7 +5,8 @@
#
# This module depends on the finder implementing the following methods:
#
-# - `#execute` should return an `ActiveRecord::Relation`
+# - `#execute` should return an `ActiveRecord::Relation` or the `model` needs to
+# be defined in the call to `requires_cross_project_access`.
# - `#current_user` the user that requires access (or nil)
module FinderWithCrossProjectAccess
extend ActiveSupport::Concern
@@ -13,20 +14,35 @@ module FinderWithCrossProjectAccess
prepended do
extend Gitlab::CrossProjectAccess::ClassMethods
+
+ cattr_accessor :finder_model
+
+ def self.requires_cross_project_access(*args)
+ super
+
+ self.finder_model = extract_model_from_arguments(args)
+ end
+
+ private
+
+ def self.extract_model_from_arguments(args)
+ args.detect { |argument| argument.is_a?(Hash) && argument[:model] }
+ &.fetch(:model)
+ end
end
override :execute
def execute(*args)
check = Gitlab::CrossProjectAccess.find_check(self)
- original = super
+ original = -> { super }
- return original unless check
- return original if should_skip_cross_project_check || can_read_cross_project?
+ return original.call unless check
+ return original.call if should_skip_cross_project_check || can_read_cross_project?
if check.should_run?(self)
- original.model.none
+ finder_model&.none || original.call.model.none
else
- original
+ original.call
end
end
@@ -48,8 +64,6 @@ module FinderWithCrossProjectAccess
skip_cross_project_check { super }
end
- private
-
attr_accessor :should_skip_cross_project_check
def skip_cross_project_check
diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb
index c1ef9dfefa7..f8c7f0c3167 100644
--- a/app/finders/contributed_projects_finder.rb
+++ b/app/finders/contributed_projects_finder.rb
@@ -14,6 +14,9 @@ class ContributedProjectsFinder < UnionFinder
# Returns an ActiveRecord::Relation.
# rubocop: disable CodeReuse/ActiveRecord
def execute(current_user = nil)
+ # Do not show contributed projects if the user profile is private.
+ return Project.none unless can_read_profile?(current_user)
+
segments = all_projects(current_user)
find_union(segments, Project).includes(:namespace).order_id_desc
@@ -22,6 +25,10 @@ class ContributedProjectsFinder < UnionFinder
private
+ def can_read_profile?(current_user)
+ Ability.allowed?(current_user, :read_user_profile, @user)
+ end
+
def all_projects(current_user)
projects = []
diff --git a/app/finders/events_finder.rb b/app/finders/events_finder.rb
index 8df01f1dad9..234b7090fd9 100644
--- a/app/finders/events_finder.rb
+++ b/app/finders/events_finder.rb
@@ -3,22 +3,27 @@
class EventsFinder
prepend FinderMethods
prepend FinderWithCrossProjectAccess
+
+ MAX_PER_PAGE = 100
+
attr_reader :source, :params, :current_user
- requires_cross_project_access unless: -> { source.is_a?(Project) }
+ requires_cross_project_access unless: -> { source.is_a?(Project) }, model: Event
# Used to filter Events
#
# Arguments:
# source - which user or project to looks for events on
# current_user - only return events for projects visible to this user
- # WARNING: does not consider project feature visibility!
# params:
# action: string
# target_type: string
# before: datetime
# after: datetime
- #
+ # per_page: integer (max. 100)
+ # page: integer
+ # with_associations: boolean
+ # sort: 'asc' or 'desc'
def initialize(params = {})
@source = params.delete(:source)
@current_user = params.delete(:current_user)
@@ -33,15 +38,18 @@ class EventsFinder
events = by_target_type(events)
events = by_created_at_before(events)
events = by_created_at_after(events)
+ events = sort(events)
+
+ events = events.with_associations if params[:with_associations]
- events
+ paginated_filtered_by_user_visibility(events)
end
private
# rubocop: disable CodeReuse/ActiveRecord
def by_current_user_access(events)
- events.merge(ProjectsFinder.new(current_user: current_user).execute) # rubocop: disable CodeReuse/Finder
+ events.merge(Project.public_or_visible_to_user(current_user))
.joins(:project)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -77,4 +85,31 @@ class EventsFinder
events.where('events.created_at > ?', params[:after].end_of_day)
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def sort(events)
+ return events unless params[:sort]
+
+ if params[:sort] == 'asc'
+ events.order_id_asc
+ else
+ events.order_id_desc
+ end
+ end
+
+ def paginated_filtered_by_user_visibility(events)
+ limited_events = events.page(page).per(per_page)
+ visible_events = limited_events.select { |event| event.visible_to_user?(current_user) }
+
+ Kaminari.paginate_array(visible_events, total_count: events.count)
+ end
+
+ def per_page
+ return MAX_PER_PAGE unless params[:per_page]
+
+ [params[:per_page], MAX_PER_PAGE].min
+ end
+
+ def page
+ params[:page] || 1
+ end
end
diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb
index a9ce5be13f3..ec340f38450 100644
--- a/app/finders/group_descendants_finder.rb
+++ b/app/finders/group_descendants_finder.rb
@@ -112,7 +112,7 @@ class GroupDescendantsFinder
# rubocop: disable CodeReuse/ActiveRecord
def ancestors_of_groups(base_for_ancestors)
group_ids = base_for_ancestors.except(:select, :sort).select(:id)
- Gitlab::GroupHierarchy.new(Group.where(id: group_ids))
+ Gitlab::ObjectHierarchy.new(Group.where(id: group_ids))
.base_and_ancestors(upto: parent_group.id)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -132,9 +132,9 @@ class GroupDescendantsFinder
end
def subgroups
- return Group.none unless Group.supports_nested_groups?
+ return Group.none unless Group.supports_nested_objects?
- # When filtering subgroups, we want to find all matches withing the tree of
+ # When filtering subgroups, we want to find all matches within the tree of
# descendants to show to the user
groups = if params[:filter]
subgroups_matching_filter
@@ -183,7 +183,7 @@ class GroupDescendantsFinder
# rubocop: disable CodeReuse/ActiveRecord
def hierarchy_for_parent
- @hierarchy ||= Gitlab::GroupHierarchy.new(Group.where(id: parent_group.id))
+ @hierarchy ||= Gitlab::ObjectHierarchy.new(Group.where(id: parent_group.id))
end
# rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb
index ea954f98220..7d419103b1c 100644
--- a/app/finders/groups_finder.rb
+++ b/app/finders/groups_finder.rb
@@ -11,6 +11,7 @@
# parent: Group
# all_available: boolean (defaults to true)
# min_access_level: integer
+# exclude_group_ids: array of integers
#
# Users with full private access can see all groups. The `owned` and `parent`
# params can be used to restrict the groups that are returned.
@@ -29,6 +30,7 @@ class GroupsFinder < UnionFinder
items = all_groups.map do |item|
item = by_parent(item)
item = by_custom_attributes(item)
+ item = exclude_group_ids(item)
item
end
@@ -46,7 +48,7 @@ class GroupsFinder < UnionFinder
return [Group.all] if current_user&.full_private_access? && all_available?
groups = []
- groups << Gitlab::GroupHierarchy.new(groups_for_ancestors, groups_for_descendants).all_groups if current_user
+ groups << Gitlab::ObjectHierarchy.new(groups_for_ancestors, groups_for_descendants).all_objects if current_user
groups << Group.unscoped.public_to_user(current_user) if include_public_groups?
groups << Group.none if groups.empty?
groups
@@ -66,12 +68,18 @@ class GroupsFinder < UnionFinder
.groups
.where('members.access_level >= ?', params[:min_access_level])
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(groups)
.base_and_descendants
end
# rubocop: enable CodeReuse/ActiveRecord
+ def exclude_group_ids(groups)
+ return groups unless params[:exclude_group_ids]
+
+ groups.id_not_in(params[:exclude_group_ids])
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_parent(groups)
return groups unless params[:parent]
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index b73a3fa6e01..50e9418677c 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -18,6 +18,7 @@
# assignee_id: integer or 'None' or 'Any'
# assignee_username: string
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
@@ -28,6 +29,7 @@
# updated_after: datetime
# updated_before: datetime
# attempt_group_search_optimizations: boolean
+# attempt_project_search_optimizations: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -56,6 +58,7 @@ class IssuableFinder
milestone_title
my_reaction_emoji
search
+ in
]
end
@@ -76,22 +79,24 @@ class IssuableFinder
items = init_collection
items = filter_items(items)
- # This has to be last as we may use a CTE as an optimization fence
- # by passing the attempt_group_search_optimizations param and
- # enabling the use_cte_for_group_issues_search feature flag
+ # This has to be last as we use a CTE as an optimization fence
+ # for counts by passing the force_cte param and enabling the
+ # attempt_group_search_optimizations feature flag
# https://www.postgresql.org/docs/current/static/queries-with.html
items = by_search(items)
- sort(items)
+ items = sort(items)
+
+ items
end
def filter_items(items)
items = by_project(items)
items = by_group(items)
- items = by_subquery(items)
items = by_scope(items)
items = by_created_at(items)
items = by_updated_at(items)
+ items = by_closed_at(items)
items = by_state(items)
items = by_group(items)
items = by_assignee(items)
@@ -114,8 +119,9 @@ class IssuableFinder
#
# rubocop: disable CodeReuse/ActiveRecord
def count_by_state
- count_params = params.merge(state: nil, sort: nil)
+ count_params = params.merge(state: nil, sort: nil, force_cte: true)
finder = self.class.new(current_user, count_params)
+
counts = Hash.new(0)
# Searching by label includes a GROUP BY in the query, but ours will be last
@@ -125,6 +131,11 @@ class IssuableFinder
#
# This does not apply when we are using a CTE for the search, as the labels
# GROUP BY is inside the subquery in that case, so we set labels_count to 1.
+ #
+ # Groups and projects have separate feature flags to suggest the use
+ # of a CTE. The CTE will not be used if the sort doesn't support it,
+ # but will always be used for the counts here as we ignore sorting
+ # anyway.
labels_count = label_names.any? ? label_names.count : 1
labels_count = 1 if use_cte_for_search?
@@ -149,6 +160,18 @@ class IssuableFinder
end
end
+ def related_groups
+ if project? && project && project.group && Ability.allowed?(current_user, :read_group, project.group)
+ project.group.self_and_ancestors
+ elsif group
+ [group]
+ elsif current_user
+ Gitlab::ObjectHierarchy.new(current_user.authorized_groups, current_user.groups).all_objects
+ else
+ []
+ end
+ end
+
def project?
params[:project_id].present?
end
@@ -162,23 +185,32 @@ class IssuableFinder
@project = project
end
- # rubocop: disable CodeReuse/ActiveRecord
- def projects(items = nil)
- return @projects = project if project?
+ def projects
+ return @projects if defined?(@projects)
+
+ return @projects = [project] if project?
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
- current_user.authorized_projects
+ current_user.authorized_projects(min_access_level)
elsif group
- finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
- GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder
+ find_group_projects
else
- ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder
+ Project.public_or_visible_to_user(current_user, min_access_level)
end
- @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
+ @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def find_group_projects
+ return Project.none unless group
+
+ if params[:include_subgroups]
+ Project.where(namespace_id: group.self_and_descendants) # rubocop: disable CodeReuse/ActiveRecord
+ else
+ group.projects
+ end.public_or_visible_to_user(current_user, min_access_level)
end
- # rubocop: enable CodeReuse/ActiveRecord
def search
params[:search].presence
@@ -286,29 +318,35 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- def use_subquery_for_search?
- strong_memoize(:use_subquery_for_search) do
- attempt_group_search_optimizations? &&
- Feature.enabled?(:use_subquery_for_group_issues_search, default_enabled: false)
- end
- end
-
def use_cte_for_search?
strong_memoize(:use_cte_for_search) do
- attempt_group_search_optimizations? &&
- !use_subquery_for_search? &&
- Feature.enabled?(:use_cte_for_group_issues_search, default_enabled: true)
+ next false unless search
+ next false unless Gitlab::Database.postgresql?
+ # Only simple unsorted & simple sorts can use CTE
+ next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys)
+
+ attempt_group_search_optimizations? || attempt_project_search_optimizations?
end
end
private
+ def force_cte?
+ !!params[:force_cte]
+ end
+
def init_collection
klass.all
end
def attempt_group_search_optimizations?
- search && Gitlab::Database.postgresql? && params[:attempt_group_search_optimizations]
+ params[:attempt_group_search_optimizations] &&
+ Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true)
+ end
+
+ def attempt_project_search_optimizations?
+ params[:attempt_project_search_optimizations] &&
+ Feature.enabled?(:attempt_project_search_optimizations, default_enabled: true)
end
def count_key(value)
@@ -337,6 +375,13 @@ class IssuableFinder
items
end
+ def by_closed_at(items)
+ items = items.closed_after(params[:closed_after]) if params[:closed_after].present?
+ items = items.closed_before(params[:closed_before]) if params[:closed_before].present?
+
+ items
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_state(items)
case params[:state].to_s
@@ -374,15 +419,6 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # Wrap projects and groups in a subquery if the conditions are met.
- def by_subquery(items)
- if use_subquery_for_search?
- klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord
- else
- items
- end
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def by_search(items)
return items unless search
@@ -394,7 +430,7 @@ class IssuableFinder
items = klass.with(cte.to_arel).from(klass.table_name)
end
- items.full_search(search)
+ items.full_search(search, matched_columns: params[:in])
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -412,22 +448,6 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
- def by_assignee(items)
- if filter_by_no_assignee?
- items.where(assignee_id: nil)
- elsif filter_by_any_assignee?
- items.where('assignee_id IS NOT NULL')
- elsif assignee
- items.where(assignee_id: assignee.id)
- elsif assignee_id? || assignee_username? # assignee not found
- items.none
- else
- items
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def filter_by_no_assignee?
# Assignee_id takes precedence over assignee_username
[NONE, FILTER_NONE].include?(params[:assignee_id].to_s.downcase) || params[:assignee_username].to_s == NONE
@@ -451,6 +471,20 @@ class IssuableFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_assignee(items)
+ if filter_by_no_assignee?
+ items.unassigned
+ elsif filter_by_any_assignee?
+ items.assigned
+ elsif assignee
+ items.assigned_to(assignee)
+ elsif assignee_id? || assignee_username? # assignee not found
+ items.none
+ else
+ items
+ end
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def by_milestone(items)
if milestones?
@@ -459,10 +493,10 @@ class IssuableFinder
elsif filter_by_any_milestone?
items = items.any_milestone
elsif filter_by_upcoming_milestone?
- upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items))
+ upcoming_ids = Milestone.upcoming_ids(projects, related_groups)
items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
elsif filter_by_started_milestone?
- items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
+ items = items.left_joins_milestones.merge(Milestone.started)
else
items = items.with_milestone(params[:milestone_title])
end
@@ -544,4 +578,8 @@ class IssuableFinder
scope = params[:scope]
scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
end
+
+ def min_access_level
+ ProjectFeature.required_minimum_access_level(klass)
+ end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index 45e494725d7..58a01d598ba 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -14,6 +14,7 @@
# milestone_title: string
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# my_reaction_emoji: string
@@ -47,9 +48,9 @@ class IssuesFinder < IssuableFinder
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
- OR issues.project_id IN(:project_ids)))',
+ OR EXISTS (:authorizations)))',
user_id: current_user.id,
- project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
+ authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id"))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -68,7 +69,16 @@ class IssuesFinder < IssuableFinder
end
def filter_items(items)
- by_due_date(super)
+ issues = super
+ issues = by_due_date(issues)
+ issues = by_confidential(issues)
+ issues
+ end
+
+ def by_confidential(items)
+ return items if params[:confidential].nil?
+
+ params[:confidential] ? items.confidential_only : items.public_only
end
def by_due_date(items)
@@ -134,18 +144,4 @@ class IssuesFinder < IssuableFinder
current_user.blank?
end
-
- def by_assignee(items)
- if filter_by_no_assignee?
- items.unassigned
- elsif filter_by_any_assignee?
- items.assigned
- elsif assignee
- items.assigned_to(assignee)
- elsif assignee_id? || assignee_username? # assignee not found
- items.none
- else
- items
- end
- end
end
diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb
index f90a7868102..917de249104 100644
--- a/app/finders/members_finder.rb
+++ b/app/finders/members_finder.rb
@@ -9,25 +9,18 @@ class MembersFinder
@group = project.group
end
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(include_descendants: false)
+ def execute(include_descendants: false, include_invited_groups_members: false)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
- if group
- group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) # rubocop: disable CodeReuse/Finder
- group_members = group_members.non_invite
+ union_members = group_union_members(include_descendants, include_invited_groups_members)
- union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) # rubocop: disable Gitlab/Union
-
- sql = distinct_on(union)
-
- Member.includes(:user).from("(#{sql}) AS #{Member.table_name}")
+ if union_members.any?
+ distinct_union_of_members(union_members << project_members)
else
project_members
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def can?(*args)
Ability.allowed?(*args)
@@ -35,6 +28,34 @@ class MembersFinder
private
+ def group_union_members(include_descendants, include_invited_groups_members)
+ [].tap do |members|
+ members << direct_group_members(include_descendants) if group
+ members << project_invited_groups_members if include_invited_groups_members
+ end
+ end
+
+ def direct_group_members(include_descendants)
+ GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder
+ end
+
+ def project_invited_groups_members
+ invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
+ .new(project.invited_groups)
+ .base_and_ancestors
+ .public_or_visible_to_user(current_user)
+ .select(:id)
+
+ GroupMember.with_source_id(invited_groups_ids_including_ancestors)
+ end
+
+ def distinct_union_of_members(union_members)
+ union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union
+ sql = distinct_on(union)
+
+ Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
def distinct_on(union)
# We're interested in a list of members without duplicates by user_id.
# We prefer project members over group members, project members should go first.
diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb
index e190d5d90c9..29947bc94d5 100644
--- a/app/finders/merge_requests_finder.rb
+++ b/app/finders/merge_requests_finder.rb
@@ -15,6 +15,7 @@
# author_id: integer
# assignee_id: integer
# search: string
+# in: 'title', 'description', or a string joining them with comma
# label_name: string
# sort: string
# non_archived: boolean
@@ -28,7 +29,7 @@
#
class MergeRequestsFinder < IssuableFinder
def self.scalar_params
- @scalar_params ||= super + [:wip]
+ @scalar_params ||= super + [:wip, :target_branch]
end
def klass
@@ -36,13 +37,21 @@ class MergeRequestsFinder < IssuableFinder
end
def filter_items(_items)
- items = by_source_branch(super)
+ items = by_commit(super)
+ items = by_source_branch(items)
items = by_wip(items)
- by_target_branch(items)
+ items = by_target_branch(items)
+ by_source_project_id(items)
end
private
+ def by_commit(items)
+ return items unless params[:commit_sha].presence
+
+ items.by_commit_sha(params[:commit_sha])
+ end
+
def source_branch
@source_branch ||= params[:source_branch].presence
end
@@ -66,6 +75,16 @@ class MergeRequestsFinder < IssuableFinder
items.where(target_branch: target_branch)
end
+ def source_project_id
+ @source_project_id ||= params[:source_project_id].presence
+ end
+
+ def by_source_project_id(items)
+ return items unless source_project_id
+
+ items.where(source_project_id: source_project_id)
+ end
+
def by_wip(items)
if params[:wip] == 'yes'
items.where(wip_match(items.arel_table))
diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb
index 9c477978f60..77b55cbb838 100644
--- a/app/finders/milestones_finder.rb
+++ b/app/finders/milestones_finder.rb
@@ -3,8 +3,8 @@
# Search for milestones
#
# params - Hash
-# project_ids: Array of project ids or single project id.
-# group_ids: Array of group ids or single group id.
+# project_ids: Array of project ids or single project id or ActiveRecord relation.
+# group_ids: Array of group ids or single group id or ActiveRecord relation.
# order - Orders by field default due date asc.
# title - filter by title.
# state - filters by state.
@@ -12,20 +12,17 @@
class MilestonesFinder
include FinderMethods
- attr_reader :params, :project_ids, :group_ids
+ attr_reader :params
def initialize(params = {})
- @project_ids = Array(params[:project_ids])
- @group_ids = Array(params[:group_ids])
@params = params
end
def execute
- return Milestone.none if project_ids.empty? && group_ids.empty?
-
items = Milestone.all
items = by_groups_and_projects(items)
items = by_title(items)
+ items = by_search_title(items)
items = by_state(items)
order(items)
@@ -34,7 +31,7 @@ class MilestonesFinder
private
def by_groups_and_projects(items)
- items.for_projects_and_groups(project_ids, group_ids)
+ items.for_projects_and_groups(params[:project_ids], params[:group_ids])
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -47,6 +44,14 @@ class MilestonesFinder
end
# rubocop: enable CodeReuse/ActiveRecord
+ def by_search_title(items)
+ if params[:search_title].present?
+ items.search_title(params[:search_title])
+ else
+ items
+ end
+ end
+
def by_state(items)
Milestone.filter_by_state(items, params[:state])
end
diff --git a/app/finders/projects/daily_statistics_finder.rb b/app/finders/projects/daily_statistics_finder.rb
new file mode 100644
index 00000000000..912c23107bc
--- /dev/null
+++ b/app/finders/projects/daily_statistics_finder.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Projects
+ class DailyStatisticsFinder
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def fetches
+ ProjectDailyStatistic.of_project(project)
+ .of_last_30_days
+ .sorted_by_date_desc
+ end
+
+ def total_fetch_count
+ fetches.sum_fetch_count
+ end
+ end
+end
diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb
index 2b5d67e79d7..e5bffccabfe 100644
--- a/app/finders/projects/serverless/functions_finder.rb
+++ b/app/finders/projects/serverless/functions_finder.rb
@@ -3,8 +3,11 @@
module Projects
module Serverless
class FunctionsFinder
- def initialize(clusters)
- @clusters = clusters
+ attr_reader :project
+
+ def initialize(project)
+ @clusters = project.clusters
+ @project = project
end
def execute
@@ -15,17 +18,69 @@ module Projects
clusters_with_knative_installed.exists?
end
+ def service(environment_scope, name)
+ knative_service(environment_scope, name)&.first
+ end
+
+ def invocation_metrics(environment_scope, name)
+ return unless prometheus_adapter&.can_query?
+
+ cluster = clusters_with_knative_installed.preload_knative.find do |c|
+ environment_scope == c.environment_scope
+ end
+
+ func = ::Serverless::Function.new(project, name, cluster.kubernetes_namespace_for(project))
+ prometheus_adapter.query(:knative_invocation, func)
+ end
+
+ def has_prometheus?(environment_scope)
+ clusters_with_knative_installed.preload_knative.to_a.any? do |cluster|
+ environment_scope == cluster.environment_scope && cluster.application_prometheus_available?
+ end
+ end
+
private
+ def knative_service(environment_scope, name)
+ clusters_with_knative_installed.preload_knative.map do |cluster|
+ next if environment_scope != cluster.environment_scope
+
+ services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project))
+ .select { |svc| svc["metadata"]["name"] == name }
+
+ add_metadata(cluster, services).first unless services.nil?
+ end
+ end
+
def knative_services
clusters_with_knative_installed.preload_knative.map do |cluster|
- cluster.application_knative.services_for(ns: cluster.platform_kubernetes&.actual_namespace)
+ services = cluster.application_knative.services_for(ns: cluster.kubernetes_namespace_for(project))
+ add_metadata(cluster, services) unless services.nil?
+ end
+ end
+
+ def add_metadata(cluster, services)
+ services.each do |s|
+ s["environment_scope"] = cluster.environment_scope
+ s["cluster_id"] = cluster.id
+
+ if services.length == 1
+ s["podcount"] = cluster.application_knative.service_pod_details(
+ cluster.kubernetes_namespace_for(project),
+ s["metadata"]["name"]).length
+ end
end
end
def clusters_with_knative_installed
@clusters.with_knative_installed
end
+
+ # rubocop: disable CodeReuse/ServiceClass
+ def prometheus_adapter
+ @prometheus_adapter ||= ::Prometheus::AdapterService.new(project).prometheus_adapter
+ end
+ # rubocop: enable CodeReuse/ServiceClass
end
end
end
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 93d3c991846..23b731b1aed 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -62,7 +62,7 @@ class ProjectsFinder < UnionFinder
collection = by_personal(collection)
collection = by_starred(collection)
collection = by_trending(collection)
- collection = by_visibilty_level(collection)
+ collection = by_visibility_level(collection)
collection = by_tags(collection)
collection = by_search(collection)
collection = by_archived(collection)
@@ -71,12 +71,11 @@ class ProjectsFinder < UnionFinder
collection
end
- # rubocop: disable CodeReuse/ActiveRecord
def collection_with_user
if owned_projects?
current_user.owned_projects
elsif min_access_level?
- current_user.authorized_projects.where('project_authorizations.access_level >= ?', params[:min_access_level])
+ current_user.authorized_projects(params[:min_access_level])
else
if private_only?
current_user.authorized_projects
@@ -85,7 +84,6 @@ class ProjectsFinder < UnionFinder
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
# Builds a collection for an anonymous user.
def collection_without_user
@@ -131,7 +129,7 @@ class ProjectsFinder < UnionFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def by_visibilty_level(items)
+ def by_visibility_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb
new file mode 100644
index 00000000000..59e84198fde
--- /dev/null
+++ b/app/finders/releases_finder.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ReleasesFinder
+ def initialize(project, current_user = nil)
+ @project = project
+ @current_user = current_user
+ end
+
+ def execute
+ return Release.none unless Ability.allowed?(@current_user, :read_release, @project)
+
+ @project.releases.sorted
+ end
+end
diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb
index d3774746cb8..bf29f15642d 100644
--- a/app/finders/snippets_finder.rb
+++ b/app/finders/snippets_finder.rb
@@ -69,6 +69,8 @@ class SnippetsFinder < UnionFinder
base.with_optional_visibility(visibility_from_scope).fresh
end
+ private
+
# Produces a query that retrieves snippets from multiple projects.
#
# The resulting query will, depending on the user's permissions, include the
diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb
index 06d26309b5b..a63f45f231c 100644
--- a/app/graphql/gitlab_schema.rb
+++ b/app/graphql/gitlab_schema.rb
@@ -1,13 +1,72 @@
# frozen_string_literal: true
class GitlabSchema < GraphQL::Schema
+ # Currently an IntrospectionQuery has a complexity of 179.
+ # These values will evolve over time.
+ DEFAULT_MAX_COMPLEXITY = 200
+ AUTHENTICATED_COMPLEXITY = 250
+ ADMIN_COMPLEXITY = 300
+
+ DEFAULT_MAX_DEPTH = 10
+ AUTHENTICATED_MAX_DEPTH = 15
+
use BatchLoader::GraphQL
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Present
use Gitlab::Graphql::Connections
+ use Gitlab::Graphql::GenericTracing
+
+ query_analyzer Gitlab::Graphql::QueryAnalyzers::LogQueryComplexity.analyzer
query(Types::QueryType)
default_max_page_size 100
+
+ max_complexity DEFAULT_MAX_COMPLEXITY
+ max_depth DEFAULT_MAX_DEPTH
+
mutation(Types::MutationType)
+
+ class << self
+ def multiplex(queries, **kwargs)
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+
+ queries.each do |query|
+ query[:max_depth] = max_query_depth(kwargs[:context])
+ end
+
+ super(queries, **kwargs)
+ end
+
+ def execute(query_str = nil, **kwargs)
+ kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
+ kwargs[:max_depth] ||= max_query_depth(kwargs[:context])
+
+ super(query_str, **kwargs)
+ end
+
+ private
+
+ def max_query_complexity(ctx)
+ current_user = ctx&.fetch(:current_user, nil)
+
+ if current_user&.admin
+ ADMIN_COMPLEXITY
+ elsif current_user
+ AUTHENTICATED_COMPLEXITY
+ else
+ DEFAULT_MAX_COMPLEXITY
+ end
+ end
+
+ def max_query_depth(ctx)
+ current_user = ctx&.fetch(:current_user, nil)
+
+ if current_user
+ AUTHENTICATED_MAX_DEPTH
+ else
+ DEFAULT_MAX_DEPTH
+ end
+ end
+ end
end
diff --git a/app/graphql/mutations/merge_requests/base.rb b/app/graphql/mutations/merge_requests/base.rb
index 54f01c99d78..7d0cb777ad1 100644
--- a/app/graphql/mutations/merge_requests/base.rb
+++ b/app/graphql/mutations/merge_requests/base.rb
@@ -25,7 +25,8 @@ module Mutations
def find_object(project_path:, iid:)
project = resolve_project(full_path: project_path)
- resolver = Resolvers::MergeRequestResolver.new(object: project, context: context)
+ resolver = Resolvers::MergeRequestsResolver
+ .single.new(object: project, context: context)
resolver.resolve(iid: iid)
end
diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb
index 459933af9d3..31850c2cadb 100644
--- a/app/graphql/resolvers/base_resolver.rb
+++ b/app/graphql/resolvers/base_resolver.rb
@@ -2,5 +2,31 @@
module Resolvers
class BaseResolver < GraphQL::Schema::Resolver
+ def self.single
+ @single ||= Class.new(self) do
+ def resolve(**args)
+ super.first
+ end
+ end
+ end
+
+ def self.resolver_complexity(args)
+ complexity = 1
+ complexity += 1 if args[:sort]
+ complexity += 5 if args[:search]
+
+ complexity
+ end
+
+ def self.complexity_multiplier(args)
+ # When fetching many items, additional complexity is added to the field
+ # depending on how many items is fetched. For each item we add 1% of the
+ # original complexity - this means that loading 100 items (our default
+ # maxp_age_size limit) doubles the original complexity.
+ #
+ # Complexity is not increased when searching by specific ID(s), because
+ # complexity difference is minimal in this case.
+ [args[:iid], args[:iids]].any? ? 0 : 0.01
+ end
end
end
diff --git a/app/graphql/resolvers/concerns/resolves_pipelines.rb b/app/graphql/resolvers/concerns/resolves_pipelines.rb
index 8fd26d85994..a166211fc18 100644
--- a/app/graphql/resolvers/concerns/resolves_pipelines.rb
+++ b/app/graphql/resolvers/concerns/resolves_pipelines.rb
@@ -19,6 +19,16 @@ module ResolvesPipelines
description: "Filter pipelines by the sha of the commit they are run for"
end
+ class_methods do
+ def resolver_complexity(args)
+ complexity = super
+ complexity += 2 if args[:sha]
+ complexity += 2 if args[:ref]
+
+ complexity
+ end
+ end
+
def resolve_pipelines(project, params = {})
PipelinesFinder.new(project, context[:current_user], params).execute
end
diff --git a/app/graphql/resolvers/full_path_resolver.rb b/app/graphql/resolvers/full_path_resolver.rb
index 0f1a64b6c58..972f318c806 100644
--- a/app/graphql/resolvers/full_path_resolver.rb
+++ b/app/graphql/resolvers/full_path_resolver.rb
@@ -7,14 +7,14 @@ module Resolvers
prepended do
argument :full_path, GraphQL::ID_TYPE,
required: true,
- description: 'The full path of the project or namespace, e.g., "gitlab-org/gitlab-ce"'
+ description: 'The full path of the project, group or namespace, e.g., "gitlab-org/gitlab-ce"'
end
def model_by_full_path(model, full_path)
BatchLoader.for(full_path).batch(key: model) do |full_paths, loader, args|
# `with_route` avoids an N+1 calculating full_path
- args[:key].where_full_path_in(full_paths).with_route.each do |project|
- loader.call(project.full_path, project)
+ args[:key].where_full_path_in(full_paths).with_route.each do |model_instance|
+ loader.call(model_instance.full_path, model_instance)
end
end
end
diff --git a/app/graphql/resolvers/group_resolver.rb b/app/graphql/resolvers/group_resolver.rb
new file mode 100644
index 00000000000..4260e18829e
--- /dev/null
+++ b/app/graphql/resolvers/group_resolver.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class GroupResolver < BaseResolver
+ prepend FullPathResolver
+
+ type Types::GroupType, null: true
+
+ def resolve(full_path:)
+ model_by_full_path(Group, full_path)
+ end
+ end
+end
diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb
index 4ab3c13787a..f7e49166ca0 100644
--- a/app/graphql/resolvers/issues_resolver.rb
+++ b/app/graphql/resolvers/issues_resolver.rb
@@ -2,8 +2,37 @@
module Resolvers
class IssuesResolver < BaseResolver
- extend ActiveSupport::Concern
+ argument :iid, GraphQL::ID_TYPE,
+ required: false,
+ description: 'The IID of the issue, e.g., "1"'
+ argument :iids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The list of IIDs of issues, e.g., [1, 2]'
+ argument :state, Types::IssuableStateEnum,
+ required: false,
+ description: "Current state of Issue"
+ argument :label_name, GraphQL::STRING_TYPE.to_list_type,
+ required: false,
+ description: "Labels applied to the Issue"
+ argument :created_before, Types::TimeType,
+ required: false,
+ description: "Issues created before this date"
+ argument :created_after, Types::TimeType,
+ required: false,
+ description: "Issues created after this date"
+ argument :updated_before, Types::TimeType,
+ required: false,
+ description: "Issues updated before this date"
+ argument :updated_after, Types::TimeType,
+ required: false,
+ description: "Issues updated after this date"
+ argument :closed_before, Types::TimeType,
+ required: false,
+ description: "Issues closed before this date"
+ argument :closed_after, Types::TimeType,
+ required: false,
+ description: "Issues closed after this date"
argument :search, GraphQL::STRING_TYPE,
required: false
argument :sort, Types::Sort,
@@ -15,11 +44,25 @@ module Resolvers
alias_method :project, :object
def resolve(**args)
+ # The project could have been loaded in batch by `BatchLoader`.
+ # At this point we need the `id` of the project to query for issues, so
+ # make sure it's loaded and not `nil` before continuing.
+ project.sync if project.respond_to?(:sync)
+ return Issue.none if project.nil?
+
# Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
args[:project_id] = project.id
+ args[:iids] ||= [args[:iid]].compact
IssuesFinder.new(context[:current_user], args).execute
end
+
+ def self.resolver_complexity(args)
+ complexity = super
+ complexity += 2 if args[:labelName]
+
+ complexity
+ end
end
end
diff --git a/app/graphql/resolvers/merge_request_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb
index d047ce9e3a1..90795c797ac 100644
--- a/app/graphql/resolvers/merge_request_resolver.rb
+++ b/app/graphql/resolvers/merge_requests_resolver.rb
@@ -1,19 +1,30 @@
# frozen_string_literal: true
module Resolvers
- class MergeRequestResolver < BaseResolver
+ class MergeRequestsResolver < BaseResolver
argument :iid, GraphQL::ID_TYPE,
- required: true,
- description: 'The IID of the merge request, e.g., "1"'
+ required: false,
+ description: 'The IID of the merge request, e.g., "1"'
+
+ argument :iids, [GraphQL::ID_TYPE],
+ required: false,
+ description: 'The list of IIDs of issues, e.g., [1, 2]'
type Types::MergeRequestType, null: true
alias_method :project, :object
- # rubocop: disable CodeReuse/ActiveRecord
- def resolve(iid:)
+ def resolve(**args)
return unless project.present?
+ args[:iids] ||= [args[:iid]].compact
+
+ args[:iids].map { |iid| batch_load(iid) }
+ .select(&:itself) # .compact doesn't work on BatchLoader
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def batch_load(iid)
BatchLoader.for(iid.to_s).batch(key: project) do |iids, loader, args|
args[:key].merge_requests.where(iid: iids).each do |mr|
loader.call(mr.iid.to_s, mr)
diff --git a/app/graphql/resolvers/metadata_resolver.rb b/app/graphql/resolvers/metadata_resolver.rb
new file mode 100644
index 00000000000..3a79e6434fb
--- /dev/null
+++ b/app/graphql/resolvers/metadata_resolver.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class MetadataResolver < BaseResolver
+ type Types::MetadataType, null: false
+
+ def resolve(**args)
+ { version: Gitlab::VERSION, revision: Gitlab.revision }
+ end
+ end
+end
diff --git a/app/graphql/resolvers/project_resolver.rb b/app/graphql/resolvers/project_resolver.rb
index ac7c9b0ce2e..2132447da5e 100644
--- a/app/graphql/resolvers/project_resolver.rb
+++ b/app/graphql/resolvers/project_resolver.rb
@@ -9,5 +9,9 @@ module Resolvers
def resolve(full_path:)
model_by_full_path(Project, full_path)
end
+
+ def self.complexity_multiplier(args)
+ 0
+ end
end
end
diff --git a/app/graphql/resolvers/tree_resolver.rb b/app/graphql/resolvers/tree_resolver.rb
new file mode 100644
index 00000000000..5aad1c71b40
--- /dev/null
+++ b/app/graphql/resolvers/tree_resolver.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Resolvers
+ class TreeResolver < BaseResolver
+ argument :path, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: '',
+ description: 'The path to get the tree for. Default value is the root of the repository'
+ argument :ref, GraphQL::STRING_TYPE,
+ required: false,
+ default_value: :head,
+ description: 'The commit ref to get the tree for. Default value is HEAD'
+ argument :recursive, GraphQL::BOOLEAN_TYPE,
+ required: false,
+ default_value: false,
+ description: 'Used to get a recursive tree. Default is false'
+
+ alias_method :repository, :object
+
+ def resolve(**args)
+ return unless repository.exists?
+
+ repository.tree(args[:ref], args[:path], recursive: args[:recursive])
+ end
+ end
+end
diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb
index 2b2ea64c00b..15331129134 100644
--- a/app/graphql/types/base_field.rb
+++ b/app/graphql/types/base_field.rb
@@ -3,5 +3,44 @@
module Types
class BaseField < GraphQL::Schema::Field
prepend Gitlab::Graphql::Authorize
+
+ DEFAULT_COMPLEXITY = 1
+
+ def initialize(*args, **kwargs, &block)
+ kwargs[:complexity] ||= field_complexity(kwargs[:resolver_class])
+
+ super(*args, **kwargs, &block)
+ end
+
+ private
+
+ def field_complexity(resolver_class)
+ if resolver_class
+ field_resolver_complexity
+ else
+ DEFAULT_COMPLEXITY
+ end
+ end
+
+ def field_resolver_complexity
+ # Complexity can be either integer or proc. If proc is used then it's
+ # called when computing a query complexity and context and query
+ # arguments are available for computing complexity. For resolvers we use
+ # proc because we set complexity depending on arguments and number of
+ # items which can be loaded.
+ proc do |ctx, args, child_complexity|
+ page_size = @max_page_size || ctx.schema.default_max_page_size
+ limit_value = [args[:first], args[:last], page_size].compact.min
+
+ # Resolvers may add extra complexity depending on used arguments
+ complexity = child_complexity + self.resolver&.try(:resolver_complexity, args).to_i
+
+ # Resolvers may add extra complexity depending on number of items being loaded.
+ multiplier = self.resolver&.try(:complexity_multiplier, args).to_f
+ complexity += complexity * limit_value * multiplier
+
+ complexity.to_i
+ end
+ end
end
end
diff --git a/app/graphql/types/ci/detailed_status_type.rb b/app/graphql/types/ci/detailed_status_type.rb
new file mode 100644
index 00000000000..2987354b556
--- /dev/null
+++ b/app/graphql/types/ci/detailed_status_type.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+module Types
+ module Ci
+ class DetailedStatusType < BaseObject
+ graphql_name 'DetailedStatus'
+
+ field :group, GraphQL::STRING_TYPE, null: false
+ field :icon, GraphQL::STRING_TYPE, null: false
+ field :favicon, GraphQL::STRING_TYPE, null: false
+ field :details_path, GraphQL::STRING_TYPE, null: false
+ field :has_details, GraphQL::BOOLEAN_TYPE, null: false, method: :has_details?
+ field :label, GraphQL::STRING_TYPE, null: false
+ field :text, GraphQL::STRING_TYPE, null: false
+ field :tooltip, GraphQL::STRING_TYPE, null: false, method: :status_tooltip
+ end
+ end
+end
diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb
index 2bbffad4563..de7d6570a3e 100644
--- a/app/graphql/types/ci/pipeline_type.rb
+++ b/app/graphql/types/ci/pipeline_type.rb
@@ -3,16 +3,22 @@
module Types
module Ci
class PipelineType < BaseObject
- expose_permissions Types::PermissionTypes::Ci::Pipeline
-
graphql_name 'Pipeline'
+ authorize :read_pipeline
+
+ expose_permissions Types::PermissionTypes::Ci::Pipeline
+
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :sha, GraphQL::STRING_TYPE, null: false
field :before_sha, GraphQL::STRING_TYPE, null: true
field :status, PipelineStatusEnum, null: false
+ field :detailed_status,
+ Types::Ci::DetailedStatusType,
+ null: false,
+ resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
field :duration,
GraphQL::INT_TYPE,
null: true,
diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb
new file mode 100644
index 00000000000..530aecc2bf9
--- /dev/null
+++ b/app/graphql/types/group_type.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Types
+ class GroupType < NamespaceType
+ graphql_name 'Group'
+
+ authorize :read_group
+
+ expose_permissions Types::PermissionTypes::Group
+
+ field :web_url, GraphQL::STRING_TYPE, null: false
+
+ field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (group, args, ctx) do
+ group.avatar_url(only_path: false)
+ end
+
+ if ::Group.supports_nested_objects?
+ field :parent, GroupType,
+ null: true,
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find }
+ end
+ end
+end
diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb
new file mode 100644
index 00000000000..f2f6d6c6cab
--- /dev/null
+++ b/app/graphql/types/issuable_state_enum.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Types
+ class IssuableStateEnum < BaseEnum
+ graphql_name 'IssuableState'
+ description 'State of a GitLab issue or merge request'
+
+ value 'opened'
+ value 'closed'
+ value 'locked'
+ end
+end
diff --git a/app/graphql/types/issue_state_enum.rb b/app/graphql/types/issue_state_enum.rb
new file mode 100644
index 00000000000..6521407fc9d
--- /dev/null
+++ b/app/graphql/types/issue_state_enum.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+module Types
+ class IssueStateEnum < IssuableStateEnum
+ graphql_name 'IssueState'
+ description 'State of a GitLab issue'
+ end
+end
diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb
index a8f2f7914a8..b21a226d07f 100644
--- a/app/graphql/types/issue_type.rb
+++ b/app/graphql/types/issue_type.rb
@@ -2,31 +2,31 @@
module Types
class IssueType < BaseObject
- expose_permissions Types::PermissionTypes::Issue
-
graphql_name 'Issue'
+ authorize :read_issue
+
+ expose_permissions Types::PermissionTypes::Issue
+
present_using IssuePresenter
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
- field :state, GraphQL::STRING_TYPE, null: false
+ field :state, IssueStateEnum, null: false
field :author, Types::UserType,
null: false,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do
- authorize :read_user
- end
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
- field :assignees, Types::UserType.connection_type, null: true
+ # Remove complexity when BatchLoader is used
+ field :assignees, Types::UserType.connection_type, null: true, complexity: 5
- field :labels, Types::LabelType.connection_type, null: true
+ # Remove complexity when BatchLoader is used
+ field :labels, Types::LabelType.connection_type, null: true, complexity: 5
field :milestone, Types::MilestoneType,
null: true,
- resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do
- authorize :read_milestone
- end
+ resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
field :due_date, Types::TimeType, null: true
field :confidential, GraphQL::BOOLEAN_TYPE, null: false
diff --git a/app/graphql/types/merge_request_state_enum.rb b/app/graphql/types/merge_request_state_enum.rb
new file mode 100644
index 00000000000..92f52726ab3
--- /dev/null
+++ b/app/graphql/types/merge_request_state_enum.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class MergeRequestStateEnum < IssuableStateEnum
+ graphql_name 'MergeRequestState'
+ description 'State of a GitLab merge request'
+
+ value 'merged'
+ end
+end
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index fb740b6fb1c..120ffe0dfde 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -2,17 +2,19 @@
module Types
class MergeRequestType < BaseObject
+ graphql_name 'MergeRequest'
+
+ authorize :read_merge_request
+
expose_permissions Types::PermissionTypes::MergeRequest
present_using MergeRequestPresenter
- graphql_name 'MergeRequest'
-
field :id, GraphQL::ID_TYPE, null: false
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
- field :state, GraphQL::STRING_TYPE, null: true
+ field :state, MergeRequestStateEnum, null: false
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
field :source_project, Types::ProjectType, null: true
@@ -38,8 +40,8 @@ module Types
field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false
field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true
field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false
- field :diff_head_sha, GraphQL::STRING_TYPE, null: true
- field :merge_commit_message, GraphQL::STRING_TYPE, null: true
+ field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage"
+ field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true
field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false
field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false
field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true
@@ -48,9 +50,7 @@ module Types
field :downvotes, GraphQL::INT_TYPE, null: false
field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false
- field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline do
- authorize :read_pipeline
- end
+ field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline
field :pipelines, Types::Ci::PipelineType.connection_type,
resolver: Resolvers::MergeRequestPipelinesResolver
end
diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb
new file mode 100644
index 00000000000..2d8bad0614b
--- /dev/null
+++ b/app/graphql/types/metadata_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module Types
+ class MetadataType < ::Types::BaseObject
+ graphql_name 'Metadata'
+
+ field :version, GraphQL::STRING_TYPE, null: false
+ field :revision, GraphQL::STRING_TYPE, null: false
+ end
+end
diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb
index af31b572c9a..2772fbec86f 100644
--- a/app/graphql/types/milestone_type.rb
+++ b/app/graphql/types/milestone_type.rb
@@ -4,6 +4,8 @@ module Types
class MilestoneType < BaseObject
graphql_name 'Milestone'
+ authorize :read_milestone
+
field :description, GraphQL::STRING_TYPE, null: true
field :title, GraphQL::STRING_TYPE, null: false
field :state, GraphQL::STRING_TYPE, null: false
diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb
new file mode 100644
index 00000000000..36d8ee8c878
--- /dev/null
+++ b/app/graphql/types/namespace_type.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Types
+ class NamespaceType < BaseObject
+ graphql_name 'Namespace'
+
+ field :id, GraphQL::ID_TYPE, null: false
+
+ field :name, GraphQL::STRING_TYPE, null: false
+ field :path, GraphQL::STRING_TYPE, null: false
+ field :full_name, GraphQL::STRING_TYPE, null: false
+ field :full_path, GraphQL::ID_TYPE, null: false
+
+ field :description, GraphQL::STRING_TYPE, null: true
+ field :visibility, GraphQL::STRING_TYPE, null: true
+ field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?
+ field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
+ end
+end
diff --git a/app/graphql/types/permission_types/group.rb b/app/graphql/types/permission_types/group.rb
new file mode 100644
index 00000000000..29833993ce6
--- /dev/null
+++ b/app/graphql/types/permission_types/group.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Types
+ module PermissionTypes
+ class Group < BasePermissionType
+ graphql_name 'GroupPermissions'
+
+ abilities :read_group
+ end
+ end
+end
diff --git a/app/graphql/types/permission_types/project.rb b/app/graphql/types/permission_types/project.rb
index ab37c282fe5..e9a4ea9157b 100644
--- a/app/graphql/types/permission_types/project.rb
+++ b/app/graphql/types/permission_types/project.rb
@@ -8,7 +8,7 @@ module Types
abilities :change_namespace, :change_visibility_level, :rename_project,
:remove_project, :archive_project, :remove_fork_project,
:remove_pages, :read_project, :create_merge_request_in,
- :read_wiki, :read_project_member, :create_issue, :upload_file,
+ :read_wiki, :read_project_member, :create_issue, :upload_file,
:read_cycle_analytics, :download_code, :download_wiki_code,
:fork_project, :create_project_snippet, :read_commit_status,
:request_access, :create_pipeline, :create_pipeline_schedule,
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 050706f97be..06a1aab09f6 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -2,10 +2,12 @@
module Types
class ProjectType < BaseObject
- expose_permissions Types::PermissionTypes::Project
-
graphql_name 'Project'
+ authorize :read_project
+
+ expose_permissions Types::PermissionTypes::Project
+
field :id, GraphQL::ID_TYPE, null: false
field :full_path, GraphQL::ID_TYPE, null: false
@@ -16,7 +18,6 @@ module Types
field :description, GraphQL::STRING_TYPE, null: true
- field :default_branch, GraphQL::STRING_TYPE, null: true
field :tag_list, GraphQL::STRING_TYPE, null: true
field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true
@@ -59,28 +60,40 @@ module Types
end
field :import_status, GraphQL::STRING_TYPE, null: true
- field :ci_config_path, GraphQL::STRING_TYPE, null: true
field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true
field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true
field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true
field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true
+ field :namespace, Types::NamespaceType, null: false
+ field :group, Types::GroupType, null: true
+
+ field :repository, Types::RepositoryType, null: false
+
+ field :merge_requests,
+ Types::MergeRequestType.connection_type,
+ null: true,
+ resolver: Resolvers::MergeRequestsResolver
+
field :merge_request,
Types::MergeRequestType,
null: true,
- resolver: Resolvers::MergeRequestResolver do
- authorize :read_merge_request
- end
+ resolver: Resolvers::MergeRequestsResolver.single
field :issues,
Types::IssueType.connection_type,
null: true,
resolver: Resolvers::IssuesResolver
+ field :issue,
+ Types::IssueType,
+ null: true,
+ resolver: Resolvers::IssuesResolver.single
+
field :pipelines,
Types::Ci::PipelineType.connection_type,
- null: false,
+ null: true,
resolver: Resolvers::ProjectPipelinesResolver
end
end
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index 7c41716b82a..40d7de1a49a 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -1,14 +1,25 @@
# frozen_string_literal: true
module Types
- class QueryType < BaseObject
+ class QueryType < ::Types::BaseObject
graphql_name 'Query'
field :project, Types::ProjectType,
null: true,
resolver: Resolvers::ProjectResolver,
- description: "Find a project" do
- authorize :read_project
+ description: "Find a project"
+
+ field :group, Types::GroupType,
+ null: true,
+ resolver: Resolvers::GroupResolver,
+ description: "Find a group"
+
+ field :metadata, Types::MetadataType,
+ null: true,
+ resolver: Resolvers::MetadataResolver,
+ description: 'Metadata about GitLab' do |*args|
+
+ authorize :read_instance_metadata
end
field :echo, GraphQL::STRING_TYPE, null: false, function: Functions::Echo.new
diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb
new file mode 100644
index 00000000000..5987467e1ea
--- /dev/null
+++ b/app/graphql/types/repository_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ class RepositoryType < BaseObject
+ graphql_name 'Repository'
+
+ authorize :download_code
+
+ field :root_ref, GraphQL::STRING_TYPE, null: true
+ field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?
+ field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists?
+ field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver
+ end
+end
diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb
new file mode 100644
index 00000000000..230624201b0
--- /dev/null
+++ b/app/graphql/types/tree/blob_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class BlobType < BaseObject
+ implements Types::Tree::EntryType
+
+ graphql_name 'Blob'
+ end
+ end
+end
diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb
new file mode 100644
index 00000000000..d8e8642ddb8
--- /dev/null
+++ b/app/graphql/types/tree/entry_type.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ module EntryType
+ include Types::BaseInterface
+
+ field :id, GraphQL::ID_TYPE, null: false
+ field :name, GraphQL::STRING_TYPE, null: false
+ field :type, Tree::TypeEnum, null: false
+ field :path, GraphQL::STRING_TYPE, null: false
+ field :flat_path, GraphQL::STRING_TYPE, null: false
+ end
+ end
+end
diff --git a/app/graphql/types/tree/submodule_type.rb b/app/graphql/types/tree/submodule_type.rb
new file mode 100644
index 00000000000..cea76dbfd2a
--- /dev/null
+++ b/app/graphql/types/tree/submodule_type.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class SubmoduleType < BaseObject
+ implements Types::Tree::EntryType
+
+ graphql_name 'Submodule'
+ end
+ end
+end
diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb
new file mode 100644
index 00000000000..d5cfb898aea
--- /dev/null
+++ b/app/graphql/types/tree/tree_entry_type.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class TreeEntryType < BaseObject
+ implements Types::Tree::EntryType
+
+ graphql_name 'TreeEntry'
+ description 'Represents a directory'
+ end
+ end
+end
diff --git a/app/graphql/types/tree/tree_type.rb b/app/graphql/types/tree/tree_type.rb
new file mode 100644
index 00000000000..1eb6c43972e
--- /dev/null
+++ b/app/graphql/types/tree/tree_type.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+module Types
+ module Tree
+ class TreeType < BaseObject
+ graphql_name 'Tree'
+
+ field :trees, Types::Tree::TreeEntryType.connection_type, null: false
+ field :submodules, Types::Tree::SubmoduleType.connection_type, null: false
+ field :blobs, Types::Tree::BlobType.connection_type, null: false
+ end
+ end
+end
diff --git a/app/graphql/types/tree/type_enum.rb b/app/graphql/types/tree/type_enum.rb
new file mode 100644
index 00000000000..6560d91e9e5
--- /dev/null
+++ b/app/graphql/types/tree/type_enum.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Types
+ module Tree
+ class TypeEnum < BaseEnum
+ graphql_name 'EntryType'
+ description 'Type of a tree entry'
+
+ value 'tree', value: :tree
+ value 'blob', value: :blob
+ value 'commit', value: :commit
+ end
+ end
+end
diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb
index a13e65207df..6b53554314b 100644
--- a/app/graphql/types/user_type.rb
+++ b/app/graphql/types/user_type.rb
@@ -4,6 +4,8 @@ module Types
class UserType < BaseObject
graphql_name 'User'
+ authorize :read_user
+
present_using UserPresenter
field :name, GraphQL::STRING_TYPE, null: false
diff --git a/app/helpers/appearances_helper.rb b/app/helpers/appearances_helper.rb
index 3f69af50f25..c0db9910143 100644
--- a/app/helpers/appearances_helper.rb
+++ b/app/helpers/appearances_helper.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
module AppearancesHelper
+ include MarkupHelper
+
def brand_title
current_appearance&.title.presence || default_brand_title
end
@@ -11,7 +13,7 @@ module AppearancesHelper
end
def brand_image
- image_tag(current_appearance.logo) if current_appearance&.logo?
+ image_tag(current_appearance.logo_path) if current_appearance&.logo?
end
def brand_text
@@ -28,7 +30,7 @@ module AppearancesHelper
def brand_header_logo
if current_appearance&.header_logo?
- image_tag current_appearance.header_logo
+ image_tag current_appearance.header_logo_path, class: 'brand-header-logo'
else
render 'shared/logo.svg'
end
@@ -40,4 +42,36 @@ module AppearancesHelper
render 'shared/logo_type.svg'
end
end
+
+ def header_message
+ return unless current_appearance&.show_header?
+
+ class_names = []
+ class_names << 'with-performance-bar' if performance_bar_enabled?
+
+ render_message(:header_message, class_names: class_names)
+ end
+
+ def footer_message
+ return unless current_appearance&.show_footer?
+
+ render_message(:footer_message)
+ end
+
+ private
+
+ def render_message(field_sym, class_names: [], style: message_style)
+ class_names << field_sym.to_s.dasherize
+
+ content_tag :div, class: class_names, style: style do
+ markdown_field(current_appearance, field_sym)
+ end
+ end
+
+ def message_style
+ style = []
+ style << "background-color: #{current_appearance.message_background_color};"
+ style << "color: #{current_appearance.message_font_color}"
+ style.join
+ end
end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 74042f0bae8..ffa5719fefb 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -171,7 +171,6 @@ module ApplicationHelper
def page_filter_path(options = {})
without = options.delete(:without)
- add_label = options.delete(:label)
options = request.query_parameters.merge(options)
@@ -181,11 +180,7 @@ module ApplicationHelper
end
end
- params = options.compact
-
- params.delete(:label_name) unless add_label
-
- "#{request.path}?#{params.to_param}"
+ "#{request.path}?#{options.compact.to_param}"
end
def outdated_browser?
@@ -219,12 +214,19 @@ module ApplicationHelper
class_names = []
class_names << 'issue-boards-page' if current_controller?(:boards)
class_names << 'with-performance-bar' if performance_bar_enabled?
-
+ class_names << system_message_class
class_names
end
- # EE feature: System header and footer, unavailable in CE
def system_message_class
+ class_names = []
+
+ return class_names unless appearance
+
+ class_names << 'with-system-header' if appearance.show_header?
+ class_names << 'with-system-footer' if appearance.show_footer?
+
+ class_names
end
# Returns active css class when condition returns true
@@ -273,6 +275,17 @@ module ApplicationHelper
_('You are on a read-only GitLab instance.')
end
+ def client_class_list
+ "gl-browser-#{browser.id} gl-platform-#{browser.platform.id}"
+ end
+
+ def client_js_flags
+ {
+ "is#{browser.id.to_s.titlecase}": true,
+ "is#{browser.platform.id.to_s.titlecase}": true
+ }
+ end
+
def autocomplete_data_sources(object, noteable_type)
return {} unless object && noteable_type
@@ -286,4 +299,10 @@ module ApplicationHelper
snippets: snippets_project_autocomplete_sources_path(object)
}
end
+
+ private
+
+ def appearance
+ ::Appearance.current
+ end
end
diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb
index 086bb38ce9a..971d1052824 100644
--- a/app/helpers/application_settings_helper.rb
+++ b/app/helpers/application_settings_helper.rb
@@ -20,12 +20,24 @@ module ApplicationSettingsHelper
def enabled_protocol
case Gitlab::CurrentSettings.enabled_git_access_protocol
when 'http'
- gitlab_config.protocol
+ Gitlab.config.gitlab.protocol
when 'ssh'
'ssh'
end
end
+ def all_protocols_enabled?
+ Gitlab::CurrentSettings.enabled_git_access_protocol.blank?
+ end
+
+ def ssh_enabled?
+ all_protocols_enabled? || enabled_protocol == 'ssh'
+ end
+
+ def http_enabled?
+ all_protocols_enabled? || Gitlab::CurrentSettings.enabled_git_access_protocol == 'http'
+ end
+
def enabled_project_button(project, protocol)
case protocol
when 'ssh'
@@ -107,6 +119,39 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
+ def external_authorization_description
+ _("If enabled, access to projects will be validated on an external service"\
+ " using their classification label.")
+ end
+
+ def external_authorization_timeout_help_text
+ _("Time in seconds GitLab will wait for a response from the external "\
+ "service. When the service does not respond in time, access will be "\
+ "denied.")
+ end
+
+ def external_authorization_url_help_text
+ _("When leaving the URL blank, classification labels can still be "\
+ "specified without disabling cross project features or performing "\
+ "external authorization checks.")
+ end
+
+ def external_authorization_client_certificate_help_text
+ _("The X509 Certificate to use when mutual TLS is required to communicate "\
+ "with the external authorization service. If left blank, the server "\
+ "certificate is still validated when accessing over HTTPS.")
+ end
+
+ def external_authorization_client_key_help_text
+ _("The private key to use when a client certificate is provided. This value "\
+ "is encrypted at rest.")
+ end
+
+ def external_authorization_client_pass_help_text
+ _("The passphrase required to decrypt the private key. This is optional "\
+ "and the value is encrypted at rest.")
+ end
+
def visible_attributes
[
:admin_notification_email,
@@ -125,6 +170,7 @@ module ApplicationSettingsHelper
:default_artifacts_expire_in,
:default_branch_protection,
:default_group_visibility,
+ :default_project_creation,
:default_project_visibility,
:default_projects_limit,
:default_snippet_visibility,
@@ -138,6 +184,7 @@ module ApplicationSettingsHelper
:email_author_in_body,
:enabled_git_access_protocol,
:enforce_terms,
+ :first_day_of_week,
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
@@ -218,11 +265,29 @@ module ApplicationSettingsHelper
:version_check_enabled,
:web_ide_clientside_preview_enabled,
:diff_max_patch_bytes,
- :commit_email_hostname
+ :commit_email_hostname,
+ :protected_ci_variables,
+ :local_markdown_version
+ ]
+ end
+
+ def external_authorization_service_attributes
+ [
+ :external_auth_client_cert,
+ :external_auth_client_key,
+ :external_auth_client_key_pass,
+ :external_authorization_service_default_label,
+ :external_authorization_service_enabled,
+ :external_authorization_service_timeout,
+ :external_authorization_service_url
]
end
def expanded_by_default?
Rails.env.test?
end
+
+ def instance_clusters_enabled?
+ can?(current_user, :read_cluster, Clusters::Instance.new)
+ end
end
diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb
index 654fb9d9987..076976175a9 100644
--- a/app/helpers/auth_helper.rb
+++ b/app/helpers/auth_helper.rb
@@ -2,7 +2,7 @@
module AuthHelper
PROVIDERS_WITH_ICONS = %w(twitter github gitlab bitbucket google_oauth2 facebook azure_oauth2 authentiq).freeze
- LDAP_PROVIDER = /\Aldap/
+ LDAP_PROVIDER = /\Aldap/.freeze
def ldap_enabled?
Gitlab::Auth::LDAP::Config.enabled?
@@ -16,6 +16,13 @@ module AuthHelper
PROVIDERS_WITH_ICONS.include?(name.to_s)
end
+ def qa_class_for_provider(provider)
+ {
+ saml: 'qa-saml-login-button',
+ github: 'qa-github-login-button'
+ }[provider.to_sym]
+ end
+
def auth_providers
Gitlab::Auth::OAuth::Provider.providers
end
@@ -31,7 +38,7 @@ module AuthHelper
def form_based_provider_with_highest_priority
@form_based_provider_with_highest_priority ||= begin
form_based_provider_priority.each do |provider_regexp|
- highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) }
+ highest_priority = form_based_providers.find { |provider| provider.match?(provider_regexp) }
break highest_priority unless highest_priority.nil?
end
end
@@ -93,8 +100,12 @@ module AuthHelper
end
# rubocop: enable CodeReuse/ActiveRecord
- def unlink_allowed?(provider)
- %w(saml cas3).exclude?(provider.to_s)
+ def unlink_provider_allowed?(provider)
+ IdentityProviderPolicy.new(current_user, provider).can?(:unlink)
+ end
+
+ def link_provider_allowed?(provider)
+ IdentityProviderPolicy.new(current_user, provider).can?(:link)
end
extend self
diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb
index 516c8a353ea..0f0d5350df6 100644
--- a/app/helpers/auto_devops_helper.rb
+++ b/app/helpers/auto_devops_helper.rb
@@ -10,40 +10,16 @@ module AutoDevopsHelper
!project.ci_service
end
- def auto_devops_warning_message(project)
- if missing_auto_devops_service?(project)
- params = {
- kubernetes: link_to('Kubernetes cluster', project_clusters_path(project))
- }
+ def badge_for_auto_devops_scope(auto_devops_receiver)
+ return unless auto_devops_receiver.auto_devops_enabled?
- if missing_auto_devops_domain?(project)
- _('Auto Review Apps and Auto Deploy need a domain name and a %{kubernetes} to work correctly.') % params
- else
- _('Auto Review Apps and Auto Deploy need a %{kubernetes} to work correctly.') % params
- end
- elsif missing_auto_devops_domain?(project)
- _('Auto Review Apps and Auto Deploy need a domain name to work correctly.')
+ case auto_devops_receiver.first_auto_devops_config[:scope]
+ when :project
+ nil
+ when :group
+ s_('CICD|group enabled')
+ when :instance
+ s_('CICD|instance enabled')
end
end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def cluster_ingress_ip(project)
- project
- .cluster_ingresses
- .where("external_ip is not null")
- .limit(1)
- .pluck(:external_ip)
- .first
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def missing_auto_devops_domain?(project)
- !(project.auto_devops || project.build_auto_devops)&.has_domain?
- end
-
- def missing_auto_devops_service?(project)
- !project.deployment_platform&.active?
- end
end
diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb
index bd42f00944f..7e631053b54 100644
--- a/app/helpers/blob_helper.rb
+++ b/app/helpers/blob_helper.rb
@@ -19,10 +19,14 @@ module BlobHelper
def ide_edit_path(project = @project, ref = @ref, path = @path, options = {})
segments = [ide_path, 'project', project.full_path, 'edit', ref]
- segments.concat(['-', path]) if path.present?
+ segments.concat(['-', encode_ide_path(path)]) if path.present?
File.join(segments)
end
+ def encode_ide_path(path)
+ url_encode(path).gsub('%2F', '/')
+ end
+
def edit_blob_button(project = @project, ref = @ref, path = @path, options = {})
return unless blob = readable_blob(options, path, project, ref)
@@ -31,12 +35,13 @@ module BlobHelper
edit_button_tag(blob,
common_classes,
_('Edit'),
- edit_blob_path(project, ref, path, options),
+ Feature.enabled?(:web_ide_default) ? ide_edit_path(project, ref, path, options) : edit_blob_path(project, ref, path, options),
project,
ref)
end
def ide_edit_button(project = @project, ref = @ref, path = @path, options = {})
+ return if Feature.enabled?(:web_ide_default)
return unless blob = readable_blob(options, path, project, ref)
edit_button_tag(blob,
@@ -72,7 +77,7 @@ module BlobHelper
project,
ref,
path,
- label: "Replace",
+ label: _("Replace"),
action: "replace",
btn_class: "default",
modal_type: "upload"
@@ -84,7 +89,7 @@ module BlobHelper
project,
ref,
path,
- label: "Delete",
+ label: _("Delete"),
action: "delete",
btn_class: "remove",
modal_type: "remove"
@@ -96,14 +101,14 @@ module BlobHelper
end
def leave_edit_message
- "Leave edit mode?\nAll unsaved changes will be lost."
+ _("Leave edit mode? All unsaved changes will be lost.")
end
def editing_preview_title(filename)
if Gitlab::MarkupHelper.previewable?(filename)
- 'Preview'
+ _('Preview')
else
- 'Preview changes'
+ _('Preview changes')
end
end
@@ -140,36 +145,6 @@ module BlobHelper
Gitlab::Sanitizers::SVG.clean(data)
end
- # Remove once https://gitlab.com/gitlab-org/gitlab-ce/issues/36103 is closed
- # and :workhorse_set_content_type flag is removed
- # If we blindly set the 'real' content type when serving a Git blob we
- # are enabling XSS attacks. An attacker could upload e.g. a Javascript
- # file to a Git repository, trick the browser of a victim into
- # downloading the blob, and then the 'application/javascript' content
- # type would tell the browser to execute the attacker's Javascript. By
- # overriding the content type and setting it to 'text/plain' (in the
- # example of Javascript) we tell the browser of the victim not to
- # execute untrusted data.
- def safe_content_type(blob)
- if blob.extension == 'svg'
- blob.mime_type
- elsif blob.text?
- 'text/plain; charset=utf-8'
- elsif blob.image?
- blob.content_type
- else
- 'application/octet-stream'
- end
- end
-
- def content_disposition(blob, inline)
- # Remove the following line when https://gitlab.com/gitlab-org/gitlab-ce/issues/36103
- # is closed and :workhorse_set_content_type flag is removed
- return 'attachment' if blob.extension == 'svg'
-
- inline ? 'inline' : 'attachment'
- end
-
def ref_project
@ref_project ||= @target_project || @project
end
@@ -207,7 +182,8 @@ module BlobHelper
'relative-url-root' => Rails.application.config.relative_url_root,
'assets-prefix' => Gitlab::Application.config.assets.prefix,
'blob-filename' => @blob && @blob.path,
- 'project-id' => project.id
+ 'project-id' => project.id,
+ 'is-markdown' => @blob && @blob.path && Gitlab::MarkupHelper.gitlab_markdown?(@blob.path)
}
end
@@ -223,16 +199,16 @@ module BlobHelper
def open_raw_blob_button(blob)
return if blob.empty?
- return if blob.raw_binary? || blob.stored_externally?
+ return if blob.binary? || blob.stored_externally?
- title = 'Open raw'
+ title = _('Open raw')
link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
end
def download_blob_button(blob)
return if blob.empty?
- title = 'Download'
+ title = _('Download')
link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' }
end
diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb
index be1e7016a1e..1640f4fc93f 100644
--- a/app/helpers/boards_helper.rb
+++ b/app/helpers/boards_helper.rb
@@ -69,7 +69,7 @@ module BoardsHelper
end
def board_sidebar_user_data
- dropdown_options = issue_assignees_dropdown_options
+ dropdown_options = assignees_dropdown_options('issue')
{
toggle: 'dropdown',
diff --git a/app/helpers/broadcast_messages_helper.rb b/app/helpers/broadcast_messages_helper.rb
index 289cb44f1e8..495c29d3e24 100644
--- a/app/helpers/broadcast_messages_helper.rb
+++ b/app/helpers/broadcast_messages_helper.rb
@@ -4,7 +4,7 @@ module BroadcastMessagesHelper
def broadcast_message(message)
return unless message.present?
- content_tag :div, class: 'broadcast-message', style: broadcast_message_style(message) do
+ content_tag :div, dir: 'auto', class: 'broadcast-message', style: broadcast_message_style(message) do
icon('bullhorn') << ' ' << render_broadcast_message(message)
end
end
diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb
index 3c8caec3fe5..a5fe6bb8f07 100644
--- a/app/helpers/builds_helper.rb
+++ b/app/helpers/builds_helper.rb
@@ -4,12 +4,12 @@ module BuildsHelper
def build_summary(build, skip: false)
if build.has_trace?
if skip
- link_to "View job trace", pipeline_job_url(build.pipeline, build)
+ link_to _("View job trace"), pipeline_job_url(build.pipeline, build)
else
build.trace.html(last_lines: 10).html_safe
end
else
- "No job trace"
+ _("No job trace")
end
end
@@ -31,7 +31,7 @@ module BuildsHelper
def build_failed_issue_options
{
- title: "Job Failed ##{@build.id}",
+ title: _("Job Failed #%{build_id}") % { build_id: @build.id },
description: project_job_url(@project, @build)
}
end
diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb
index 494c754e7d5..03adbfa204f 100644
--- a/app/helpers/button_helper.rb
+++ b/app/helpers/button_helper.rb
@@ -21,7 +21,7 @@ module ButtonHelper
# See http://clipboardjs.com/#usage
def clipboard_button(data = {})
css_class = data[:class] || 'btn-clipboard btn-transparent'
- title = data[:title] || 'Copy to clipboard'
+ title = data[:title] || _('Copy to clipboard')
button_text = data[:button_text] || ''
hide_tooltip = data[:hide_tooltip] || false
hide_button_icon = data[:hide_button_icon] || false
diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb
index 923a06a0512..f2b5b82b013 100644
--- a/app/helpers/ci_status_helper.rb
+++ b/app/helpers/ci_status_helper.rb
@@ -16,7 +16,7 @@ module CiStatusHelper
label = case status
when 'success'
'passed'
- when 'success_with_warnings'
+ when 'success-with-warnings'
'passed with warnings'
when 'manual'
'waiting for manual action'
@@ -37,7 +37,7 @@ module CiStatusHelper
case status
when 'success'
s_('CiStatusText|passed')
- when 'success_with_warnings'
+ when 'success-with-warnings'
s_('CiStatusText|passed')
when 'manual'
s_('CiStatusText|blocked')
@@ -71,7 +71,7 @@ module CiStatusHelper
case status
when 'success'
'status_success'
- when 'success_with_warnings'
+ when 'success-with-warnings'
'status_warning'
when 'failed'
'status_failed'
@@ -100,17 +100,6 @@ module CiStatusHelper
"pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}"
end
- def render_project_pipeline_status(pipeline_status, tooltip_placement: 'left')
- project = pipeline_status.project
- path = pipelines_project_commit_path(project, pipeline_status.sha, ref: pipeline_status.ref)
-
- render_status_with_link(
- 'commit',
- pipeline_status.status,
- path,
- tooltip_placement: tooltip_placement)
- end
-
def render_commit_status(commit, ref: nil, tooltip_placement: 'left')
project = commit.project
path = pipelines_project_commit_path(project, commit, ref: ref)
@@ -123,14 +112,8 @@ module CiStatusHelper
icon_size: 24)
end
- def render_pipeline_status(pipeline, tooltip_placement: 'left')
- project = pipeline.project
- path = project_pipeline_path(project, pipeline)
- render_status_with_link('pipeline', pipeline.status, path, tooltip_placement: tooltip_placement)
- end
-
def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16)
- klass = "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}"
+ klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}"
title = "#{type.titleize}: #{ci_label_for_status(status)}"
data = { toggle: 'tooltip', placement: tooltip_placement, container: container }
diff --git a/app/helpers/ci_variables_helper.rb b/app/helpers/ci_variables_helper.rb
new file mode 100644
index 00000000000..5bfdeb9e33c
--- /dev/null
+++ b/app/helpers/ci_variables_helper.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module CiVariablesHelper
+ def ci_variable_protected_by_default?
+ Gitlab::CurrentSettings.current_application_settings.protected_ci_variables
+ end
+
+ def ci_variable_protected?(variable, only_key_value)
+ if variable && !only_key_value
+ variable.protected
+ else
+ ci_variable_protected_by_default?
+ end
+ end
+
+ def ci_variable_masked?(variable, only_key_value)
+ if variable && !only_key_value
+ variable.masked
+ else
+ true
+ end
+ end
+
+ def ci_variable_type_options
+ [
+ %w(Variable env_var),
+ %w(File file)
+ ]
+ end
+end
diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb
index 916dcb1a308..769f75f57c4 100644
--- a/app/helpers/clusters_helper.rb
+++ b/app/helpers/clusters_helper.rb
@@ -14,4 +14,10 @@ module ClustersHelper
render 'clusters/clusters/gcp_signup_offer_banner'
end
end
+
+ def has_rbac_enabled?(cluster)
+ return cluster.platform_kubernetes_rbac? if cluster.platform_kubernetes
+
+ !cluster.provider.legacy_abac?
+ end
end
diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb
index d52cfd6e37a..d58f634425b 100644
--- a/app/helpers/commits_helper.rb
+++ b/app/helpers/commits_helper.rb
@@ -154,7 +154,7 @@ module CommitsHelper
if user.nil?
mail_to(source_email, text, link_options)
else
- link_to(text, user_path(user), link_options)
+ link_to(text, user_path(user), { class: "commit-#{options[:source]}-link js-user-link", data: { user_id: user.id } })
end
end
diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb
index 13839474e1f..62bb2e4da23 100644
--- a/app/helpers/count_helper.rb
+++ b/app/helpers/count_helper.rb
@@ -13,7 +13,7 @@ module CountHelper
# memberships, and deducting 1 for each root of the fork network.
# This might be inacurate as the root of the fork network might have been deleted.
#
- # This makes querying this information a lot more effecient and it should be
+ # This makes querying this information a lot more efficient and it should be
# accurate enough for the instance wide statistics
def approximate_fork_count_with_delimiters(count_data)
fork_network_count = count_data[ForkNetwork]
diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb
index d90ef8903a7..42732eb93dd 100644
--- a/app/helpers/dashboard_helper.rb
+++ b/app/helpers/dashboard_helper.rb
@@ -21,6 +21,10 @@ module DashboardHelper
links.any? { |link| dashboard_nav_link?(link) }
end
+ def has_start_trial?
+ false
+ end
+
private
def get_dashboard_nav_links
diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb
index b6844d36052..32431959851 100644
--- a/app/helpers/diff_helper.rb
+++ b/app/helpers/diff_helper.rb
@@ -138,30 +138,6 @@ module DiffHelper
!diff_file.deleted_file? && @merge_request && @merge_request.source_project
end
- def diff_render_error_reason(viewer)
- case viewer.render_error
- when :too_large
- "it is too large"
- when :server_side_but_stored_externally
- case viewer.diff_file.external_storage
- when :lfs
- 'it is stored in LFS'
- else
- 'it is stored externally'
- end
- end
- end
-
- def diff_render_error_options(viewer)
- diff_file = viewer.diff_file
- options = []
-
- blob_url = project_blob_path(@project, tree_join(diff_file.content_sha, diff_file.file_path))
- options << link_to('view the blob', blob_url)
-
- options
- end
-
def diff_file_changed_icon(diff_file)
if diff_file.deleted_file?
"file-deletion"
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index e4c46ceeaa2..dc0e5511fcf 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -36,6 +36,14 @@ module EmailsHelper
nil
end
+ def sanitize_name(name)
+ if name =~ URI::DEFAULT_PARSER.regexp[:URI_REF]
+ name.tr('.', '_')
+ else
+ name
+ end
+ end
+
def password_reset_token_valid_time
valid_hours = Devise.reset_password_within / 60 / 60
if valid_hours >= 24
@@ -58,7 +66,7 @@ module EmailsHelper
def header_logo
if current_appearance&.header_logo?
image_tag(
- current_appearance.header_logo,
+ current_appearance.header_logo_path,
style: 'height: 50px'
)
else
@@ -83,6 +91,28 @@ module EmailsHelper
].join(';')
end
+ def closure_reason_text(closed_via, format: nil)
+ case closed_via
+ when MergeRequest
+ merge_request = MergeRequest.find(closed_via[:id]).present
+
+ case format
+ when :html
+ " via merge request #{link_to(merge_request.to_reference, merge_request.web_url)}"
+ else
+ # If it's not HTML nor text then assume it's text to be safe
+ " via merge request #{merge_request.to_reference} (#{merge_request.web_url})"
+ end
+ when String
+ # Technically speaking this should be Commit but per
+ # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15610#note_163812339
+ # we can't deserialize Commit without custom serializer for ActiveJob
+ " via #{closed_via}"
+ else
+ ""
+ end
+ end
+
# "You are receiving this email because #{reason}"
def notification_reason_text(reason)
string = case reason
@@ -123,4 +153,42 @@ module EmailsHelper
project.id.to_s + "." + project_path_as_domain + "." + Gitlab.config.gitlab.host
end
+
+ def html_header_message
+ return unless show_header?
+
+ render_message(:header_message, style: '')
+ end
+
+ def html_footer_message
+ return unless show_footer?
+
+ render_message(:footer_message, style: '')
+ end
+
+ def text_header_message
+ return unless show_header?
+
+ strip_tags(render_message(:header_message, style: ''))
+ end
+
+ def text_footer_message
+ return unless show_footer?
+
+ strip_tags(render_message(:footer_message, style: ''))
+ end
+
+ private
+
+ def show_footer?
+ email_header_and_footer_enabled? && current_appearance&.show_footer?
+ end
+
+ def show_header?
+ email_header_and_footer_enabled? && current_appearance&.show_header?
+ end
+
+ def email_header_and_footer_enabled?
+ current_appearance&.email_header_and_footer_enabled?
+ end
end
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 7b22bc8f98f..365b94f5a3e 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -7,6 +7,14 @@ module EnvironmentsHelper
}
end
+ def environments_folder_list_view_data
+ {
+ "endpoint" => folder_project_environments_path(@project, @folder, format: :json),
+ "folder-name" => @folder,
+ "can-read-environment" => can?(current_user, :read_environment, @project).to_s
+ }
+ end
+
def metrics_data(project, environment)
{
"settings-path" => edit_project_service_path(project, 'prometheus'),
diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb
index 1371e9993b4..e990e425cb6 100644
--- a/app/helpers/events_helper.rb
+++ b/app/helpers/events_helper.rb
@@ -68,7 +68,7 @@ module EventsHelper
end
def event_preposition(event)
- if event.push? || event.commented? || event.target
+ if event.push_action? || event.commented_action? || event.target
"at"
elsif event.milestone?
"in"
@@ -80,11 +80,11 @@ module EventsHelper
words << event.author_name
words << event_action_name(event)
- if event.push?
+ if event.push_action?
words << event.ref_type
words << event.ref_name
words << "at"
- elsif event.commented?
+ elsif event.commented_action?
words << event.note_target_reference
words << "at"
elsif event.milestone?
@@ -121,9 +121,9 @@ module EventsHelper
if event.note_target
event_note_target_url(event)
end
- elsif event.push?
+ elsif event.push_action?
push_event_feed_url(event)
- elsif event.created_project?
+ elsif event.created_project_action?
project_url(event.project)
end
end
@@ -147,7 +147,7 @@ module EventsHelper
def event_feed_summary(event)
if event.issue?
render "events/event_issue", issue: event.issue
- elsif event.push?
+ elsif event.push_action?
render "events/event_push", event: event
elsif event.merge_request?
render "events/event_merge_request", merge_request: event.merge_request
diff --git a/app/helpers/external_wiki_helper.rb b/app/helpers/external_wiki_helper.rb
deleted file mode 100644
index e36d63b2946..00000000000
--- a/app/helpers/external_wiki_helper.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-module ExternalWikiHelper
- def get_project_wiki_path(project)
- external_wiki_service = project.external_wiki
- if external_wiki_service
- external_wiki_service.properties['external_wiki_url']
- else
- project_wiki_path(project, :home)
- end
- end
-end
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 5705ee54cee..f7c7f37cc38 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -4,8 +4,7 @@ module FormHelper
def form_errors(model, type: 'form')
return unless model.errors.any?
- pluralized = 'error'.pluralize(model.errors.count)
- headline = "The #{type} contains the following #{pluralized}:"
+ headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type }
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) <<
@@ -18,19 +17,19 @@ module FormHelper
end
end
- def issue_assignees_dropdown_options
- {
+ def assignees_dropdown_options(issuable_type)
+ dropdown_data = {
toggle_class: 'js-user-search js-assignee-search js-multiselect js-save-user-data',
title: 'Select assignee',
filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee',
- placeholder: 'Search users',
+ placeholder: _('Search users'),
data: {
first_user: current_user&.username,
null_user: true,
current_user: true,
- project_id: @project&.id,
- field_name: 'issue[assignee_ids][]',
+ project_id: (@target_project || @project)&.id,
+ field_name: "#{issuable_type}[assignee_ids][]",
default_label: 'Unassigned',
'max-select': 1,
'dropdown-header': 'Assignee',
@@ -40,5 +39,36 @@ module FormHelper
current_user_info: UserSerializer.new.represent(current_user)
}
}
+
+ type = issuable_type.to_s
+
+ if type == 'issue' && issue_supports_multiple_assignees? ||
+ type == 'merge_request' && merge_request_supports_multiple_assignees?
+ dropdown_data = multiple_assignees_dropdown_options(dropdown_data)
+ end
+
+ dropdown_data
+ end
+
+ # Overwritten
+ def issue_supports_multiple_assignees?
+ false
+ end
+
+ # Overwritten
+ def merge_request_supports_multiple_assignees?
+ false
+ end
+
+ private
+
+ def multiple_assignees_dropdown_options(options)
+ new_options = options.dup
+
+ new_options[:title] = 'Select assignee(s)'
+ new_options[:data][:'dropdown-header'] = 'Assignee(s)'
+ new_options[:data].delete(:'max-select')
+
+ new_options
end
end
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
new file mode 100644
index 00000000000..a5d2f76820f
--- /dev/null
+++ b/app/helpers/groups/group_members_helper.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module Groups::GroupMembersHelper
+ def group_member_select_options
+ { multiple: true, class: 'input-clamp', scope: :all, email_user: true }
+ end
+end
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 866fc555856..7af766c8544 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -4,6 +4,7 @@ module GroupsHelper
def group_overview_nav_link_paths
%w[
groups#show
+ groups#details
groups#activity
groups#subgroups
analytics#show
@@ -117,16 +118,17 @@ module GroupsHelper
end
def parent_group_options(current_group)
- groups = current_user.owned_groups.sort_by(&:human_name).map do |group|
+ exclude_groups = current_group.self_and_descendants.pluck_primary_key
+ exclude_groups << current_group.parent_id if current_group.parent_id
+ groups = GroupsFinder.new(current_user, min_access_level: Gitlab::Access::OWNER, exclude_group_ids: exclude_groups).execute.sort_by(&:human_name).map do |group|
{ id: group.id, text: group.human_name }
end
- groups.delete_if { |group| group[:id] == current_group.id }
groups.to_json
end
def supports_nested_groups?
- Group.supports_nested_groups?
+ Group.supports_nested_objects?
end
private
diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb
index 49171df1433..3d494c3de6a 100644
--- a/app/helpers/import_helper.rb
+++ b/app/helpers/import_helper.rb
@@ -8,7 +8,9 @@ module ImportHelper
end
def sanitize_project_name(name)
- name.gsub(/[^\w\-]/, '-')
+ # For personal projects in Bitbucket in the form ~username, we can
+ # just drop that leading tilde.
+ name.gsub(/\A~+/, '').gsub(/[^\w\-]/, '-')
end
def import_project_target(owner, name)
@@ -16,10 +18,8 @@ module ImportHelper
"#{namespace}/#{name}"
end
- def provider_project_link(provider, full_path)
- url = __send__("#{provider}_project_url", full_path) # rubocop:disable GitlabSecurity/PublicSend
-
- link_to full_path, url, target: '_blank', rel: 'noopener noreferrer'
+ def provider_project_link_url(provider_url, full_path)
+ Gitlab::Utils.append_path(provider_url, full_path)
end
def import_will_timeout_message(_ci_cd_only)
@@ -44,10 +44,6 @@ module ImportHelper
_('Please wait while we import the repository for you. Refresh at will.')
end
- def import_github_title
- _('Import repositories from GitHub')
- end
-
def import_github_authorize_message
_('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:')
end
@@ -71,30 +67,4 @@ module ImportHelper
_('Note: Consider asking your GitLab administrator to configure %{github_integration_link}, which will allow login via GitHub and allow importing repositories without generating a Personal Access Token.').html_safe % { github_integration_link: github_integration_link }
end
end
-
- def import_githubish_choose_repository_message
- _('Choose which repositories you want to import.')
- end
-
- def import_all_githubish_repositories_button_label
- _('Import all repositories')
- end
-
- private
-
- def github_project_url(full_path)
- Gitlab::Utils.append_path(github_root_url, full_path)
- end
-
- def github_root_url
- strong_memoize(:github_url) do
- provider = Gitlab::Auth::OAuth::Provider.config_for('github')
-
- provider&.dig('url').presence || 'https://github.com'
- end
- end
-
- def gitea_project_url(full_path)
- Gitlab::Utils.append_path(@gitea_host_url, full_path)
- end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index da991458ea7..9a12db258d5 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -15,38 +15,52 @@ module IssuablesHelper
sidebar_gutter_collapsed? ? _('Expand sidebar') : _('Collapse sidebar')
end
- def sidebar_assignee_tooltip_label(issuable)
- if issuable.assignee
- issuable.assignee.name
+ def assignees_label(issuable, include_value: true)
+ label = 'Assignee'.pluralize(issuable.assignees.count)
+
+ if include_value
+ sanitized_list = sanitize_name(issuable.assignee_list)
+ "#{label}: #{sanitized_list}"
else
- issuable.allows_multiple_assignees? ? _('Assignee(s)') : _('Assignee')
+ label
end
end
- def sidebar_due_date_tooltip_label(issuable)
- if issuable.due_date
- "#{_('Due date')}<br />#{due_date_remaining_days(issuable)}"
- else
- _('Due date')
- end
+ def sidebar_milestone_tooltip_label(milestone)
+ return _('Milestone') unless milestone.present?
+
+ [milestone[:title], sidebar_milestone_remaining_days(milestone) || _('Milestone')].join('<br/>')
+ end
+
+ def sidebar_milestone_remaining_days(milestone)
+ due_date_with_remaining_days(milestone[:due_date], milestone[:start_date])
+ end
+
+ def sidebar_due_date_tooltip_label(due_date)
+ [_('Due date'), due_date_with_remaining_days(due_date)].compact.join('<br/>')
+ end
+
+ def due_date_with_remaining_days(due_date, start_date = nil)
+ return unless due_date
+
+ "#{due_date.to_s(:medium)} (#{remaining_days_in_words(due_date, start_date)})"
end
- def due_date_remaining_days(issuable)
- remaining_days_in_words = remaining_days_in_words(issuable)
+ def sidebar_label_filter_path(base_path, label_name)
+ query_params = { label_name: [label_name] }.to_query
- "#{issuable.due_date.to_s(:medium)} (#{remaining_days_in_words})"
+ "#{base_path}?#{query_params}"
end
def multi_label_name(current_labels, default_label)
- if current_labels && current_labels.any?
- title = current_labels.first.try(:title)
- if current_labels.size > 1
- "#{title} +#{current_labels.size - 1} more"
- else
- title
- end
+ return default_label if current_labels.blank?
+
+ title = current_labels.first.try(:title) || current_labels.first[:title]
+
+ if current_labels.size > 1
+ "#{title} +#{current_labels.size - 1} more"
else
- default_label
+ title
end
end
@@ -180,7 +194,7 @@ module IssuablesHelper
output << content_tag(:strong) do
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
- author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none")
+ author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-inline d-sm-none")
if status = user_status(issuable.author)
author_output << "#{status}".html_safe
@@ -189,7 +203,7 @@ module IssuablesHelper
author_output
end
- output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!'))
+ output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip prepend-left-4', title: _('1st contribution!'))
output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block prepend-left-8")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none")
@@ -197,19 +211,11 @@ module IssuablesHelper
output.join.html_safe
end
- # rubocop: disable CodeReuse/ActiveRecord
- def issuable_todo(issuable)
- if current_user
- current_user.todos.find_by(target: issuable, state: :pending)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def issuable_labels_tooltip(labels, limit: 5)
- first, last = labels.partition.with_index { |_, i| i < limit }
+ first, last = labels.partition.with_index { |_, i| i < limit }
if labels && labels.any?
- label_names = first.collect(&:name)
+ label_names = first.collect { |label| label.fetch(:title) }
label_names << "and #{last.size} more" unless last.empty?
label_names.join(', ')
@@ -265,7 +271,7 @@ module IssuablesHelper
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
+ lockVersion: issuable.lock_version,
issuableTemplates: issuable_templates(issuable),
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
@@ -274,6 +280,8 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
+ data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue)
+
if parent.is_a?(Group)
data[:groupPath] = parent.path
else
@@ -356,12 +364,6 @@ module IssuablesHelper
issuable.model_name.human.downcase
end
- def selected_labels
- Array(params[:label_name]).map do |label_name|
- Label.new(title: label_name)
- end
- end
-
def has_filter_bar_param?
finder.class.scalar_params.any? { |p| params[p].present? }
end
@@ -386,19 +388,20 @@ module IssuablesHelper
params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
- def issuable_todo_button_data(issuable, todo, is_collapsed)
+ def issuable_todo_button_data(issuable, is_collapsed)
{
- todo_text: "Add todo",
- mark_text: "Mark todo as done",
- todo_icon: (is_collapsed ? sprite_icon('todo-add') : nil),
- mark_icon: (is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : nil),
- issuable_id: issuable.id,
- issuable_type: issuable.class.name.underscore,
- url: project_todos_path(@project),
- delete_path: (dashboard_todo_path(todo) if todo),
- placement: (is_collapsed ? 'left' : nil),
- container: (is_collapsed ? 'body' : nil),
- boundary: 'viewport'
+ todo_text: _('Add todo'),
+ mark_text: _('Mark todo as done'),
+ todo_icon: sprite_icon('todo-add'),
+ mark_icon: sprite_icon('todo-done', css_class: 'todo-undone'),
+ issuable_id: issuable[:id],
+ issuable_type: issuable[:type],
+ create_path: issuable[:create_todo_path],
+ delete_path: issuable.dig(:current_user, :todo, :delete_path),
+ placement: is_collapsed ? 'left' : nil,
+ container: is_collapsed ? 'body' : nil,
+ boundary: 'viewport',
+ is_collapsed: is_collapsed
}
end
@@ -418,27 +421,20 @@ module IssuablesHelper
end
end
- def issuable_sidebar_options(issuable, can_edit_issuable)
+ def issuable_sidebar_options(issuable)
{
- endpoint: "#{issuable_json_path(issuable)}?serializer=sidebar",
- toggleSubscriptionEndpoint: toggle_subscription_path(issuable),
- moveIssueEndpoint: move_namespace_project_issue_path(namespace_id: issuable.project.namespace.to_param, project_id: issuable.project, id: issuable),
- projectsAutocompleteEndpoint: autocomplete_projects_path(project_id: @project.id),
- editable: can_edit_issuable,
- currentUser: UserSerializer.new.represent(current_user),
+ endpoint: "#{issuable[:issuable_json_path]}?serializer=sidebar_extras",
+ toggleSubscriptionEndpoint: issuable[:toggle_subscription_path],
+ moveIssueEndpoint: issuable[:move_issue_path],
+ projectsAutocompleteEndpoint: issuable[:projects_autocomplete_path],
+ editable: issuable.dig(:current_user, :can_edit),
+ currentUser: issuable[:current_user],
rootPath: root_path,
- fullPath: @project.full_path
+ fullPath: issuable[:project_full_path]
}
end
def parent
@project || @group
end
-
- def issuable_milestone_tooltip_title(issuable)
- if issuable.milestone
- milestone_tooltip = milestone_tooltip_title(issuable.milestone)
- _('Milestone') + (milestone_tooltip ? ': ' + milestone_tooltip : '')
- end
- end
end
diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb
index 39f661b5f0c..acc8aeae282 100644
--- a/app/helpers/labels_helper.rb
+++ b/app/helpers/labels_helper.rb
@@ -5,7 +5,7 @@ module LabelsHelper
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
- return true if label.is_a?(GroupLabel)
+ return true unless label.project_label?
return true unless project
project.feature_available?(issuables_type, current_user)
@@ -13,9 +13,7 @@ module LabelsHelper
# Link to a Label
#
- # label - Label object to link to
- # subject - Project/Group object which will be used as the context for the
- # label's link. If omitted, defaults to the label's own group/project.
+ # label - LabelPresenter object to link to
# type - The type of item the link will point to (:issue or
# :merge_request). If omitted, defaults to :issue.
# block - An optional block that will be passed to `link_to`, forming the
@@ -40,57 +38,43 @@ module LabelsHelper
# link_to_label(label) { "My Custom Label Text" }
#
# Returns a String
- def link_to_label(label, subject: nil, type: :issue, tooltip: true, css_class: nil, &block)
- link = label_filter_path(subject || label.subject, label, type: type)
+ def link_to_label(label, type: :issue, tooltip: true, css_class: nil, &block)
+ link = label.filter_path(type: type)
if block_given?
link_to link, class: css_class, &block
else
- link_to render_colored_label(label, tooltip: tooltip), link, class: css_class
+ render_label(label, tooltip: tooltip, link: link, css: css_class)
end
end
- def label_filter_path(subject, label, type: :issue)
- case subject
- when Group
- send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
- subject,
- label_name: [label.name])
- when Project
- send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
- subject.namespace,
- subject,
- label_name: [label.name])
- end
- end
-
- def edit_label_path(label)
- case label
- when GroupLabel then edit_group_label_path(label.group, label)
- when ProjectLabel then edit_project_label_path(label.project, label)
- end
- end
+ def render_label(label, tooltip: true, link: nil, css: nil)
+ # if scoped label is used then EE wraps label tag with scoped label
+ # doc link
+ html = render_colored_label(label, tooltip: tooltip)
+ html = link_to(html, link, class: css) if link
- def destroy_label_path(label)
- case label
- when GroupLabel then group_label_path(label.group, label)
- when ProjectLabel then project_label_path(label.project, label)
- end
+ html
end
- def render_colored_label(label, label_suffix = '', tooltip: true)
+ def render_colored_label(label, label_suffix: '', tooltip: true, title: nil)
text_color = text_color_for_bg(label.color)
+ title ||= tooltip ? label_tooltip_title(label) : label.name
# Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter
span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) +
- %(style="background-color: #{label.color}; color: #{text_color}" ) +
- %(title="#{escape_once(label.description)}" data-container="body">) +
+ %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) +
+ %(title="#{escape_once(title)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>)
span.html_safe
end
+ def label_tooltip_title(label)
+ label.description
+ end
+
def suggested_colors
[
'#0033CC',
@@ -154,10 +138,6 @@ module LabelsHelper
end
end
- def can_subscribe_to_label_in_different_levels?(label)
- defined?(@project) && label.is_a?(GroupLabel)
- end
-
def label_subscription_status(label, project)
return 'group-level' if label.subscribed?(current_user)
return 'project-level' if label.subscribed?(current_user, project)
@@ -179,13 +159,6 @@ module LabelsHelper
label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe'
end
- def label_deletion_confirm_text(label)
- case label
- when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?'
- when ProjectLabel then 'Remove this label? Are you sure?'
- end
- end
-
def create_label_title(subject)
case subject
when Group
@@ -220,13 +193,52 @@ module LabelsHelper
end
def label_status_tooltip(label, status)
- type = label.is_a?(ProjectLabel) ? 'project' : 'group'
+ type = label.project_label? ? 'project' : 'group'
level = status.unsubscribed? ? type : status.sub('-level', '')
action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe'
"#{action} at #{level} level"
end
+ def presented_labels_sorted_by_title(labels, subject)
+ labels.sort_by(&:title).map { |label| label.present(issuable_subject: subject) }
+ end
+
+ def label_dropdown_data(project, opts = {})
+ {
+ toggle: "dropdown",
+ field_name: opts[:field_name] || "label_name[]",
+ show_no: "true",
+ show_any: "true",
+ project_id: project&.try(:id),
+ namespace_path: project&.try(:namespace)&.try(:full_path),
+ project_path: project&.try(:path)
+ }.merge(opts)
+ end
+
+ def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
+ label_dropdown_data(nil, {
+ default_label: "Labels",
+ field_name: "#{issuable_type}[label_names][]",
+ ability_name: issuable_type,
+ namespace_path: issuable_sidebar[:namespace_path],
+ project_path: issuable_sidebar[:project_path],
+ issue_update: issuable_sidebar[:issuable_json_path],
+ labels: issuable_sidebar[:project_labels_path],
+ display: 'static'
+ })
+ end
+
+ def label_from_hash(hash)
+ klass = hash[:group_id] ? GroupLabel : ProjectLabel
+
+ klass.new(hash.slice(:color, :description, :title, :group_id, :project_id))
+ end
+
+ def issuable_types
+ ['issues', 'merge requests']
+ end
+
# Required for Banzai::Filter::LabelReferenceFilter
- module_function :render_colored_label, :text_color_for_bg, :escape_once
+ module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title
end
diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb
index 0d638b850b4..dce4168ad7b 100644
--- a/app/helpers/markup_helper.rb
+++ b/app/helpers/markup_helper.rb
@@ -4,7 +4,7 @@ require 'nokogiri'
module MarkupHelper
include ActionView::Helpers::TagHelper
- include ActionView::Context
+ include ::Gitlab::ActionViewOutput::Context
def plain?(filename)
Gitlab::MarkupHelper.plain?(filename)
@@ -74,7 +74,7 @@ module MarkupHelper
# the tag contents are truncated without removing the closing tag.
def first_line_in_markdown(object, attribute, max_chars = nil, options = {})
md = markdown_field(object, attribute, options)
- return nil unless md.present?
+ return unless md.present?
tags = %w(a gl-emoji b pre code p span)
tags << 'img' if options[:allow_images]
@@ -83,7 +83,8 @@ module MarkupHelper
text = sanitize(
text,
tags: tags,
- attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-src', 'data-name', 'data-unicode-version']
+ attributes: Rails::Html::WhiteListSanitizer.allowed_attributes +
+ %w(style data-src data-name data-unicode-version data-iid data-project-path data-mr-title)
)
# since <img> tags are stripped, this can leave empty <a> tags hanging around
@@ -116,7 +117,6 @@ module MarkupHelper
def markup(file_name, text, context = {})
context[:project] ||= @project
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html = context.delete(:rendered) || markup_unsafe(file_name, text, context)
prepare_for_rendering(html, context)
end
@@ -132,7 +132,6 @@ module MarkupHelper
page_slug: wiki_page.slug,
issuable_state_filter_enabled: true
)
- context[:markdown_engine] ||= :redcarpet unless commonmark_for_repositories_enabled?
html =
case wiki_page.format
@@ -187,10 +186,6 @@ module MarkupHelper
end
end
- def commonmark_for_repositories_enabled?
- Feature.enabled?(:commonmark_for_repositories, default_enabled: true)
- end
-
private
# Return +text+, truncated to +max_chars+ characters, excluding any HTML
@@ -247,9 +242,7 @@ module MarkupHelper
node.remove if node.name == 'a' && node.content.blank?
end
- # Use `Loofah` directly instead of `sanitize`
- # as we still use the `rails-deprecated_sanitizer` gem
- Loofah.fragment(text).scrub!(scrubber).to_s
+ sanitize text, scrubber: scrubber
end
def markdown_toolbar_button(options = {})
diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb
index 5a21403bc5e..11d5591d509 100644
--- a/app/helpers/members_helper.rb
+++ b/app/helpers/members_helper.rb
@@ -18,12 +18,13 @@ module MembersHelper
"remove #{member.user.name} from"
end
- "#{text} #{action} the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?"
+ "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?"
end
def remove_member_title(member)
action = member.request? ? 'Deny access request' : 'Remove user'
- "#{action} from #{member.real_source_type.humanize(capitalize: false)}"
+
+ "#{action} from #{source_text(member)}"
end
def leave_confirmation_message(member_source)
@@ -32,7 +33,17 @@ module MembersHelper
end
def filter_group_project_member_path(options = {})
- options = params.slice(:search, :sort).merge(options)
+ options = params.slice(:search, :sort).merge(options).permit!
"#{request.path}?#{options.to_param}"
end
+
+ private
+
+ def source_text(member)
+ type = member.real_source_type.humanize(capitalize: false)
+
+ return type if member.request? || member.invite? || type != 'group'
+
+ 'group and any subresources'
+ end
end
diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb
index 23d7aa427bb..991ca42c445 100644
--- a/app/helpers/merge_requests_helper.rb
+++ b/app/helpers/merge_requests_helper.rb
@@ -29,7 +29,7 @@ module MergeRequestsHelper
def ci_build_details_path(merge_request)
build_url = merge_request.source_project.ci_service.build_page(merge_request.diff_head_sha, merge_request.source_branch)
- return nil unless build_url
+ return unless build_url
parsed_url = URI.parse(build_url)
@@ -92,7 +92,7 @@ module MergeRequestsHelper
end
def version_index(merge_request_diff)
- return nil if @merge_request_diffs.empty?
+ return if @merge_request_diffs.empty?
@merge_request_diffs.size - @merge_request_diffs.index(merge_request_diff)
end
@@ -149,7 +149,7 @@ module MergeRequestsHelper
def merge_request_source_project_for_project(project = @project)
unless can?(current_user, :create_merge_request_in, project)
- return nil
+ return
end
if can?(current_user, :create_merge_request_from, project)
diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb
index 9666080092b..c1a04640688 100644
--- a/app/helpers/milestones_helper.rb
+++ b/app/helpers/milestones_helper.rb
@@ -45,7 +45,7 @@ module MilestonesHelper
when :closed
issues.closed
else
- raise ArgumentError, "invalid milestone state `#{state}`"
+ raise ArgumentError, _("invalid milestone state `%{state}`") % { state: state }
end
issues.size
@@ -114,12 +114,6 @@ module MilestonesHelper
end
end
- def milestone_tooltip_title(milestone)
- if milestone
- "#{milestone.title}<br />#{milestone_tooltip_due_date(milestone)}"
- end
- end
-
def milestone_time_for(date, date_type)
title = date_type == :start ? "Start date" : "End date"
@@ -151,8 +145,13 @@ module MilestonesHelper
content = []
- content << n_("1 open issue", "%d open issues", issues["opened"]) % issues["opened"] if issues["opened"]
- content << n_("1 closed issue", "%d closed issues", issues["closed"]) % issues["closed"] if issues["closed"]
+ if issues["opened"]
+ content << n_("1 open issue", "%{issues} open issues", issues["opened"]) % { issues: issues["opened"] }
+ end
+
+ if issues["closed"]
+ content << n_("1 closed issue", "%{issues} closed issues", issues["closed"]) % { issues: issues["closed"] }
+ end
content.join('<br />').html_safe
end
@@ -164,16 +163,16 @@ module MilestonesHelper
content = []
- content << n_("1 open merge request", "%d open merge requests", merge_requests.opened.count) % merge_requests.opened.count if merge_requests.opened.any?
- content << n_("1 closed merge request", "%d closed merge requests", merge_requests.closed.count) % merge_requests.closed.count if merge_requests.closed.any?
- content << n_("1 merged merge request", "%d merged merge requests", merge_requests.merged.count) % merge_requests.merged.count if merge_requests.merged.any?
+ content << n_("1 open merge request", "%{merge_requests} open merge requests", merge_requests.opened.count) % { merge_requests: merge_requests.opened.count } if merge_requests.opened.any?
+ content << n_("1 closed merge request", "%{merge_requests} closed merge requests", merge_requests.closed.count) % { merge_requests: merge_requests.closed.count } if merge_requests.closed.any?
+ content << n_("1 merged merge request", "%{merge_requests} merged merge requests", merge_requests.merged.count) % { merge_requests: merge_requests.merged.count } if merge_requests.merged.any?
content.join('<br />').html_safe
end
def milestone_tooltip_due_date(milestone)
if milestone.due_date
- "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone)})"
+ "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})"
else
_('Milestone')
end
@@ -184,15 +183,15 @@ module MilestonesHelper
"#{milestone.start_date.to_s(:medium)}–#{milestone.due_date.to_s(:medium)}"
elsif milestone.due_date
if milestone.due_date.past?
- "expired on #{milestone.due_date.to_s(:medium)}"
+ _("expired on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
else
- "expires on #{milestone.due_date.to_s(:medium)}"
+ _("expires on %{milestone_due_date}") % { milestone_due_date: milestone.due_date.strftime('%b %-d, %Y') }
end
elsif milestone.start_date
if milestone.start_date.past?
- "started on #{milestone.start_date.to_s(:medium)}"
+ _("started on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
else
- "starts on #{milestone.start_date.to_s(:medium)}"
+ _("starts on %{milestone_start_date}") % { milestone_start_date: milestone.start_date.strftime('%b %-d, %Y') }
end
end
end
@@ -237,12 +236,15 @@ module MilestonesHelper
end
end
- def group_or_dashboard_milestone_path(milestone)
- if milestone.group_milestone?
- group_milestone_path(milestone.group, milestone.iid, milestone: { title: milestone.title })
- else
- dashboard_milestone_path(milestone.safe_title, title: milestone.title)
- end
+ def group_or_project_milestone_path(milestone)
+ params =
+ if milestone.group_milestone?
+ { milestone: { title: milestone.title } }
+ else
+ { title: milestone.title }
+ end
+
+ milestone_path(milestone.milestone, params)
end
def can_admin_project_milestones?
diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb
index 65c7cd82832..921c79ab771 100644
--- a/app/helpers/mirror_helper.rb
+++ b/app/helpers/mirror_helper.rb
@@ -7,4 +7,8 @@ module MirrorHelper
project_mirror_endpoint: project_mirror_path(@project, :json)
}
end
+
+ def mirror_lfs_sync_message
+ _('The Git LFS objects will <strong>not</strong> be synced.').html_safe
+ end
end
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index 6c65e573307..572d68cb4a3 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -5,11 +5,8 @@ module NamespacesHelper
params.dig(:project, :namespace_id) || params[:namespace_id]
end
- # rubocop: disable CodeReuse/ActiveRecord
def namespaces_options(selected = :current_user, display_path: false, groups: nil, extra_group: nil, groups_only: false)
- groups ||= current_user.manageable_groups
- .eager_load(:route)
- .order('routes.path')
+ groups ||= current_user.manageable_groups_with_routes
users = [current_user.namespace]
selected_id = selected
@@ -43,7 +40,6 @@ module NamespacesHelper
grouped_options_for_select(options, selected_id)
end
- # rubocop: enable CodeReuse/ActiveRecord
def namespace_icon(namespace, size = 40)
if namespace.is_a?(Group)
@@ -53,6 +49,13 @@ module NamespacesHelper
end
end
+ def namespaces_options_with_developer_maintainer_access(options = {})
+ selected = options.delete(:selected) || :current_user
+ options[:groups] = current_user.manageable_groups_with_routes(include_groups_with_developer_maintainer_access: true)
+
+ namespaces_options(selected, options)
+ end
+
private
# Many importers create a temporary Group, so use the real
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index 05da5ebdb22..a57ba5f3a4f 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -58,6 +58,14 @@ module NavHelper
current_path?('milestones#show')
end
+ def admin_monitoring_nav_links
+ %w(system_info background_jobs logs health_check requests_profiles)
+ end
+
+ def group_issues_sub_menu_items
+ %w(groups#issues labels#index milestones#index boards#index boards#show)
+ end
+
private
def get_header_links
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index 033686823a2..2e31a5e2ed4 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -85,7 +85,7 @@ module NotesHelper
diffs_project_merge_request_path(discussion.project, discussion.noteable, path_params)
elsif discussion.for_commit?
- anchor = discussion.line_code if discussion.diff_discussion?
+ anchor = discussion.diff_discussion? ? discussion.line_code : "note_#{discussion.first_note.id}"
project_commit_path(discussion.project, discussion.noteable, anchor: anchor)
end
@@ -122,21 +122,15 @@ module NotesHelper
end
def new_form_url
- return nil unless @snippet.is_a?(PersonalSnippet)
+ return unless @snippet.is_a?(PersonalSnippet)
snippet_notes_path(@snippet)
end
def can_create_note?
- issuable = @issue || @merge_request
+ noteable = @issue || @merge_request || @snippet || @project
- if @snippet.is_a?(PersonalSnippet)
- can?(current_user, :comment_personal_snippet, @snippet)
- elsif issuable
- can?(current_user, :create_note, issuable)
- else
- can?(current_user, :create_note, @project)
- end
+ can?(current_user, :create_note, noteable)
end
def initial_notes_data(autocomplete)
@@ -171,7 +165,6 @@ module NotesHelper
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'),
- markdownVersion: issuable.cached_markdown_version,
quickActionsDocsPath: help_page_path('user/project/quick_actions'),
closePath: close_issuable_path(issuable),
reopenPath: reopen_issuable_path(issuable),
diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb
index 5318ab4ddef..a7ce7667916 100644
--- a/app/helpers/notifications_helper.rb
+++ b/app/helpers/notifications_helper.rb
@@ -93,4 +93,11 @@ module NotificationsHelper
s_(event.to_s.humanize)
end
end
+
+ def notification_setting_icon(notification_setting)
+ sprite_icon(
+ notification_setting.disabled? ? "notifications-off" : "notifications",
+ css_class: "icon notifications-icon js-notifications-icon"
+ )
+ end
end
diff --git a/app/helpers/page_layout_helper.rb b/app/helpers/page_layout_helper.rb
index 5038dcf9746..ec1d8577f36 100644
--- a/app/helpers/page_layout_helper.rb
+++ b/app/helpers/page_layout_helper.rb
@@ -1,3 +1,4 @@
+# coding: utf-8
# frozen_string_literal: true
module PageLayoutHelper
@@ -36,7 +37,7 @@ module PageLayoutHelper
if description.present?
@page_description = description.squish
elsif @page_description.present?
- sanitize(@page_description, tags: []).truncate_words(30)
+ sanitize(@page_description.truncate_words(30), tags: [])
end
end
diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb
index f4f46b0fe96..766508b6609 100644
--- a/app/helpers/preferences_helper.rb
+++ b/app/helpers/preferences_helper.rb
@@ -43,6 +43,18 @@ module PreferencesHelper
]
end
+ def first_day_of_week_choices
+ [
+ [_('Sunday'), 0],
+ [_('Monday'), 1],
+ [_('Saturday'), 6]
+ ]
+ end
+
+ def first_day_of_week_choices_with_default
+ first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil])
+ end
+
def user_application_theme
@user_application_theme ||= Gitlab::Themes.for_user(current_user).css_class
end
@@ -51,6 +63,10 @@ module PreferencesHelper
Gitlab::ColorSchemes.for_user(current_user).css_class
end
+ def language_choices
+ Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }
+ end
+
private
# Ensure that anyone adding new options updates `DASHBOARD_CHOICES` too
@@ -66,4 +82,8 @@ module PreferencesHelper
def excluded_dashboard_choices
['operations']
end
+
+ def default_first_day_of_week
+ first_day_of_week_choices.rassoc(Gitlab::CurrentSettings.first_day_of_week).first
+ end
end
diff --git a/app/helpers/profiles_helper.rb b/app/helpers/profiles_helper.rb
index df318de740a..5a42e581867 100644
--- a/app/helpers/profiles_helper.rb
+++ b/app/helpers/profiles_helper.rb
@@ -25,4 +25,8 @@ module ProfilesHelper
end
end
end
+
+ def user_profile?
+ params[:controller] == 'users'
+ end
end
diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb
new file mode 100644
index 00000000000..6daf2e21ca2
--- /dev/null
+++ b/app/helpers/projects/error_tracking_helper.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Projects::ErrorTrackingHelper
+ def error_tracking_data(project)
+ error_tracking_enabled = !!project.error_tracking_setting&.enabled?
+
+ {
+ 'index-path' => project_error_tracking_index_path(project,
+ format: :json),
+ 'enable-error-tracking-link' => project_settings_operations_path(project),
+ 'error-tracking-enabled' => error_tracking_enabled.to_s,
+ 'illustration-path' => image_path('illustrations/cluster_popover.svg')
+ }
+ end
+end
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 1186eb3ddcc..f798bfbf703 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -169,7 +169,7 @@ module ProjectsHelper
translation.html_safe
end
- def project_list_cache_key(project)
+ def project_list_cache_key(project, pipeline_status: true)
key = [
project.route.cache_key,
project.cache_key,
@@ -179,10 +179,11 @@ module ProjectsHelper
Gitlab::CurrentSettings.cache_key,
"cross-project:#{can?(current_user, :read_cross_project)}",
max_project_member_access_cache_key(project),
+ pipeline_status,
'v2.6'
]
- key << pipeline_status_cache_key(project.pipeline_status) if project.pipeline_status.has_status?
+ key << pipeline_status_cache_key(project.pipeline_status) if pipeline_status && project.pipeline_status.has_status?
key
end
@@ -204,12 +205,10 @@ module ProjectsHelper
current_user.require_extra_setup_for_git_auth?
end
- def show_auto_devops_implicitly_enabled_banner?(project)
- cookie_key = "hide_auto_devops_implicitly_enabled_banner_#{project.id}"
+ def show_auto_devops_implicitly_enabled_banner?(project, user)
+ return false unless user_can_see_auto_devops_implicitly_enabled_banner?(project, user)
- project.has_auto_devops_implicitly_enabled? &&
- cookies[cookie_key.to_sym].blank? &&
- (project.owner == current_user || project.team.maintainer?(current_user))
+ cookies["hide_auto_devops_implicitly_enabled_banner_#{project.id}".to_sym].blank?
end
def link_to_set_password
@@ -240,8 +239,10 @@ module ProjectsHelper
end
# rubocop: enable CodeReuse/ActiveRecord
+ # TODO: Remove this method when removing the feature flag
+ # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234863
def show_projects?(projects, params)
- !!(params[:personal] || params[:name] || any_projects?(projects))
+ Feature.enabled?(:project_list_filter_bar) || !!(params[:personal] || params[:name] || any_projects?(projects))
end
def push_to_create_project_command(user = current_user)
@@ -267,8 +268,79 @@ module ProjectsHelper
link_to 'BFG', 'https://rtyley.github.io/bfg-repo-cleaner/', target: '_blank', rel: 'noopener noreferrer'
end
- def legacy_render_context(params)
- params[:legacy_render] ? { markdown_engine: :redcarpet } : {}
+ def explore_projects_tab?
+ current_page?(explore_projects_path) ||
+ current_page?(trending_explore_projects_path) ||
+ current_page?(starred_explore_projects_path)
+ end
+
+ def show_merge_request_count?(disabled: false, compact_mode: false)
+ !disabled && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true)
+ end
+
+ def show_issue_count?(disabled: false, compact_mode: false)
+ !disabled && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true)
+ end
+
+ # overridden in EE
+ def settings_operations_available?
+ can?(current_user, :read_environment, @project)
+ end
+
+ def error_tracking_setting_project_json
+ setting = @project.error_tracking_setting
+
+ return if setting.blank? || setting.project_slug.blank? ||
+ setting.organization_slug.blank?
+
+ {
+ name: setting.project_name,
+ organization_name: setting.organization_name,
+ organization_slug: setting.organization_slug,
+ slug: setting.project_slug
+ }.to_json
+ end
+
+ def directory?
+ @path.present?
+ end
+
+ def external_classification_label_help_message
+ default_label = ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ s_(
+ "ExternalAuthorizationService|When no classification label is set the "\
+ "default label `%{default_label}` will be used."
+ ) % { default_label: default_label }
+ end
+
+ def can_import_members?
+ Ability.allowed?(current_user, :admin_project_member, @project)
+ end
+
+ def project_can_be_shared?
+ !membership_locked? || @project.allowed_to_share_with_group?
+ end
+
+ def membership_locked?
+ false
+ end
+
+ def share_project_description(project)
+ share_with_group = project.allowed_to_share_with_group?
+ share_with_members = !membership_locked?
+
+ description =
+ if share_with_group && share_with_members
+ _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.")
+ elsif share_with_group
+ _("You can invite another group to <strong>%{project_name}</strong>.")
+ elsif share_with_members
+ _("You can invite a new member to <strong>%{project_name}</strong>.")
+ end
+
+ description.html_safe % { project_name: project.name }
end
private
@@ -276,8 +348,9 @@ module ProjectsHelper
def get_project_nav_tabs(project, current_user)
nav_tabs = [:home]
- if !project.empty_repo? && can?(current_user, :download_code, project)
- nav_tabs << [:files, :commits, :network, :graphs, :forks]
+ unless project.empty_repo?
+ nav_tabs << [:files, :commits, :network, :graphs, :forks] if can?(current_user, :download_code, project)
+ nav_tabs << :releases if can?(current_user, :read_release, project)
end
if project.repo_exists? && can?(current_user, :read_merge_request, project)
@@ -288,7 +361,8 @@ module ProjectsHelper
nav_tabs << :container_registry
end
- if project.builds_enabled? && can?(current_user, :read_pipeline, project)
+ # Pipelines feature is tied to presence of builds
+ if can?(current_user, :read_build, project)
nav_tabs << :pipelines
end
@@ -296,19 +370,24 @@ module ProjectsHelper
nav_tabs << :operations
end
- if project.external_issue_tracker
- nav_tabs << :external_issue_tracker
- end
-
tab_ability_map.each do |tab, ability|
if can?(current_user, ability, project)
nav_tabs << tab
end
end
+ nav_tabs << external_nav_tabs(project)
+
nav_tabs.flatten
end
+ def external_nav_tabs(project)
+ [].tap do |tabs|
+ tabs << :external_issue_tracker if project.external_issue_tracker
+ tabs << :external_wiki if project.external_wiki
+ end
+ end
+
def tab_ability_map
{
environments: :read_environment,
@@ -318,6 +397,7 @@ module ProjectsHelper
builds: :read_build,
clusters: :read_cluster,
serverless: :read_cluster,
+ error_tracking: :read_sentry_issue,
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
@@ -330,7 +410,8 @@ module ProjectsHelper
blobs: :download_code,
commits: :download_code,
merge_requests: :read_merge_request,
- notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet]
+ notes: [:read_merge_request, :download_code, :read_issue, :read_project_snippet],
+ members: :read_project_member
)
end
@@ -471,7 +552,7 @@ module ProjectsHelper
lfsHelpPath: help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs'),
pagesAvailable: Gitlab.config.pages.enabled,
pagesAccessControlEnabled: Gitlab.config.pages.access_control,
- pagesHelpPath: help_page_path('user/project/pages/index.md')
+ pagesHelpPath: help_page_path('user/project/pages/introduction', anchor: 'gitlab-pages-access-control-core-only')
}
end
@@ -515,24 +596,11 @@ module ProjectsHelper
end
end
- def explore_projects_tab?
- current_page?(explore_projects_path) ||
- current_page?(trending_explore_projects_path) ||
- current_page?(starred_explore_projects_path)
- end
-
- def show_merge_request_count?(merge_requests, compact_mode)
- merge_requests && !compact_mode && Feature.enabled?(:project_list_show_mr_count, default_enabled: true)
- end
-
- def show_issue_count?(issues, compact_mode)
- issues && !compact_mode && Feature.enabled?(:project_list_show_issue_count, default_enabled: true)
- end
-
def sidebar_projects_paths
%w[
projects#show
projects#activity
+ releases#index
cycle_analytics#show
]
end
@@ -545,6 +613,7 @@ module ProjectsHelper
services#edit
repository#show
ci_cd#show
+ operations#show
badges#index
pages#show
]
@@ -564,7 +633,6 @@ module ProjectsHelper
projects/repositories
tags
branches
- releases
graphs
network
]
@@ -575,8 +643,16 @@ module ProjectsHelper
environments
clusters
functions
+ error_tracking
user
gcp
]
end
+
+ def user_can_see_auto_devops_implicitly_enabled_banner?(project, user)
+ Ability.allowed?(user, :admin_project, project) &&
+ project.has_auto_devops_implicitly_enabled? &&
+ project.builds_enabled? &&
+ !project.repository.gitlab_ci_yml
+ end
end
diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb
index cb21f922401..0d880c38a7b 100644
--- a/app/helpers/runners_helper.rb
+++ b/app/helpers/runners_helper.rb
@@ -28,4 +28,14 @@ module RunnersHelper
display_name + id
end
end
+
+ # Due to inability of performing sorting of runners by cached "contacted_at" values we have to show uncached values if sorting by "contacted_asc" is requested.
+ # Please refer to the following issue for more details: https://gitlab.com/gitlab-org/gitlab-ce/issues/55920
+ def runner_contacted_at(runner)
+ if params[:sort] == 'contacted_asc'
+ runner.uncached_contacted_at
+ else
+ runner.contacted_at
+ end
+ end
end
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 80cc568820a..4594f5a31b9 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -24,19 +24,24 @@ module SearchHelper
end
def search_entries_info(collection, scope, term)
- return unless collection.count > 0
+ return if collection.to_a.empty?
from = collection.offset_value + 1
- to = collection.offset_value + collection.count
+ to = collection.offset_value + collection.to_a.size
count = collection.total_count
- "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\""
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") % { from: from, to: to, count: count, scope: scope.humanize(capitalize: false), term: term }
end
- def find_project_for_result_blob(result)
+ def find_project_for_result_blob(projects, result)
@project
end
+ # Used in EE
+ def blob_projects(results)
+ nil
+ end
+
def parse_search_result(result)
result
end
@@ -45,36 +50,40 @@ module SearchHelper
filename
end
+ def search_service
+ @search_service ||= ::SearchService.new(current_user, params)
+ end
+
private
# Autocomplete results for various settings pages
def default_autocomplete
[
- { category: "Settings", label: "User settings", url: profile_path },
- { category: "Settings", label: "SSH Keys", url: profile_keys_path },
- { category: "Settings", label: "Dashboard", url: root_path }
+ { category: "Settings", label: _("User settings"), url: profile_path },
+ { category: "Settings", label: _("SSH Keys"), url: profile_keys_path },
+ { category: "Settings", label: _("Dashboard"), url: root_path }
]
end
# Autocomplete results for settings pages, for admins
def default_autocomplete_admin
[
- { category: "Settings", label: "Admin Section", url: admin_root_path }
+ { category: "Settings", label: _("Admin Section"), url: admin_root_path }
]
end
# Autocomplete results for internal help pages
def help_autocomplete
[
- { category: "Help", label: "API Help", url: help_page_path("api/README") },
- { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") },
- { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") },
- { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") },
- { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") },
- { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") },
- { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") },
- { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") },
- { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") }
+ { category: "Help", label: _("API Help"), url: help_page_path("api/README") },
+ { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") },
+ { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") },
+ { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") },
+ { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") },
+ { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") },
+ { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") },
+ { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") },
+ { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") }
]
end
@@ -84,16 +93,16 @@ module SearchHelper
ref = @ref || @project.repository.root_ref
[
- { category: "In this project", label: "Files", url: project_tree_path(@project, ref) },
- { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) },
- { category: "In this project", label: "Network", url: project_network_path(@project, ref) },
- { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) },
- { category: "In this project", label: "Issues", url: project_issues_path(@project) },
- { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) },
- { category: "In this project", label: "Milestones", url: project_milestones_path(@project) },
- { category: "In this project", label: "Snippets", url: project_snippets_path(@project) },
- { category: "In this project", label: "Members", url: project_project_members_path(@project) },
- { category: "In this project", label: "Wiki", url: project_wikis_path(@project) }
+ { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) },
+ { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) },
+ { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) },
+ { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) },
+ { category: "In this project", label: _("Issues"), url: project_issues_path(@project) },
+ { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) },
+ { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) },
+ { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) },
+ { category: "In this project", label: _("Members"), url: project_project_members_path(@project) },
+ { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) }
]
else
[]
@@ -119,7 +128,7 @@ module SearchHelper
# rubocop: disable CodeReuse/ActiveRecord
def projects_autocomplete(term, limit = 5)
current_user.authorized_projects.order_id_desc.search_by_title(term)
- .sorted_by_stars.non_archived.limit(limit).map do |p|
+ .sorted_by_stars_desc.non_archived.limit(limit).map do |p|
{
category: "Projects",
id: p.id,
@@ -153,7 +162,7 @@ module SearchHelper
opts =
{
id: "filtered-search-#{type}",
- placeholder: 'Search or filter results...',
+ placeholder: _('Search or filter results...'),
data: {
'username-params' => UserSerializer.new.represent(@users)
},
@@ -201,4 +210,14 @@ module SearchHelper
def limited_count(count, limit = 1000)
count > limit ? "#{limit}+" : count
end
+
+ def search_tabs?(tab)
+ return false if Feature.disabled?(:users_search, default_enabled: true)
+
+ if @project
+ project_search_tabs?(:members)
+ else
+ can?(current_user, :read_users_list)
+ end
+ end
end
diff --git a/app/helpers/sidekiq_helper.rb b/app/helpers/sidekiq_helper.rb
index 32bf3526571..6326d98461e 100644
--- a/app/helpers/sidekiq_helper.rb
+++ b/app/helpers/sidekiq_helper.rb
@@ -8,7 +8,7 @@ module SidekiqHelper
(?<state>[DIEKNRSTVWXZNLpsl\+<>/\d]+)\s+
(?<start>.+?)\s+
(?<command>(?:ruby\d+:\s+)?sidekiq.*\].*)
- \z}x
+ \z}x.freeze
def parse_sidekiq_ps(line)
match = line.strip.match(SIDEKIQ_PS_REGEXP)
diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb
index c7d31f3469d..ecb2b2d707b 100644
--- a/app/helpers/snippets_helper.rb
+++ b/app/helpers/snippets_helper.rb
@@ -110,7 +110,7 @@ module SnippetsHelper
def embedded_snippet_raw_button
blob = @snippet.blob
- return if blob.empty? || blob.raw_binary? || blob.stored_externally?
+ return if blob.empty? || blob.binary? || blob.stored_externally?
snippet_raw_url = if @snippet.is_a?(PersonalSnippet)
raw_snippet_url(@snippet)
@@ -130,12 +130,4 @@ module SnippetsHelper
link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer'
end
-
- def public_snippet?
- if @snippet.project_id?
- can?(nil, :read_project_snippet, @snippet)
- else
- can?(nil, :read_personal_snippet, @snippet)
- end
- end
end
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index 67c808b167a..f2d814e6930 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -30,13 +30,20 @@ module SortingHelper
end
def projects_sort_options_hash
+ Feature.enabled?(:project_list_filter_bar) && !current_controller?('admin/projects') ? projects_sort_common_options_hash : old_projects_sort_options_hash
+ end
+
+ # TODO: Simplify these sorting options
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798
+ # https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/11209#note_162234858
+ def old_projects_sort_options_hash
options = {
sort_value_latest_activity => sort_title_latest_activity,
sort_value_name => sort_title_name,
sort_value_oldest_activity => sort_title_oldest_activity,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_created => sort_title_recently_created,
- sort_value_most_stars => sort_title_most_stars
+ sort_value_stars_desc => sort_title_most_stars
}
if current_controller?('admin/projects')
@@ -46,6 +53,41 @@ module SortingHelper
options
end
+ def projects_sort_common_options_hash
+ {
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_name => sort_title_name,
+ sort_value_stars_desc => sort_title_stars
+ }
+ end
+
+ def projects_sort_option_titles
+ {
+ sort_value_latest_activity => sort_title_latest_activity,
+ sort_value_recently_created => sort_title_created_date,
+ sort_value_name => sort_title_name,
+ sort_value_stars_desc => sort_title_stars,
+ sort_value_oldest_activity => sort_title_latest_activity,
+ sort_value_oldest_created => sort_title_created_date,
+ sort_value_name_desc => sort_title_name,
+ sort_value_stars_asc => sort_title_stars
+ }
+ end
+
+ def projects_reverse_sort_options_hash
+ {
+ sort_value_latest_activity => sort_value_oldest_activity,
+ sort_value_recently_created => sort_value_oldest_created,
+ sort_value_name => sort_value_name_desc,
+ sort_value_stars_desc => sort_value_stars_asc,
+ sort_value_oldest_activity => sort_value_latest_activity,
+ sort_value_oldest_created => sort_value_recently_created,
+ sort_value_name_desc => sort_value_name,
+ sort_value_stars_asc => sort_value_stars_desc
+ }
+ end
+
def groups_sort_options_hash
{
sort_value_name => sort_title_name,
@@ -59,7 +101,7 @@ module SortingHelper
def subgroups_sort_options_hash
groups_sort_options_hash.merge(
- sort_value_most_stars => sort_title_most_stars
+ sort_value_stars_desc => sort_title_most_stars
)
end
@@ -128,7 +170,9 @@ module SortingHelper
sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created,
sort_value_recently_updated => sort_title_recently_updated,
- sort_value_oldest_updated => sort_title_oldest_updated
+ sort_value_oldest_updated => sort_title_oldest_updated,
+ sort_value_recently_last_activity => sort_title_recently_last_activity,
+ sort_value_oldest_last_activity => sort_title_oldest_last_activity
}
end
@@ -140,7 +184,9 @@ module SortingHelper
{
sort_value_oldest_created => sort_value_created_date,
sort_value_oldest_updated => sort_value_recently_updated,
- sort_value_milestone_later => sort_value_milestone
+ sort_value_milestone_later => sort_value_milestone,
+ sort_value_due_date_later => sort_value_due_date,
+ sort_value_least_popular => sort_value_popularity
}
end
@@ -149,7 +195,11 @@ module SortingHelper
sort_value_created_date => sort_value_oldest_created,
sort_value_recently_created => sort_value_oldest_created,
sort_value_recently_updated => sort_value_oldest_updated,
- sort_value_milestone => sort_value_milestone_later
+ sort_value_milestone => sort_value_milestone_later,
+ sort_value_due_date => sort_value_due_date_later,
+ sort_value_due_date_soon => sort_value_due_date_later,
+ sort_value_popularity => sort_value_least_popular,
+ sort_value_most_popular => sort_value_least_popular
}.merge(issuable_sort_option_overrides)
end
@@ -159,27 +209,46 @@ module SortingHelper
sort_options_hash[sort_value]
end
+ def issuable_sort_icon_suffix(sort_value)
+ case sort_value
+ when sort_value_milestone, sort_value_due_date, /_asc\z/
+ 'lowest'
+ else
+ 'highest'
+ end
+ end
+
+ # TODO: dedupicate issuable and project sort direction
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/60798
def issuable_sort_direction_button(sort_value)
link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
reverse_sort = issuable_reverse_sort_order_hash[sort_value]
if reverse_sort
- reverse_url = page_filter_path(sort: reverse_sort, label: true)
+ reverse_url = page_filter_path(sort: reverse_sort)
else
reverse_url = '#'
link_class += ' disabled'
end
- link_to(reverse_url, type: 'button', class: link_class, title: 'Sort direction') do
- icon_suffix =
- case sort_value
- when sort_value_milestone, sort_value_due_date, /_asc\z/
- 'lowest'
- else
- 'highest'
- end
+ link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do
+ sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16)
+ end
+ end
+
+ def project_sort_direction_button(sort_value)
+ link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
+ reverse_sort = projects_reverse_sort_options_hash[sort_value]
- sprite_icon("sort-#{icon_suffix}", size: 16)
+ if reverse_sort
+ reverse_url = filter_projects_path(sort: reverse_sort)
+ else
+ reverse_url = '#'
+ link_class += ' disabled'
+ end
+
+ link_to(reverse_url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do
+ sprite_icon("sort-#{issuable_sort_icon_suffix(sort_value)}", size: 16)
end
end
@@ -233,7 +302,7 @@ module SortingHelper
end
def sort_title_milestone
- s_('SortOptions|Milestone')
+ s_('SortOptions|Milestone due date')
end
def sort_title_milestone_later
@@ -316,6 +385,18 @@ module SortingHelper
s_('SortOptions|Most stars')
end
+ def sort_title_stars
+ s_('SortOptions|Stars')
+ end
+
+ def sort_title_oldest_last_activity
+ s_('SortOptions|Oldest last activity')
+ end
+
+ def sort_title_recently_last_activity
+ s_('SortOptions|Recent last activity')
+ end
+
# Values.
def sort_value_access_level_asc
'access_level_asc'
@@ -409,6 +490,14 @@ module SortingHelper
'popularity'
end
+ def sort_value_most_popular
+ 'popularity_desc'
+ end
+
+ def sort_value_least_popular
+ 'popularity_asc'
+ end
+
def sort_value_priority
'priority'
end
@@ -441,7 +530,19 @@ module SortingHelper
'contacted_asc'
end
- def sort_value_most_stars
+ def sort_value_stars_desc
'stars_desc'
end
+
+ def sort_value_stars_asc
+ 'stars_asc'
+ end
+
+ def sort_value_oldest_last_activity
+ 'last_activity_on_asc'
+ end
+
+ def sort_value_recently_last_activity
+ 'last_activity_on_desc'
+ end
end
diff --git a/app/helpers/storage_helper.rb b/app/helpers/storage_helper.rb
index be8761db562..e80b3f2b54a 100644
--- a/app/helpers/storage_helper.rb
+++ b/app/helpers/storage_helper.rb
@@ -2,8 +2,20 @@
module StorageHelper
def storage_counter(size_in_bytes)
+ return s_('StorageSize|Unknown') unless size_in_bytes
+
precision = size_in_bytes < 1.megabyte ? 0 : 1
number_to_human_size(size_in_bytes, delimiter: ',', precision: precision, significant: false)
end
+
+ def storage_counters_details(statistics)
+ counters = {
+ counter_repositories: storage_counter(statistics.repository_size),
+ counter_build_artifacts: storage_counter(statistics.build_artifacts_size),
+ counter_lfs_objects: storage_counter(statistics.lfs_objects_size)
+ }
+
+ _("%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS") % counters
+ end
end
diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb
index e2879bfdcf1..4690b6ffbe1 100644
--- a/app/helpers/tree_helper.rb
+++ b/app/helpers/tree_helper.rb
@@ -86,17 +86,17 @@ module TreeHelper
end
def edit_in_new_fork_notice_now
- "You're not allowed to make changes to this project directly." +
- " A fork of this project is being created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project is being created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice
- "You're not allowed to make changes to this project directly." +
- " A fork of this project has been created that you can make changes in, so you can submit a merge request."
+ _("You're not allowed to make changes to this project directly. "\
+ "A fork of this project has been created that you can make changes in, so you can submit a merge request.")
end
def edit_in_new_fork_notice_action(action)
- edit_in_new_fork_notice + " Try to #{action} this file again."
+ edit_in_new_fork_notice + _(" Try to %{action} this file again.") % { action: action }
end
def commit_in_fork_help
@@ -136,18 +136,9 @@ module TreeHelper
end
# returns the relative path of the first subdir that doesn't have only one directory descendant
- # rubocop: disable CodeReuse/ActiveRecord
def flatten_tree(root_path, tree)
- return tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '') if tree.flat_path.present?
-
- subtree = Gitlab::Git::Tree.where(@repository, @commit.id, tree.path)
- if subtree.count == 1 && subtree.first.dir?
- return tree_join(tree.name, flatten_tree(root_path, subtree.first))
- else
- return tree.name
- end
+ tree.flat_path.sub(%r{\A#{Regexp.escape(root_path)}/}, '')
end
- # rubocop: enable CodeReuse/ActiveRecord
def selected_branch
@branch_name || tree_edit_branch
diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb
index 4aba48061ba..5d658d35107 100644
--- a/app/helpers/user_callouts_helper.rb
+++ b/app/helpers/user_callouts_helper.rb
@@ -13,6 +13,13 @@ module UserCalloutsHelper
!user_dismissed?(GCP_SIGNUP_OFFER)
end
+ def render_flash_user_callout(flash_type, message, feature_name)
+ render 'shared/flash_user_callout', flash_type: flash_type, message: message, feature_name: feature_name
+ end
+
+ def render_dashboard_gold_trial(user)
+ end
+
private
def user_dismissed?(feature_name)
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index bde9ca0cbf2..73ca17c6605 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -8,7 +8,7 @@ module UsersHelper
end
def user_email_help_text(user)
- return 'We also use email for avatar detection if no avatar is uploaded.' unless user.unconfirmed_email.present?
+ return 'We also use email for avatar detection if no avatar is uploaded' unless user.unconfirmed_email.present?
confirmation_link = link_to 'Resend confirmation e-mail', user_confirmation_path(user: { email: @user.unconfirmed_email }), method: :post
@@ -74,6 +74,15 @@ module UsersHelper
Gitlab.config.gitlab.impersonation_enabled
end
+ def user_badges_in_admin_section(user)
+ [].tap do |badges|
+ badges << { text: s_('AdminUsers|Blocked'), variant: 'danger' } if user.blocked?
+ badges << { text: s_('AdminUsers|Admin'), variant: 'success' } if user.admin?
+ badges << { text: s_('AdminUsers|External'), variant: 'secondary' } if user.external?
+ badges << { text: s_("AdminUsers|It's you!"), variant: nil } if current_user == user
+ end
+ end
+
private
def get_profile_tabs
diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb
index 712f0f808dd..9deb783d289 100644
--- a/app/helpers/visibility_level_helper.rb
+++ b/app/helpers/visibility_level_helper.rb
@@ -42,11 +42,11 @@ module VisibilityLevelHelper
def group_visibility_level_description(level)
case level
when Gitlab::VisibilityLevel::PRIVATE
- "The group and its projects can only be viewed by members."
+ _("The group and its projects can only be viewed by members.")
when Gitlab::VisibilityLevel::INTERNAL
- "The group and any internal projects can be viewed by any logged in user."
+ _("The group and any internal projects can be viewed by any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The group and any public projects can be viewed without any authentication."
+ _("The group and any public projects can be viewed without any authentication.")
end
end
@@ -54,20 +54,20 @@ module VisibilityLevelHelper
case level
when Gitlab::VisibilityLevel::PRIVATE
if snippet.is_a? ProjectSnippet
- "The snippet is visible only to project members."
+ _("The snippet is visible only to project members.")
else
- "The snippet is visible only to me."
+ _("The snippet is visible only to me.")
end
when Gitlab::VisibilityLevel::INTERNAL
- "The snippet is visible to any logged in user."
+ _("The snippet is visible to any logged in user.")
when Gitlab::VisibilityLevel::PUBLIC
- "The snippet can be accessed without any authentication."
+ _("The snippet can be accessed without any authentication.")
end
end
def restricted_visibility_level_description(level)
level_name = Gitlab::VisibilityLevel.level_name(level)
- "#{level_name.capitalize} visibility has been restricted by the administrator."
+ _("%{level_name} visibility has been restricted by the administrator.") % { level_name: level_name.capitalize }
end
def disallowed_visibility_level_description(level, form_model)
diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb
index 647f34e57ed..edd48f82729 100644
--- a/app/helpers/wiki_helper.rb
+++ b/app/helpers/wiki_helper.rb
@@ -47,4 +47,24 @@ module WikiHelper
def wiki_attachment_upload_url
expose_url(api_v4_projects_wikis_attachments_path(id: @project.id))
end
+
+ def wiki_sort_controls(project, sort, direction)
+ sort ||= ProjectWiki::TITLE_ORDER
+ link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort'
+ reversed_direction = direction == 'desc' ? 'asc' : 'desc'
+ icon_class = direction == 'desc' ? 'highest' : 'lowest'
+
+ link_to(project_wikis_pages_path(project, sort: sort, direction: reversed_direction),
+ type: 'button', class: link_class, title: _('Sort direction')) do
+ sprite_icon("sort-#{icon_class}", size: 16)
+ end
+ end
+
+ def wiki_sort_title(key)
+ if key == ProjectWiki::CREATED_AT_ORDER
+ s_("Wiki|Created date")
+ else
+ s_("Wiki|Title")
+ end
+ end
end
diff --git a/app/helpers/workhorse_helper.rb b/app/helpers/workhorse_helper.rb
index e9fc39e451b..bb5b1555dc4 100644
--- a/app/helpers/workhorse_helper.rb
+++ b/app/helpers/workhorse_helper.rb
@@ -7,8 +7,7 @@ module WorkhorseHelper
def send_git_blob(repository, blob, inline: true)
headers.store(*Gitlab::Workhorse.send_git_blob(repository, blob))
- headers['Content-Disposition'] = content_disposition(blob, inline)
- headers['Content-Type'] = safe_content_type(blob)
+ headers['Content-Disposition'] = inline ? 'inline' : 'attachment'
# If enabled, this will override the values set above
workhorse_set_content_type!
@@ -47,6 +46,6 @@ module WorkhorseHelper
end
def workhorse_set_content_type!
- headers[Gitlab::Workhorse::DETECT_HEADER] = "true" if Feature.enabled?(:workhorse_set_content_type)
+ headers[Gitlab::Workhorse::DETECT_HEADER] = "true"
end
end
diff --git a/app/mailers/abuse_report_mailer.rb b/app/mailers/abuse_report_mailer.rb
index e032f568913..e0aa66e6de3 100644
--- a/app/mailers/abuse_report_mailer.rb
+++ b/app/mailers/abuse_report_mailer.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class AbuseReportMailer < BaseMailer
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def notify(abuse_report_id)
return unless deliverable?
diff --git a/app/mailers/devise_mailer.rb b/app/mailers/devise_mailer.rb
index 7aa75ee30e6..cbaf53fced1 100644
--- a/app/mailers/devise_mailer.rb
+++ b/app/mailers/devise_mailer.rb
@@ -7,6 +7,7 @@ class DeviseMailer < Devise::Mailer
layout 'mailer/devise'
helper EmailsHelper
+ helper ApplicationHelper
protected
diff --git a/app/mailers/email_rejection_mailer.rb b/app/mailers/email_rejection_mailer.rb
index 45fc5a6c383..d743533b1bc 100644
--- a/app/mailers/email_rejection_mailer.rb
+++ b/app/mailers/email_rejection_mailer.rb
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class EmailRejectionMailer < BaseMailer
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def rejection(reason, original_raw, can_retry = false)
@reason = reason
@original_message = Mail::Message.new(original_raw)
diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb
index 93b51fb1774..2b046d17122 100644
--- a/app/mailers/emails/issues.rb
+++ b/app/mailers/emails/issues.rb
@@ -30,8 +30,8 @@ module Emails
end
# rubocop: enable CodeReuse/ActiveRecord
- def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason = nil)
- setup_issue_mail(issue_id, recipient_id)
+ def closed_issue_email(recipient_id, issue_id, updated_by_user_id, reason: nil, closed_via: nil)
+ setup_issue_mail(issue_id, recipient_id, closed_via: closed_via)
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
@@ -56,7 +56,9 @@ module Emails
@milestone = milestone
@milestone_url = milestone_url(@milestone)
- mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id, reason).merge({
+ template_name: 'changed_milestone_email'
+ }))
end
def issue_status_changed_email(recipient_id, issue_id, status, updated_by_user_id, reason = nil)
@@ -72,15 +74,28 @@ module Emails
@new_issue = new_issue
@new_project = new_issue.project
+ @can_access_project = recipient.can?(:read_project, @new_project)
mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id, reason))
end
+ def import_issues_csv_email(user_id, project_id, results)
+ @user = User.find(user_id)
+ @project = Project.find(project_id)
+ @results = results
+
+ mail(to: @user.notification_email, subject: subject('Imported issues')) do |format|
+ format.html { render layout: 'mailer' }
+ format.text { render layout: 'mailer' }
+ end
+ end
+
private
- def setup_issue_mail(issue_id, recipient_id)
+ def setup_issue_mail(issue_id, recipient_id, closed_via: nil)
@issue = Issue.find(issue_id)
@project = @issue.project
@target_url = project_issue_url(@project, @issue)
+ @closed_via = closed_via
@sent_notification = SentNotification.record(@issue, recipient_id, reply_key)
end
diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb
index 6524d0c2087..fc6ed695675 100644
--- a/app/mailers/emails/merge_requests.rb
+++ b/app/mailers/emails/merge_requests.rb
@@ -24,10 +24,12 @@ module Emails
end
# rubocop: disable CodeReuse/ActiveRecord
- def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id, reason = nil)
+ def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_ids, updated_by_user_id, reason = nil)
setup_merge_request_mail(merge_request_id, recipient_id)
- @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id
+ @previous_assignees = []
+ @previous_assignees = User.where(id: previous_assignee_ids) if previous_assignee_ids.any?
+
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -51,17 +53,19 @@ module Emails
@milestone = milestone
@milestone_url = milestone_url(@milestone)
- mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
+ mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason).merge({
+ template_name: 'changed_milestone_email'
+ }))
end
- def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
@updated_by = User.find(updated_by_user_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
end
- def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason = nil)
+ def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, reason: nil, closed_via: nil)
setup_merge_request_mail(merge_request_id, recipient_id)
mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id, reason))
diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb
index 31e183640ad..fb57c0da34d 100644
--- a/app/mailers/emails/pipelines.rb
+++ b/app/mailers/emails/pipelines.rb
@@ -15,7 +15,7 @@ module Emails
def pipeline_mail(pipeline, recipients, status)
@project = pipeline.project
@pipeline = pipeline
- @merge_request = pipeline.merge_requests.first
+ @merge_request = pipeline.merge_requests_as_head_pipeline.first
add_headers
# We use bcc here because we don't want to generate this emails for a
diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb
index 15710bee4d4..0b740809f30 100644
--- a/app/mailers/notify.rb
+++ b/app/mailers/notify.rb
@@ -4,6 +4,7 @@ class Notify < BaseMailer
include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper
include EmailsHelper
+ include IssuablesHelper
include Emails::Issues
include Emails::MergeRequests
@@ -16,6 +17,7 @@ class Notify < BaseMailer
include Emails::AutoDevops
include Emails::RemoteMirrors
+ helper MilestonesHelper
helper MergeRequestsHelper
helper DiffHelper
helper BlobHelper
@@ -23,6 +25,7 @@ class Notify < BaseMailer
helper MembersHelper
helper AvatarsHelper
helper GitlabRoutingHelper
+ helper IssuablesHelper
def test_email(recipient_email, subject, body)
mail(to: recipient_email,
diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb
index 2ac4610967d..80e0a17c312 100644
--- a/app/mailers/previews/notify_preview.rb
+++ b/app/mailers/previews/notify_preview.rb
@@ -76,6 +76,10 @@ class NotifyPreview < ActionMailer::Preview
Notify.changed_milestone_issue_email(user.id, issue.id, milestone, user.id)
end
+ def import_issues_csv_email
+ Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true })
+ end
+
def closed_merge_request_email
Notify.closed_merge_request_email(user.id, issue.id, user.id).message
end
diff --git a/app/mailers/repository_check_mailer.rb b/app/mailers/repository_check_mailer.rb
index 145169be8a6..a24d3476d0e 100644
--- a/app/mailers/repository_check_mailer.rb
+++ b/app/mailers/repository_check_mailer.rb
@@ -2,6 +2,10 @@
class RepositoryCheckMailer < BaseMailer
# rubocop: disable CodeReuse/ActiveRecord
+ layout 'empty_mailer'
+
+ helper EmailsHelper
+
def notify(failed_count)
@message =
if failed_count == 1
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb
index 1b78fd04ebb..a3a1748142f 100644
--- a/app/models/abuse_report.rb
+++ b/app/models/abuse_report.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AbuseReport < ActiveRecord::Base
+class AbuseReport < ApplicationRecord
include CacheMarkdownField
cache_markdown_field :message, pipeline: :single_line
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 0d9c6a4a1f0..f355b02c428 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -5,7 +5,8 @@ class ActiveSession
attr_accessor :created_at, :updated_at,
:session_id, :ip_address,
- :browser, :os, :device_name, :device_type
+ :browser, :os, :device_name, :device_type,
+ :is_impersonated
def current?(session)
return false if session_id.nil? || session.id.nil?
@@ -31,7 +32,8 @@ class ActiveSession
device_type: client.device_type,
created_at: user.current_sign_in_at || timestamp,
updated_at: timestamp,
- session_id: session_id
+ session_id: session_id,
+ is_impersonated: request.session[:impersonator_id].present?
)
redis.pipelined do
@@ -51,7 +53,7 @@ class ActiveSession
def self.list(user)
Gitlab::Redis::SharedState.with do |redis|
- cleaned_up_lookup_entries(redis, user.id).map do |entry|
+ cleaned_up_lookup_entries(redis, user).map do |entry|
# rubocop:disable Security/MarshalLoad
Marshal.load(entry)
# rubocop:enable Security/MarshalLoad
@@ -76,7 +78,7 @@ class ActiveSession
def self.cleanup(user)
Gitlab::Redis::SharedState.with do |redis|
- cleaned_up_lookup_entries(redis, user.id)
+ cleaned_up_lookup_entries(redis, user)
end
end
@@ -88,25 +90,52 @@ class ActiveSession
"#{Gitlab::Redis::SharedState::USER_SESSIONS_LOOKUP_NAMESPACE}:#{user_id}"
end
- def self.cleaned_up_lookup_entries(redis, user_id)
- lookup_key = lookup_key_name(user_id)
+ def self.list_sessions(user)
+ sessions_from_ids(session_ids_for_user(user))
+ end
- session_ids = redis.smembers(lookup_key)
+ def self.session_ids_for_user(user)
+ Gitlab::Redis::SharedState.with do |redis|
+ redis.smembers(lookup_key_name(user.id))
+ end
+ end
- entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
- return [] if entry_keys.empty?
+ def self.sessions_from_ids(session_ids)
+ return [] if session_ids.empty?
- entries = redis.mget(entry_keys)
+ Gitlab::Redis::SharedState.with do |redis|
+ session_keys = session_ids.map { |session_id| "#{Gitlab::Redis::SharedState::SESSION_NAMESPACE}:#{session_id}" }
- session_ids_and_entries = session_ids.zip(entries)
+ redis.mget(session_keys).compact.map do |raw_session|
+ # rubocop:disable Security/MarshalLoad
+ Marshal.load(raw_session)
+ # rubocop:enable Security/MarshalLoad
+ end
+ end
+ end
+
+ def self.raw_active_session_entries(session_ids, user_id)
+ return [] if session_ids.empty?
+
+ Gitlab::Redis::SharedState.with do |redis|
+ entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) }
+
+ redis.mget(entry_keys)
+ end
+ end
+
+ def self.cleaned_up_lookup_entries(redis, user)
+ session_ids = session_ids_for_user(user)
+ entries = raw_active_session_entries(session_ids, user.id)
# remove expired keys.
# only the single key entries are automatically expired by redis, the
# lookup entries in the set need to be removed manually.
+ session_ids_and_entries = session_ids.zip(entries)
session_ids_and_entries.reject { |_session_id, entry| entry }.each do |session_id, _entry|
- redis.srem(lookup_key, session_id)
+ redis.srem(lookup_key_name(user.id), session_id)
end
- session_ids_and_entries.select { |_session_id, entry| entry }.map { |_session_id, entry| entry }
+ entries.compact
end
end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index bffba3e13fa..2815a117f7f 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Appearance < ActiveRecord::Base
+class Appearance < ApplicationRecord
include CacheableAttributes
include CacheMarkdownField
include ObjectStorage::BackgroundMove
@@ -8,12 +8,20 @@ class Appearance < ActiveRecord::Base
cache_markdown_field :description
cache_markdown_field :new_project_guidelines
+ cache_markdown_field :header_message, pipeline: :broadcast_message
+ cache_markdown_field :footer_message, pipeline: :broadcast_message
validates :logo, file_size: { maximum: 1.megabyte }
validates :header_logo, file_size: { maximum: 1.megabyte }
+ validates :message_background_color, allow_blank: true, color: true
+ validates :message_font_color, allow_blank: true, color: true
validate :single_appearance_row, on: :create
+ default_value_for :message_background_color, '#E75E40'
+ default_value_for :message_font_color, '#FFFFFF'
+ default_value_for :email_header_and_footer_enabled, false
+
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
mount_uploader :favicon, FaviconUploader
@@ -28,4 +36,44 @@ class Appearance < ActiveRecord::Base
errors.add(:single_appearance_row, 'Only 1 appearances row can exist')
end
end
+
+ def logo_path
+ logo_system_path(logo, 'logo')
+ end
+
+ def header_logo_path
+ logo_system_path(header_logo, 'header_logo')
+ end
+
+ def favicon_path
+ logo_system_path(favicon, 'favicon')
+ end
+
+ def show_header?
+ header_message.present?
+ end
+
+ def show_footer?
+ footer_message.present?
+ end
+
+ private
+
+ def logo_system_path(logo, mount_type)
+ # Legacy attachments may not have have an associated Upload record,
+ # so fallback to the AttachmentUploader#url if this is the
+ # case. AttachmentUploader#path doesn't work because for a local
+ # file, this is an absolute path to the file.
+ return logo&.url unless logo&.upload
+
+ # If we're using a CDN, we need to use the full URL
+ asset_host = ActionController::Base.asset_host
+ local_path = Gitlab::Routing.url_helpers.appearance_upload_path(
+ filename: logo.filename,
+ id: logo.upload.model_id,
+ model: 'appearance',
+ mounted_as: mount_type)
+
+ Gitlab::Utils.append_path(asset_host, local_path)
+ end
end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 71fbba5b328..0979d03f6e6 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -2,4 +2,47 @@
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
+
+ alias_method :reset, :reload
+
+ def self.id_in(ids)
+ where(id: ids)
+ end
+
+ def self.id_not_in(ids)
+ where.not(id: ids)
+ end
+
+ def self.pluck_primary_key
+ where(nil).pluck(self.primary_key)
+ end
+
+ def self.safe_ensure_unique(retries: 0)
+ transaction(requires_new: true) do
+ yield
+ end
+ rescue ActiveRecord::RecordNotUnique
+ if retries > 0
+ retries -= 1
+ retry
+ end
+
+ false
+ end
+
+ def self.safe_find_or_create_by!(*args)
+ safe_find_or_create_by(*args).tap do |record|
+ record.validate! unless record.persisted?
+ end
+ end
+
+ def self.safe_find_or_create_by(*args)
+ safe_ensure_unique(retries: 1) do
+ find_or_create_by(*args)
+ end
+ end
+
+ def self.underscore
+ Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore }
+ end
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 4319db42019..bbe2d2e8fd4 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -1,26 +1,20 @@
# frozen_string_literal: true
-class ApplicationSetting < ActiveRecord::Base
+class ApplicationSetting < ApplicationRecord
include CacheableAttributes
include CacheMarkdownField
include TokenAuthenticatable
include IgnorableColumn
include ChronicDurationAttribute
- add_authentication_token_field :runners_registration_token, encrypted: true, fallback: true
+ add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
add_authentication_token_field :health_check_access_token
- DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
- | # or
- \s # any whitespace character
- | # or
- [\r\n] # any number of newline characters
- }x
-
- # Setting a key restriction to `-1` means that all keys of this type are
- # forbidden.
- FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
- SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+ # Include here so it can override methods from
+ # `add_authentication_token_field`
+ # We don't prepend for now because otherwise we'll need to
+ # fix a lot of tests using allow_any_instance_of
+ include ApplicationSettingImplementation
serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize
serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize
@@ -42,8 +36,6 @@ class ApplicationSetting < ActiveRecord::Base
cache_markdown_field :shared_runners_text, pipeline: :plain_markdown
cache_markdown_field :after_sign_up_text
- attr_accessor :domain_whitelist_raw, :domain_blacklist_raw
-
default_value_for :id, 1
chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds
@@ -56,20 +48,20 @@ class ApplicationSetting < ActiveRecord::Base
validates :home_page_url,
allow_blank: true,
- url: true,
+ addressable_url: true,
if: :home_page_url_column_exists?
validates :help_page_support_url,
allow_blank: true,
- url: true,
+ addressable_url: true,
if: :help_page_support_url_column_exists?
validates :after_sign_out_path,
allow_blank: true,
- url: true
+ addressable_url: true
validates :admin_notification_email,
- email: true,
+ devise_email: true,
allow_blank: true
validates :two_factor_grace_period,
@@ -193,6 +185,10 @@ class ApplicationSetting < ActiveRecord::Base
allow_nil: true,
numericality: { only_integer: true, greater_than_or_equal_to: 1.day.seconds }
+ validates :local_markdown_version,
+ allow_nil: true,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than: 65536 }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -202,7 +198,7 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :restricted_visibility_levels do |record, attr, value|
value&.each do |level|
unless Gitlab::VisibilityLevel.options.value?(level)
- record.errors.add(attr, "'#{level}' is not a valid visibility level")
+ record.errors.add(attr, _("'%{level}' is not a valid visibility level") % { level: level })
end
end
end
@@ -210,13 +206,63 @@ class ApplicationSetting < ActiveRecord::Base
validates_each :import_sources do |record, attr, value|
value&.each do |source|
unless Gitlab::ImportSources.options.value?(source)
- record.errors.add(attr, "'#{source}' is not a import source")
+ record.errors.add(attr, _("'%{source}' is not a import source") % { source: source })
end
end
end
validate :terms_exist, if: :enforce_terms?
+ validates :external_authorization_service_default_label,
+ presence: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_url,
+ addressable_url: true, allow_blank: true,
+ if: :external_authorization_service_enabled
+
+ validates :external_authorization_service_timeout,
+ numericality: { greater_than: 0, less_than_or_equal_to: 10 },
+ if: :external_authorization_service_enabled
+
+ validates :external_auth_client_key,
+ presence: true,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ validates :lets_encrypt_notification_email,
+ devise_email: true,
+ format: { without: /@example\.(com|org|net)\z/,
+ message: N_("Let's Encrypt does not accept emails on example.com") },
+ allow_blank: true
+
+ validates :lets_encrypt_notification_email,
+ presence: true,
+ if: :lets_encrypt_terms_of_service_accepted?
+
+ validates_with X509CertificateCredentialsValidator,
+ certificate: :external_auth_client_cert,
+ pkey: :external_auth_client_key,
+ pass: :external_auth_client_key_pass,
+ if: -> (setting) { setting.external_auth_client_cert.present? }
+
+ attr_encrypted :external_auth_client_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ attr_encrypted :external_auth_client_key_pass,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
+ attr_encrypted :lets_encrypt_private_key,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm',
+ encode: true
+
before_validation :ensure_uuid!
before_validation :strip_sentry_values
@@ -228,262 +274,12 @@ class ApplicationSetting < ActiveRecord::Base
end
after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') }
- def self.defaults
- {
- after_sign_up_text: nil,
- akismet_enabled: false,
- allow_local_requests_from_hooks_and_services: false,
- authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
- container_registry_token_expire_delay: 5,
- default_artifacts_expire_in: '30 days',
- default_branch_protection: Settings.gitlab['default_branch_protection'],
- default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- default_projects_limit: Settings.gitlab['default_projects_limit'],
- default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
- disabled_oauth_sign_in_sources: [],
- domain_whitelist: Settings.gitlab['domain_whitelist'],
- dsa_key_restriction: 0,
- ecdsa_key_restriction: 0,
- ed25519_key_restriction: 0,
- gitaly_timeout_default: 55,
- gitaly_timeout_fast: 10,
- gitaly_timeout_medium: 30,
- gravatar_enabled: Settings.gravatar['enabled'],
- help_page_hide_commercial_content: false,
- help_page_text: nil,
- hide_third_party_offers: false,
- housekeeping_bitmaps_enabled: true,
- housekeeping_enabled: true,
- housekeeping_full_repack_period: 50,
- housekeeping_gc_period: 200,
- housekeeping_incremental_repack_period: 10,
- import_sources: Settings.gitlab['import_sources'],
- max_artifacts_size: Settings.artifacts['max_size'],
- max_attachment_size: Settings.gitlab['max_attachment_size'],
- mirror_available: true,
- password_authentication_enabled_for_git: true,
- password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
- performance_bar_allowed_group_id: nil,
- rsa_key_restriction: 0,
- plantuml_enabled: false,
- plantuml_url: nil,
- polling_interval_multiplier: 1,
- project_export_enabled: true,
- recaptcha_enabled: false,
- repository_checks_enabled: true,
- repository_storages: ['default'],
- require_two_factor_authentication: false,
- restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
- session_expire_delay: Settings.gitlab['session_expire_delay'],
- send_user_confirmation_email: false,
- shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
- shared_runners_text: nil,
- sign_in_text: nil,
- signup_enabled: Settings.gitlab['signup_enabled'],
- terminal_max_session_time: 0,
- throttle_authenticated_api_enabled: false,
- throttle_authenticated_api_period_in_seconds: 3600,
- throttle_authenticated_api_requests_per_period: 7200,
- throttle_authenticated_web_enabled: false,
- throttle_authenticated_web_period_in_seconds: 3600,
- throttle_authenticated_web_requests_per_period: 7200,
- throttle_unauthenticated_enabled: false,
- throttle_unauthenticated_period_in_seconds: 3600,
- throttle_unauthenticated_requests_per_period: 3600,
- two_factor_grace_period: 48,
- unique_ips_limit_enabled: false,
- unique_ips_limit_per_user: 10,
- unique_ips_limit_time_window: 3600,
- usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
- instance_statistics_visibility_private: false,
- user_default_external: false,
- user_default_internal_regex: nil,
- user_show_add_ssh_key_message: true,
- usage_stats_set_by_user_id: nil,
- diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
- commit_email_hostname: default_commit_email_hostname
- }
- end
-
- def self.default_commit_email_hostname
- "users.noreply.#{Gitlab.config.gitlab.host}"
- end
-
def self.create_from_defaults
- create(defaults)
- end
-
- def self.human_attribute_name(attr, _options = {})
- if attr == :default_artifacts_expire_in
- 'Default artifacts expiration'
- else
+ transaction(requires_new: true) do
super
end
- end
-
- def home_page_url_column_exists?
- ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url)
- end
-
- def help_page_support_url_column_exists?
- ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
- end
-
- def disabled_oauth_sign_in_sources=(sources)
- sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s)
- super(sources)
- end
-
- def domain_whitelist_raw
- self.domain_whitelist&.join("\n")
- end
-
- def domain_blacklist_raw
- self.domain_blacklist&.join("\n")
- end
-
- def domain_whitelist_raw=(values)
- self.domain_whitelist = []
- self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR)
- self.domain_whitelist.reject! { |d| d.empty? }
- self.domain_whitelist
- end
-
- def domain_blacklist_raw=(values)
- self.domain_blacklist = []
- self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR)
- self.domain_blacklist.reject! { |d| d.empty? }
- self.domain_blacklist
- end
-
- def domain_blacklist_file=(file)
- self.domain_blacklist_raw = file.read
- end
-
- def repository_storages
- Array(read_attribute(:repository_storages))
- end
-
- def commit_email_hostname
- super.presence || self.class.default_commit_email_hostname
- end
-
- def default_project_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def default_snippet_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def default_group_visibility=(level)
- super(Gitlab::VisibilityLevel.level_value(level))
- end
-
- def restricted_visibility_levels=(levels)
- super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) })
- end
-
- def strip_sentry_values
- sentry_dsn.strip! if sentry_dsn.present?
- clientside_sentry_dsn.strip! if clientside_sentry_dsn.present?
- end
-
- def performance_bar_allowed_group
- Group.find_by_id(performance_bar_allowed_group_id)
- end
-
- # Return true if the Performance Bar is enabled for a given group
- def performance_bar_enabled
- performance_bar_allowed_group_id.present?
- end
-
- # Choose one of the available repository storage options. Currently all have
- # equal weighting.
- def pick_repository_storage
- repository_storages.sample
- end
-
- def runners_registration_token
- ensure_runners_registration_token!
- end
-
- def health_check_access_token
- ensure_health_check_access_token!
- end
-
- def usage_ping_can_be_configured?
- Settings.gitlab.usage_ping_enabled
- end
-
- def usage_ping_enabled
- usage_ping_can_be_configured? && super
- end
-
- def allowed_key_types
- SUPPORTED_KEY_TYPES.select do |type|
- key_restriction_for(type) != FORBIDDEN_KEY_VALUE
- end
- end
-
- def key_restriction_for(type)
- attr_name = "#{type}_key_restriction"
-
- has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def allow_signup?
- signup_enabled? && password_authentication_enabled_for_web?
- end
-
- def password_authentication_enabled?
- password_authentication_enabled_for_web? || password_authentication_enabled_for_git?
- end
-
- def user_default_internal_regex_enabled?
- user_default_external? && user_default_internal_regex.present?
- end
-
- def user_default_internal_regex_instance
- Regexp.new(user_default_internal_regex, Regexp::IGNORECASE)
- end
-
- delegate :terms, to: :latest_terms, allow_nil: true
- def latest_terms
- @latest_terms ||= Term.latest
- end
-
- def reset_memoized_terms
- @latest_terms = nil
- latest_terms
- end
-
- def archive_builds_older_than
- archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
- end
-
- private
-
- def ensure_uuid!
- return if uuid?
-
- self.uuid = SecureRandom.uuid
- end
-
- def check_repository_storages
- invalid = repository_storages - Gitlab.config.repositories.storages.keys
- errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless
- invalid.empty?
- end
-
- def terms_exist
- return unless enforce_terms?
-
- errors.add(:terms, "You need to set terms to be enforced") unless terms.present?
- end
-
- def expire_performance_bar_allowed_user_ids_cache
- Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ rescue ActiveRecord::RecordNotUnique
+ # We already have an ApplicationSetting record, so just return it.
+ current_without_cache
end
end
diff --git a/app/models/application_setting/term.rb b/app/models/application_setting/term.rb
index 498701ba22b..723540c9b91 100644
--- a/app/models/application_setting/term.rb
+++ b/app/models/application_setting/term.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class ApplicationSetting
- class Term < ActiveRecord::Base
+ class Term < ApplicationRecord
include CacheMarkdownField
has_many :term_agreements
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
new file mode 100644
index 00000000000..e51619b0f9c
--- /dev/null
+++ b/app/models/application_setting_implementation.rb
@@ -0,0 +1,298 @@
+# frozen_string_literal: true
+
+module ApplicationSettingImplementation
+ extend ActiveSupport::Concern
+
+ DOMAIN_LIST_SEPARATOR = %r{\s*[,;]\s* # comma or semicolon, optionally surrounded by whitespace
+ | # or
+ \s # any whitespace character
+ | # or
+ [\r\n] # any number of newline characters
+ }x.freeze
+
+ # Setting a key restriction to `-1` means that all keys of this type are
+ # forbidden.
+ FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN
+ SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze
+
+ class_methods do
+ def defaults
+ {
+ after_sign_up_text: nil,
+ akismet_enabled: false,
+ allow_local_requests_from_hooks_and_services: false,
+ authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
+ container_registry_token_expire_delay: 5,
+ default_artifacts_expire_in: '30 days',
+ default_branch_protection: Settings.gitlab['default_branch_protection'],
+ default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ default_project_creation: Settings.gitlab['default_project_creation'],
+ default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ default_projects_limit: Settings.gitlab['default_projects_limit'],
+ default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'],
+ disabled_oauth_sign_in_sources: [],
+ domain_whitelist: Settings.gitlab['domain_whitelist'],
+ dsa_key_restriction: 0,
+ ecdsa_key_restriction: 0,
+ ed25519_key_restriction: 0,
+ first_day_of_week: 0,
+ gitaly_timeout_default: 55,
+ gitaly_timeout_fast: 10,
+ gitaly_timeout_medium: 30,
+ gravatar_enabled: Settings.gravatar['enabled'],
+ help_page_hide_commercial_content: false,
+ help_page_text: nil,
+ hide_third_party_offers: false,
+ housekeeping_bitmaps_enabled: true,
+ housekeeping_enabled: true,
+ housekeeping_full_repack_period: 50,
+ housekeeping_gc_period: 200,
+ housekeeping_incremental_repack_period: 10,
+ import_sources: Settings.gitlab['import_sources'],
+ max_artifacts_size: Settings.artifacts['max_size'],
+ max_attachment_size: Settings.gitlab['max_attachment_size'],
+ mirror_available: true,
+ password_authentication_enabled_for_git: true,
+ password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'],
+ performance_bar_allowed_group_id: nil,
+ rsa_key_restriction: 0,
+ plantuml_enabled: false,
+ plantuml_url: nil,
+ polling_interval_multiplier: 1,
+ project_export_enabled: true,
+ recaptcha_enabled: false,
+ repository_checks_enabled: true,
+ repository_storages: ['default'],
+ require_two_factor_authentication: false,
+ restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'],
+ session_expire_delay: Settings.gitlab['session_expire_delay'],
+ send_user_confirmation_email: false,
+ shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'],
+ shared_runners_text: nil,
+ sign_in_text: nil,
+ signup_enabled: Settings.gitlab['signup_enabled'],
+ terminal_max_session_time: 0,
+ throttle_authenticated_api_enabled: false,
+ throttle_authenticated_api_period_in_seconds: 3600,
+ throttle_authenticated_api_requests_per_period: 7200,
+ throttle_authenticated_web_enabled: false,
+ throttle_authenticated_web_period_in_seconds: 3600,
+ throttle_authenticated_web_requests_per_period: 7200,
+ throttle_unauthenticated_enabled: false,
+ throttle_unauthenticated_period_in_seconds: 3600,
+ throttle_unauthenticated_requests_per_period: 3600,
+ two_factor_grace_period: 48,
+ unique_ips_limit_enabled: false,
+ unique_ips_limit_per_user: 10,
+ unique_ips_limit_time_window: 3600,
+ usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
+ instance_statistics_visibility_private: false,
+ user_default_external: false,
+ user_default_internal_regex: nil,
+ user_show_add_ssh_key_message: true,
+ usage_stats_set_by_user_id: nil,
+ diff_max_patch_bytes: Gitlab::Git::Diff::DEFAULT_MAX_PATCH_BYTES,
+ commit_email_hostname: default_commit_email_hostname,
+ protected_ci_variables: false,
+ local_markdown_version: 0
+ }
+ end
+
+ def default_commit_email_hostname
+ "users.noreply.#{Gitlab.config.gitlab.host}"
+ end
+
+ def create_from_defaults
+ build_from_defaults.tap(&:save)
+ end
+
+ def human_attribute_name(attr, _options = {})
+ if attr == :default_artifacts_expire_in
+ 'Default artifacts expiration'
+ else
+ super
+ end
+ end
+ end
+
+ def home_page_url_column_exists?
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url)
+ end
+
+ def help_page_support_url_column_exists?
+ ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url)
+ end
+
+ def disabled_oauth_sign_in_sources=(sources)
+ sources = (sources || []).map(&:to_s) & Devise.omniauth_providers.map(&:to_s)
+ super(sources)
+ end
+
+ def domain_whitelist_raw
+ self.domain_whitelist&.join("\n")
+ end
+
+ def domain_blacklist_raw
+ self.domain_blacklist&.join("\n")
+ end
+
+ def domain_whitelist_raw=(values)
+ self.domain_whitelist = []
+ self.domain_whitelist = values.split(DOMAIN_LIST_SEPARATOR)
+ self.domain_whitelist.reject! { |d| d.empty? }
+ self.domain_whitelist
+ end
+
+ def domain_blacklist_raw=(values)
+ self.domain_blacklist = []
+ self.domain_blacklist = values.split(DOMAIN_LIST_SEPARATOR)
+ self.domain_blacklist.reject! { |d| d.empty? }
+ self.domain_blacklist
+ end
+
+ def domain_blacklist_file=(file)
+ self.domain_blacklist_raw = file.read
+ end
+
+ def repository_storages
+ Array(read_attribute(:repository_storages))
+ end
+
+ def commit_email_hostname
+ super.presence || self.class.default_commit_email_hostname
+ end
+
+ def default_project_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def default_snippet_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def default_group_visibility=(level)
+ super(Gitlab::VisibilityLevel.level_value(level))
+ end
+
+ def restricted_visibility_levels=(levels)
+ super(levels&.map { |level| Gitlab::VisibilityLevel.level_value(level) })
+ end
+
+ def strip_sentry_values
+ sentry_dsn.strip! if sentry_dsn.present?
+ clientside_sentry_dsn.strip! if clientside_sentry_dsn.present?
+ end
+
+ def sentry_enabled
+ Gitlab.config.sentry.enabled || read_attribute(:sentry_enabled)
+ end
+
+ def sentry_dsn
+ Gitlab.config.sentry.dsn || read_attribute(:sentry_dsn)
+ end
+
+ def clientside_sentry_enabled
+ Gitlab.config.sentry.enabled || read_attribute(:clientside_sentry_enabled)
+ end
+
+ def clientside_sentry_dsn
+ Gitlab.config.sentry.clientside_dsn || read_attribute(:clientside_sentry_dsn)
+ end
+
+ def performance_bar_allowed_group
+ Group.find_by_id(performance_bar_allowed_group_id)
+ end
+
+ # Return true if the Performance Bar is enabled for a given group
+ def performance_bar_enabled
+ performance_bar_allowed_group_id.present?
+ end
+
+ # Choose one of the available repository storage options. Currently all have
+ # equal weighting.
+ def pick_repository_storage
+ repository_storages.sample
+ end
+
+ def runners_registration_token
+ ensure_runners_registration_token!
+ end
+
+ def health_check_access_token
+ ensure_health_check_access_token!
+ end
+
+ def usage_ping_can_be_configured?
+ Settings.gitlab.usage_ping_enabled
+ end
+
+ def usage_ping_enabled
+ usage_ping_can_be_configured? && super
+ end
+
+ def allowed_key_types
+ SUPPORTED_KEY_TYPES.select do |type|
+ key_restriction_for(type) != FORBIDDEN_KEY_VALUE
+ end
+ end
+
+ def key_restriction_for(type)
+ attr_name = "#{type}_key_restriction"
+
+ has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def allow_signup?
+ signup_enabled? && password_authentication_enabled_for_web?
+ end
+
+ def password_authentication_enabled?
+ password_authentication_enabled_for_web? || password_authentication_enabled_for_git?
+ end
+
+ def user_default_internal_regex_enabled?
+ user_default_external? && user_default_internal_regex.present?
+ end
+
+ def user_default_internal_regex_instance
+ Regexp.new(user_default_internal_regex, Regexp::IGNORECASE)
+ end
+
+ delegate :terms, to: :latest_terms, allow_nil: true
+ def latest_terms
+ @latest_terms ||= ApplicationSetting::Term.latest
+ end
+
+ def reset_memoized_terms
+ @latest_terms = nil # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ latest_terms
+ end
+
+ def archive_builds_older_than
+ archive_builds_in_seconds.seconds.ago if archive_builds_in_seconds
+ end
+
+ private
+
+ def ensure_uuid!
+ return if uuid?
+
+ self.uuid = SecureRandom.uuid
+ end
+
+ def check_repository_storages
+ invalid = repository_storages - Gitlab.config.repositories.storages.keys
+ errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless
+ invalid.empty?
+ end
+
+ def terms_exist
+ return unless enforce_terms?
+
+ errors.add(:terms, "You need to set terms to be enforced") unless terms.present?
+ end
+
+ def expire_performance_bar_allowed_user_ids_cache
+ Gitlab::PerformanceBar.expire_allowed_user_ids_cache
+ end
+end
diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb
index 8508c88d406..6ef2914ac11 100644
--- a/app/models/audit_event.rb
+++ b/app/models/audit_event.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AuditEvent < ActiveRecord::Base
+class AuditEvent < ApplicationRecord
serialize :details, Hash # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user, foreign_key: :author_id
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index ddc516ccb60..e26162f6151 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class AwardEmoji < ActiveRecord::Base
+class AwardEmoji < ApplicationRecord
DOWNVOTE_NAME = "thumbsdown".freeze
UPVOTE_NAME = "thumbsup".freeze
diff --git a/app/models/badge.rb b/app/models/badge.rb
index f016654206b..50299cd6652 100644
--- a/app/models/badge.rb
+++ b/app/models/badge.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Badge < ActiveRecord::Base
+class Badge < ApplicationRecord
include FromUnion
# This structure sets the placeholders that the urls
@@ -22,7 +22,7 @@ class Badge < ActiveRecord::Base
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
- validates :link_url, :image_url, url: { protocols: %w(http https) }
+ validates :link_url, :image_url, addressable_url: true
validates :type, presence: true
def rendered_link_url(project = nil)
diff --git a/app/models/blob.rb b/app/models/blob.rb
index 66a0925c495..d528bef8b19 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -102,7 +102,7 @@ class Blob < SimpleDelegator
# If the blob is a text based blob the content is converted to UTF-8 and any
# invalid byte sequences are replaced.
def data
- if binary?
+ if binary_in_repo?
super
else
@data ||= super.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
@@ -110,7 +110,7 @@ class Blob < SimpleDelegator
end
def load_all_data!
- # Endpoint needed: gitlab-org/gitaly#756
+ # Endpoint needed: https://gitlab.com/gitlab-org/gitaly/issues/756
Gitlab::GitalyClient.allow_n_plus_1_calls do
super(project.repository) if project
end
@@ -149,7 +149,7 @@ class Blob < SimpleDelegator
# an LFS pointer, we assume the file stored in LFS is binary, unless a
# text-based rich blob viewer matched on the file's extension. Otherwise, this
# depends on the type of the blob itself.
- def raw_binary?
+ def binary?
if stored_externally?
if rich_viewer
rich_viewer.binary?
@@ -161,7 +161,7 @@ class Blob < SimpleDelegator
true
end
else
- binary?
+ binary_in_repo?
end
end
@@ -180,7 +180,7 @@ class Blob < SimpleDelegator
end
def readable_text?
- text? && !stored_externally? && !truncated?
+ text_in_repo? && !stored_externally? && !truncated?
end
def simple_viewer
@@ -220,7 +220,7 @@ class Blob < SimpleDelegator
def simple_viewer_class
if empty?
BlobViewer::Empty
- elsif raw_binary?
+ elsif binary?
BlobViewer::Download
else # text
BlobViewer::Text
diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb
index eaaf9af1330..df6b9bb2f0b 100644
--- a/app/models/blob_viewer/base.rb
+++ b/app/models/blob_viewer/base.rb
@@ -16,7 +16,7 @@ module BlobViewer
def initialize(blob)
@blob = blob
- @initially_binary = blob.binary?
+ @initially_binary = blob.binary_in_repo?
end
def self.partial_path
@@ -52,7 +52,7 @@ module BlobViewer
end
def self.can_render?(blob, verify_binary: true)
- return false if verify_binary && binary? != blob.binary?
+ return false if verify_binary && binary? != blob.binary_in_repo?
return true if extensions&.include?(blob.extension)
return true if file_types&.include?(blob.file_type)
@@ -72,7 +72,7 @@ module BlobViewer
end
def binary_detected_after_load?
- !@initially_binary && blob.binary?
+ !@initially_binary && blob.binary_in_repo?
end
# This method is used on the server side to check whether we can attempt to
diff --git a/app/models/blob_viewer/gitlab_ci_yml.rb b/app/models/blob_viewer/gitlab_ci_yml.rb
index 655241c2808..11228e620c9 100644
--- a/app/models/blob_viewer/gitlab_ci_yml.rb
+++ b/app/models/blob_viewer/gitlab_ci_yml.rb
@@ -10,16 +10,16 @@ module BlobViewer
self.file_types = %i(gitlab_ci)
self.binary = false
- def validation_message(project, sha)
+ def validation_message(opts)
return @validation_message if defined?(@validation_message)
prepare!
- @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data, { project: project, sha: sha })
+ @validation_message = Gitlab::Ci::YamlProcessor.validation_message(blob.data, opts)
end
- def valid?(project, sha)
- validation_message(project, sha).blank?
+ def valid?(opts)
+ validation_message(opts).blank?
end
end
end
diff --git a/app/models/board.rb b/app/models/board.rb
index a137863456c..e08db764f65 100644
--- a/app/models/board.rb
+++ b/app/models/board.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Board < ActiveRecord::Base
+class Board < ApplicationRecord
belongs_to :group
belongs_to :project
@@ -21,6 +21,10 @@ class Board < ActiveRecord::Base
group_id.present?
end
+ def project_board?
+ project_id.present?
+ end
+
def backlog_list
lists.merge(List.backlog).take
end
diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb
index 92abbb67222..2f1cd830791 100644
--- a/app/models/board_group_recent_visit.rb
+++ b/app/models/board_group_recent_visit.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Tracks which boards in a specific group a user has visited
-class BoardGroupRecentVisit < ActiveRecord::Base
+class BoardGroupRecentVisit < ApplicationRecord
belongs_to :user
belongs_to :group
belongs_to :board
@@ -10,7 +10,7 @@ class BoardGroupRecentVisit < ActiveRecord::Base
validates :group, presence: true
validates :board, presence: true
- scope :by_user_group, -> (user, group) { where(user: user, group: group).order(:updated_at) }
+ scope :by_user_group, -> (user, group) { where(user: user, group: group) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, group: board.group, board: board)
@@ -19,7 +19,10 @@ class BoardGroupRecentVisit < ActiveRecord::Base
retry
end
- def self.latest(user, group)
- by_user_group(user, group).last
+ def self.latest(user, group, count: nil)
+ visits = by_user_group(user, group).order(updated_at: :desc)
+ visits = visits.preload(:board) if count && count > 1
+
+ visits.first(count)
end
end
diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb
index 7cffff906d8..236d88e909c 100644
--- a/app/models/board_project_recent_visit.rb
+++ b/app/models/board_project_recent_visit.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Tracks which boards in a specific project a user has visited
-class BoardProjectRecentVisit < ActiveRecord::Base
+class BoardProjectRecentVisit < ApplicationRecord
belongs_to :user
belongs_to :project
belongs_to :board
@@ -10,7 +10,7 @@ class BoardProjectRecentVisit < ActiveRecord::Base
validates :project, presence: true
validates :board, presence: true
- scope :by_user_project, -> (user, project) { where(user: user, project: project).order(:updated_at) }
+ scope :by_user_project, -> (user, project) { where(user: user, project: project) }
def self.visited!(user, board)
visit = find_or_create_by(user: user, project: board.project, board: board)
@@ -19,7 +19,10 @@ class BoardProjectRecentVisit < ActiveRecord::Base
retry
end
- def self.latest(user, project)
- by_user_project(user, project).last
+ def self.latest(user, project, count: nil)
+ visits = by_user_project(user, project).order(updated_at: :desc)
+ visits = visits.preload(:board) if count && count > 1
+
+ visits.first(count)
end
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 277f7c2717c..18fe2a9624f 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-class BroadcastMessage < ActiveRecord::Base
+class BroadcastMessage < ApplicationRecord
include CacheMarkdownField
include Sortable
- cache_markdown_field :message, pipeline: :broadcast_message
+ cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
validates :starts_at, presence: true
@@ -22,49 +22,30 @@ class BroadcastMessage < ActiveRecord::Base
after_commit :flush_redis_cache
def self.current
- raw_messages = Rails.cache.fetch(CACHE_KEY, expires_in: cache_expires_in) do
+ messages = cache.fetch(CACHE_KEY, as: BroadcastMessage, expires_in: cache_expires_in) do
remove_legacy_cache_key
- current_and_future_messages.to_json
+ current_and_future_messages
end
- messages = decode_messages(raw_messages)
-
return [] unless messages&.present?
now_or_future = messages.select(&:now_or_future?)
# If there are cached entries but none are to be displayed we'll purge the
# cache so we don't keep running this code all the time.
- Rails.cache.delete(CACHE_KEY) if now_or_future.empty?
+ cache.expire(CACHE_KEY) if now_or_future.empty?
now_or_future.select(&:now?)
end
- def self.decode_messages(raw_messages)
- return unless raw_messages&.present?
-
- message_list = ActiveSupport::JSON.decode(raw_messages)
-
- return unless message_list.is_a?(Array)
-
- valid_attr = BroadcastMessage.attribute_names
-
- message_list.map do |raw|
- BroadcastMessage.new(raw) if valid_cache_entry?(raw, valid_attr)
- end.compact
- rescue ActiveSupport::JSON.parse_error
- end
-
- def self.valid_cache_entry?(raw, valid_attr)
- return false unless raw.is_a?(Hash)
-
- (raw.keys - valid_attr).empty?
- end
-
def self.current_and_future_messages
where('ends_at > :now', now: Time.zone.now).order_id_asc
end
+ def self.cache
+ Gitlab::JsonCache.new(cache_key_with_version: false)
+ end
+
def self.cache_expires_in
nil
end
@@ -74,7 +55,7 @@ class BroadcastMessage < ActiveRecord::Base
# environment a one-shot migration would not work because the cache
# would be repopulated by a node that has not been upgraded.
def self.remove_legacy_cache_key
- Rails.cache.delete(LEGACY_CACHE_KEY)
+ cache.expire(LEGACY_CACHE_KEY)
end
def active?
@@ -102,7 +83,7 @@ class BroadcastMessage < ActiveRecord::Base
end
def flush_redis_cache
- Rails.cache.delete(CACHE_KEY)
+ self.class.cache.expire(CACHE_KEY)
self.class.remove_legacy_cache_key
end
end
diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb
index 03b0af53046..0041595baba 100644
--- a/app/models/chat_name.rb
+++ b/app/models/chat_name.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ChatName < ActiveRecord::Base
+class ChatName < ApplicationRecord
LAST_USED_AT_INTERVAL = 1.hour
belongs_to :service
diff --git a/app/models/chat_team.rb b/app/models/chat_team.rb
index 4e724f9adf7..52b5a7b4a91 100644
--- a/app/models/chat_team.rb
+++ b/app/models/chat_team.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ChatTeam < ActiveRecord::Base
+class ChatTeam < ApplicationRecord
validates :team_id, presence: true
validates :namespace, uniqueness: true
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 29aa00a66d9..644716ba8e7 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -2,11 +2,16 @@
module Ci
class Bridge < CommitStatus
+ include Ci::Processable
+ include Ci::Contextable
+ include Ci::PipelineDelegator
include Importable
include AfterCommitQueue
+ include HasRef
include Gitlab::Utils::StrongMemoize
belongs_to :project
+ belongs_to :trigger_request
validates :ref, presence: true
def self.retry(bridge, current_user)
@@ -23,12 +28,31 @@ module Ci
.fabricate!
end
- def predefined_variables
- raise NotImplementedError
+ def schedulable?
+ false
+ end
+
+ def action?
+ false
+ end
+
+ def artifacts?
+ false
+ end
+
+ def runnable?
+ false
+ end
+
+ def expanded_environment_name
end
def execute_hooks
raise NotImplementedError
end
+
+ def to_partial_path
+ 'projects/generic_commit_statuses/generic_commit_status'
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e2917049902..1b67a7272bc 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -3,13 +3,24 @@
module Ci
class Build < CommitStatus
prepend ArtifactMigratable
+ include Ci::Processable
+ include Ci::Metadatable
+ include Ci::Contextable
+ include Ci::PipelineDelegator
include TokenAuthenticatable
include AfterCommitQueue
include ObjectStorage::BackgroundMove
include Presentable
include Importable
+ include IgnorableColumn
include Gitlab::Utils::StrongMemoize
include Deployable
+ include HasRef
+ include UpdateProjectStatistics
+
+ BuildArchivedError = Class.new(StandardError)
+
+ ignore_column :commands
belongs_to :project, inverse_of: :builds
belongs_to :runner
@@ -17,7 +28,8 @@ module Ci
belongs_to :erased_by, class_name: 'User'
RUNNER_FEATURES = {
- upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }
+ upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
+ refspecs: -> (build) { build.merge_request_ref? }
}.freeze
has_one :deployment, as: :deployable, class_name: 'Deployment'
@@ -30,25 +42,33 @@ module Ci
has_one :"job_artifacts_#{key}", -> { where(file_type: value) }, class_name: 'Ci::JobArtifact', inverse_of: :job, foreign_key: :job_id
end
- has_one :metadata, class_name: 'Ci::BuildMetadata'
has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build
accepts_nested_attributes_for :runner_session
- delegate :timeout, to: :metadata, prefix: true, allow_nil: true
delegate :url, to: :runner_session, prefix: true, allow_nil: true
delegate :terminal_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
##
- # The "environment" field for builds is a String, and is the unexpanded name!
+ # Since Gitlab 11.5, deployments records started being created right after
+ # `ci_builds` creation. We can look up a relevant `environment` through
+ # `deployment` relation today. This is much more efficient than expanding
+ # environment name with variables.
+ # (See more https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22380)
+ #
+ # However, we have to still expand environment name if it's a stop action,
+ # because `deployment` persists information for start action only.
#
+ # We will follow up this by persisting expanded name in build metadata or
+ # persisting stop action in database.
def persisted_environment
return unless has_environment?
strong_memoize(:persisted_environment) do
- Environment.find_by(name: expanded_environment_name, project: project)
+ deployment&.environment ||
+ Environment.find_by(name: expanded_environment_name, project: project)
end
end
@@ -63,8 +83,13 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
scope :with_artifacts_archive, ->() do
- where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
- '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ if Feature.enabled?(:ci_enable_legacy_artifacts)
+ where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)',
+ '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ else
+ where('EXISTS (?)',
+ Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ end
end
scope :with_existing_job_artifacts, ->(query) do
@@ -79,8 +104,8 @@ module Ci
where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace)
end
- scope :with_test_reports, ->() do
- with_existing_job_artifacts(Ci::JobArtifact.test_reports)
+ scope :with_reports, ->(reports_scope) do
+ with_existing_job_artifacts(reports_scope)
.eager_load_job_artifacts
end
@@ -115,24 +140,26 @@ module Ci
where("EXISTS (?)", matcher)
end
+ scope :queued_before, ->(time) { where(arel_table[:queued_at].lt(time)) }
+
+ ##
+ # TODO: Remove these mounters when we remove :ci_enable_legacy_artifacts feature flag
mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file
mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata
acts_as_taggable
- add_authentication_token_field :token, encrypted: true, fallback: true
+ add_authentication_token_field :token, encrypted: :optional
before_save :update_artifacts_size, if: :artifacts_file_changed?
before_save :ensure_token
before_destroy { unscoped_project }
- before_create :ensure_metadata
after_create unless: :importing? do |build|
run_after_commit { BuildHooksWorker.perform_async(build.id) }
end
- after_save :update_project_statistics_after_save, if: :artifacts_size_changed?
- after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
+ update_project_statistics stat: :build_artifacts_size, attribute: :artifacts_size
class << self
# This is needed for url_for to work,
@@ -155,6 +182,10 @@ module Ci
end
state_machine :status do
+ event :enqueue do
+ transition [:created, :skipped, :manual, :scheduled] => :preparing, if: :any_unmet_prerequisites?
+ end
+
event :actionize do
transition created: :manual
end
@@ -168,8 +199,12 @@ module Ci
end
event :enqueue_scheduled do
+ transition scheduled: :preparing, if: ->(build) do
+ build.scheduled_at&.past? && build.any_unmet_prerequisites?
+ end
+
transition scheduled: :pending, if: ->(build) do
- build.scheduled_at && build.scheduled_at < Time.now
+ build.scheduled_at&.past? && !build.any_unmet_prerequisites?
end
end
@@ -187,6 +222,12 @@ module Ci
end
end
+ after_transition any => [:preparing] do |build|
+ build.run_after_commit do
+ Ci::BuildPrepareWorker.perform_async(id)
+ end
+ end
+
after_transition any => [:pending] do |build|
build.run_after_commit do
BuildQueueWorker.perform_async(id)
@@ -218,8 +259,19 @@ module Ci
before_transition any => [:failed] do |build|
next unless build.project
+ next unless build.deployment
- build.deployment&.drop
+ begin
+ build.deployment.drop!
+ rescue => e
+ Gitlab::Sentry.track_exception(e, extra: { build_id: build.id })
+ end
+
+ true
+ end
+
+ after_transition any => [:failed] do |build|
+ next unless build.project
if build.retry_failure?
begin
@@ -243,10 +295,6 @@ module Ci
end
end
- def ensure_metadata
- metadata || build_metadata(project: project)
- end
-
def detailed_status(current_user)
Gitlab::Ci::Status::Build::Factory
.new(self, current_user)
@@ -266,13 +314,8 @@ module Ci
self.name == 'pages'
end
- # degenerated build is one that cannot be run by Runner
- def degenerated?
- self.options.nil?
- end
-
- def degenerate!
- self.update!(options: nil, yaml_variables: nil, commands: nil)
+ def runnable?
+ true
end
def archived?
@@ -311,7 +354,7 @@ module Ci
end
def retryable?
- !archived? && (success? || failed? || canceled?)
+ !archived? && (success? || failed?)
end
def retries_count
@@ -336,6 +379,14 @@ module Ci
!retried?
end
+ def any_unmet_prerequisites?
+ prerequisites.present?
+ end
+
+ def prerequisites
+ Gitlab::Ci::Build::Prerequisite::Factory.new(self).unmet
+ end
+
def expanded_environment_name
return unless has_environment?
@@ -384,63 +435,59 @@ module Ci
options&.dig(:environment, :on_stop)
end
- # A slugified version of the build ref, suitable for inclusion in URLs and
- # domain names. Rules:
+ ##
+ # All variables, including persisted environment variables.
#
- # * Lowercased
- # * Anything not matching [a-z0-9-] is replaced with a -
- # * Maximum length is 63 bytes
- # * First/Last Character is not a hyphen
- def ref_slug
- Gitlab::Utils.slugify(ref.to_s)
+ def variables
+ strong_memoize(:variables) do
+ Gitlab::Ci::Variables::Collection.new
+ .concat(persisted_variables)
+ .concat(scoped_variables)
+ .concat(persisted_environment_variables)
+ .to_runner_variables
+ end
end
- ##
- # Variables in the environment name scope.
- #
- def scoped_variables(environment: expanded_environment_name)
+ CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
+
+ def persisted_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.concat(predefined_variables)
- variables.concat(project.predefined_variables)
- variables.concat(pipeline.predefined_variables)
- variables.concat(runner.predefined_variables) if runner
- variables.concat(project.deployment_variables(environment: environment)) if environment
- variables.concat(yaml_variables)
- variables.concat(user_variables)
- variables.concat(secret_group_variables)
- variables.concat(secret_project_variables(environment: environment))
- variables.concat(trigger_request.user_variables) if trigger_request
- variables.concat(pipeline.variables)
- variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
+ break variables unless persisted?
+
+ variables
+ .concat(pipeline.persisted_variables)
+ .append(key: 'CI_JOB_ID', value: id.to_s)
+ .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
+ .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_BUILD_ID', value: id.to_s)
+ .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
+ .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
+ .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
+ .concat(deploy_token_variables)
end
end
- ##
- # Variables that do not depend on the environment name.
- #
- def simple_variables
- strong_memoize(:simple_variables) do
- scoped_variables(environment: nil).to_runner_variables
+ def persisted_environment_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless persisted? && persisted_environment.present?
+
+ variables.concat(persisted_environment.predefined_variables)
+
+ # Here we're passing unexpanded environment_url for runner to expand,
+ # and we need to make sure that CI_ENVIRONMENT_NAME and
+ # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
+ variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
end
end
- ##
- # All variables, including persisted environment variables.
- #
- def variables
- Gitlab::Ci::Variables::Collection.new
- .concat(persisted_variables)
- .concat(scoped_variables)
- .concat(persisted_environment_variables)
- .to_runner_variables
- end
+ def deploy_token_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless gitlab_deploy_token
- ##
- # Regular Ruby hash of scoped variables, without duplicates that are
- # possible to be present in an array of hashes returned from `variables`.
- #
- def scoped_variables_hash
- scoped_variables.to_hash
+ variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username)
+ variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false, masked: true)
+ end
end
def features
@@ -618,35 +665,6 @@ module Ci
super || project.try(:build_coverage_regex)
end
- def when
- read_attribute(:when) || build_attributes_from_config[:when] || 'on_success'
- end
-
- def yaml_variables
- read_attribute(:yaml_variables) || build_attributes_from_config[:yaml_variables] || []
- end
-
- def user_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables if user.blank?
-
- variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
- variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
- variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
- variables.append(key: 'GITLAB_USER_NAME', value: user.name)
- end
- end
-
- def secret_group_variables
- return [] unless project.group
-
- project.group.ci_variables_for(ref, project)
- end
-
- def secret_project_variables(environment: persisted_environment)
- project.ci_variables_for(ref: ref, environment: environment)
- end
-
def steps
[Gitlab::Ci::Build::Step.from_commands(self),
Gitlab::Ci::Build::Step.from_after_script(self)].compact
@@ -749,7 +767,7 @@ module Ci
# Virtual deployment status depending on the environment status.
def deployment_status
- return nil unless starts_environment?
+ return unless starts_environment?
if success?
return successful_deployment_status
@@ -763,7 +781,7 @@ module Ci
private
def erase_old_artifacts!
- # TODO: To be removed once we get rid of
+ # TODO: To be removed once we get rid of ci_enable_legacy_artifacts feature flag
remove_artifacts_file!
remove_artifacts_metadata!
save
@@ -806,88 +824,6 @@ module Ci
@unscoped_project ||= Project.unscoped.find_by(id: project_id)
end
- CI_REGISTRY_USER = 'gitlab-ci-token'.freeze
-
- def persisted_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless persisted?
-
- variables
- .concat(pipeline.persisted_variables)
- .append(key: 'CI_JOB_ID', value: id.to_s)
- .append(key: 'CI_JOB_URL', value: Gitlab::Routing.url_helpers.project_job_url(project, self))
- .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false)
- .append(key: 'CI_BUILD_ID', value: id.to_s)
- .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false)
- .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER)
- .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false)
- .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
- .concat(deploy_token_variables)
- end
- end
-
- def predefined_variables # rubocop:disable Metrics/AbcSize
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI', value: 'true')
- variables.append(key: 'GITLAB_CI', value: 'true')
- variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(','))
- variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
- variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
- variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s)
- variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s)
- variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s)
- variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision)
- variables.append(key: 'CI_JOB_NAME', value: name)
- variables.append(key: 'CI_JOB_STAGE', value: stage)
- variables.append(key: 'CI_COMMIT_SHA', value: sha)
- variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha)
- variables.append(key: 'CI_COMMIT_REF_NAME', value: ref)
- variables.append(key: 'CI_COMMIT_REF_SLUG', value: ref_slug)
- variables.append(key: "CI_COMMIT_TAG", value: ref) if tag?
- variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request
- variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
- variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance)
- variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s)
- variables.concat(legacy_variables)
- end
- end
-
- def legacy_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- variables.append(key: 'CI_BUILD_REF', value: sha)
- variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
- variables.append(key: 'CI_BUILD_REF_NAME', value: ref)
- variables.append(key: 'CI_BUILD_REF_SLUG', value: ref_slug)
- variables.append(key: 'CI_BUILD_NAME', value: name)
- variables.append(key: 'CI_BUILD_STAGE', value: stage)
- variables.append(key: "CI_BUILD_TAG", value: ref) if tag?
- variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request
- variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action?
- end
- end
-
- def persisted_environment_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless persisted? && persisted_environment.present?
-
- variables.concat(persisted_environment.predefined_variables)
-
- # Here we're passing unexpanded environment_url for runner to expand,
- # and we need to make sure that CI_ENVIRONMENT_NAME and
- # CI_ENVIRONMENT_SLUG so on are available for the URL be expanded.
- variables.append(key: 'CI_ENVIRONMENT_URL', value: environment_url) if environment_url
- end
- end
-
- def deploy_token_variables
- Gitlab::Ci::Variables::Collection.new.tap do |variables|
- break variables unless gitlab_deploy_token
-
- variables.append(key: 'CI_DEPLOY_USER', value: gitlab_deploy_token.username)
- variables.append(key: 'CI_DEPLOY_PASSWORD', value: gitlab_deploy_token.token, public: false)
- end
- end
-
def environment_url
options&.dig(:environment, :url) || persisted_environment&.external_url
end
@@ -898,8 +834,11 @@ module Ci
# have the old integer only format. This method returns the retry option
# normalized as a hash in 11.5+ format.
def normalized_retry
- value = options&.dig(:retry)
- value.is_a?(Integer) ? { max: value } : value.to_h
+ strong_memoize(:normalized_retry) do
+ value = options&.dig(:retry)
+ value = value.is_a?(Integer) ? { max: value } : value.to_h
+ value.with_indifferent_access
+ end
end
def build_attributes_from_config
@@ -907,21 +846,5 @@ module Ci
pipeline.config_processor.build_attributes(name)
end
-
- def update_project_statistics_after_save
- update_project_statistics(read_attribute(:artifacts_size).to_i - artifacts_size_was.to_i)
- end
-
- def update_project_statistics_after_destroy
- update_project_statistics(-artifacts_size)
- end
-
- def update_project_statistics(difference)
- ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
- end
-
- def project_destroyed?
- project.pending_delete?
- end
end
end
diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb
index 9d588b862bd..f281cbd1d6f 100644
--- a/app/models/ci/build_metadata.rb
+++ b/app/models/ci/build_metadata.rb
@@ -3,18 +3,22 @@
module Ci
# The purpose of this class is to store Build related data that can be disposed.
# Data that should be persisted forever, should be stored with Ci::Build model.
- class BuildMetadata < ActiveRecord::Base
+ class BuildMetadata < ApplicationRecord
extend Gitlab::Ci::Model
include Presentable
include ChronicDurationAttribute
self.table_name = 'ci_builds_metadata'
- belongs_to :build, class_name: 'Ci::Build'
+ belongs_to :build, class_name: 'CommitStatus'
belongs_to :project
+ before_create :set_build_project
+
validates :build, presence: true
- validates :project, presence: true
+
+ serialize :config_options, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
+ serialize :config_variables, Serializers::JSON # rubocop:disable Cop/ActiveRecordSerialize
chronic_duration_attr_reader :timeout_human_readable, :timeout
@@ -33,5 +37,11 @@ module Ci
update(timeout: timeout, timeout_source: timeout_source)
end
+
+ private
+
+ def set_build_project
+ self.project_id ||= self.build.project_id
+ end
end
end
diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb
index 457d7eeab6a..997bf298025 100644
--- a/app/models/ci/build_runner_session.rb
+++ b/app/models/ci/build_runner_session.rb
@@ -3,22 +3,34 @@
module Ci
# The purpose of this class is to store Build related runner session.
# Data will be removed after transitioning from running to any state.
- class BuildRunnerSession < ActiveRecord::Base
+ class BuildRunnerSession < ApplicationRecord
extend Gitlab::Ci::Model
+ TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'.freeze
+
self.table_name = 'ci_builds_runner_session'
belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session
validates :build, presence: true
- validates :url, url: { protocols: %w(https) }
+ validates :url, addressable_url: { schemes: %w(https) }
def terminal_specification
- return {} unless url.present?
+ wss_url = Gitlab::UrlHelpers.as_wss(self.url)
+ return {} unless wss_url.present?
+
+ wss_url = "#{wss_url}/exec"
+ channel_specification(wss_url, TERMINAL_SUBPROTOCOL)
+ end
+
+ private
+
+ def channel_specification(url, subprotocol)
+ return {} if subprotocol.blank? || url.blank?
{
- subprotocols: ['terminal.gitlab.com'].freeze,
- url: "#{url}/exec".sub("https://", "wss://"),
+ subprotocols: Array(subprotocol),
+ url: url,
headers: { Authorization: [authorization.presence] }.compact,
ca_pem: certificate.presence
}
diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb
index da08214963f..0a7a0e0772b 100644
--- a/app/models/ci/build_trace_chunk.rb
+++ b/app/models/ci/build_trace_chunk.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceChunk < ActiveRecord::Base
+ class BuildTraceChunk < ApplicationRecord
include FastDestroyAll
include ::Gitlab::ExclusiveLeaseHelpers
extend Gitlab::Ci::Model
@@ -18,7 +18,7 @@ module Ci
FailedToPersistDataError = Class.new(StandardError)
# Note: The ordering of this enum is related to the precedence of persist store.
- # The bottom item takes the higest precedence, and the top item takes the lowest precedence.
+ # The bottom item takes the highest precedence, and the top item takes the lowest precedence.
enum data_store: {
redis: 1,
database: 2,
@@ -115,7 +115,7 @@ module Ci
current_data = get_data
unless current_data&.bytesize.to_i == CHUNK_SIZE
- raise FailedToPersistDataError, 'Data is not fullfilled in a bucket'
+ raise FailedToPersistDataError, 'Data is not fulfilled in a bucket'
end
old_store_class = self.class.get_store_class(data_store)
diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb
index a4bee59c83b..8be42eb48d6 100644
--- a/app/models/ci/build_trace_section.rb
+++ b/app/models/ci/build_trace_section.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceSection < ActiveRecord::Base
+ class BuildTraceSection < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :build, class_name: 'Ci::Build'
diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb
index cbdf3c4b673..c065cfea14e 100644
--- a/app/models/ci/build_trace_section_name.rb
+++ b/app/models/ci/build_trace_section_name.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class BuildTraceSectionName < ActiveRecord::Base
+ class BuildTraceSectionName < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :project
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 492d1d0329e..0e50265c7ba 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class GroupVariable < ActiveRecord::Base
+ class GroupVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
include Presentable
+ include Maskable
belongs_to :group, class_name: "::Group"
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index 11c88200c37..3beb76ffc2b 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
module Ci
- class JobArtifact < ActiveRecord::Base
+ class JobArtifact < ApplicationRecord
include AfterCommitQueue
include ObjectStorage::BackgroundMove
+ include UpdateProjectStatistics
extend Gitlab::Ci::Model
NotSupportedAdapterError = Class.new(StandardError)
@@ -21,7 +22,8 @@ module Ci
container_scanning: 'gl-container-scanning-report.json',
dast: 'gl-dast-report.json',
license_management: 'gl-license-management-report.json',
- performance: 'performance.json'
+ performance: 'performance.json',
+ metrics: 'metrics.txt'
}.freeze
TYPE_AND_FORMAT_PAIRS = {
@@ -29,6 +31,7 @@ module Ci
metadata: :gzip,
trace: :raw,
junit: :gzip,
+ metrics: :gzip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
@@ -50,10 +53,10 @@ module Ci
validates :file_format, presence: true, unless: :trace?, on: :create
validate :valid_file_format?, unless: :trace?, on: :create
before_save :set_size, if: :file_changed?
- after_save :update_project_statistics_after_save, if: :size_changed?
- after_destroy :update_project_statistics_after_destroy, unless: :project_destroyed?
- after_save :update_file_store, if: :file_changed?
+ update_project_statistics stat: :build_artifacts_size
+
+ after_save :update_file_store, if: :saved_change_to_file?
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
@@ -73,6 +76,8 @@ module Ci
where(file_type: types)
end
+ scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) }
+
delegate :filename, :exists?, :open, to: :file
enum file_type: {
@@ -86,14 +91,15 @@ module Ci
dast: 8, ## EE-specific
codequality: 9, ## EE-specific
license_management: 10, ## EE-specific
- performance: 11 ## EE-specific
+ performance: 11, ## EE-specific
+ metrics: 12 ## EE-specific
}
enum file_format: {
raw: 1,
zip: 2,
gzip: 3
- }
+ }, _suffix: true
# `file_location` indicates where actual files are stored.
# Ideally, actual files should be stored in the same directory, and use the same
@@ -171,18 +177,6 @@ module Ci
self.size = file.size
end
- def update_project_statistics_after_save
- update_project_statistics(size.to_i - size_was.to_i)
- end
-
- def update_project_statistics_after_destroy
- update_project_statistics(-self.size.to_i)
- end
-
- def update_project_statistics(difference)
- ProjectStatistics.increment_statistic(project_id, :build_artifacts_size, difference)
- end
-
def project_destroyed?
# Use job.project to avoid extra DB query for project
job.project.pending_delete?
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index 96dbc7b6895..930c8a71453 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -58,5 +58,9 @@ module Ci
statuses.latest.failed_but_allowed.any?
end
end
+
+ def manual_playable?
+ %[manual scheduled skipped].include?(status.to_s)
+ end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 2cdb4780412..80401ca0a1e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Pipeline < ActiveRecord::Base
+ class Pipeline < ApplicationRecord
extend Gitlab::Ci::Model
include HasStatus
include Importable
@@ -11,6 +11,12 @@ module Ci
include Gitlab::Utils::StrongMemoize
include AtomicInternalId
include EnumWithNil
+ include HasRef
+ include ShaAttribute
+ include FromUnion
+
+ sha_attribute :source_sha
+ sha_attribute :target_sha
belongs_to :project, inverse_of: :all_pipelines
belongs_to :user
@@ -24,6 +30,8 @@ module Ci
has_many :stages, -> { order(position: :asc) }, inverse_of: :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
+ has_many :processables, -> { processables },
+ class_name: 'CommitStatus', foreign_key: :commit_id, inverse_of: :pipeline
has_many :builds, foreign_key: :commit_id, inverse_of: :pipeline
has_many :trigger_requests, dependent: :destroy, foreign_key: :commit_id # rubocop:disable Cop/ActiveRecordDependent
has_many :variables, class_name: 'Ci::PipelineVariable'
@@ -32,7 +40,7 @@ module Ci
# Merge requests for which the current pipeline is running against
# the merge request's latest commit.
- has_many :merge_requests, foreign_key: "head_pipeline_id"
+ has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest'
has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build'
has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build'
@@ -44,6 +52,8 @@ module Ci
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_one :chat_data, class_name: 'Ci::PipelineChatData'
+
accepts_nested_attributes_for :variables, reject_if: :persisted?
delegate :id, to: :project, prefix: true
@@ -51,16 +61,12 @@ module Ci
validates :sha, presence: { unless: :importing? }
validates :ref, presence: { unless: :importing? }
- validates :merge_request, presence: { if: :merge_request? }
- validates :merge_request, absence: { unless: :merge_request? }
- validates :tag, inclusion: { in: [false], if: :merge_request? }
+ validates :merge_request, presence: { if: :merge_request_event? }
+ validates :merge_request, absence: { unless: :merge_request_event? }
+ validates :tag, inclusion: { in: [false], if: :merge_request_event? }
validates :status, presence: { unless: :importing? }
validate :valid_commit_sha, unless: :importing?
-
- # Replace validator below with
- # `validates :source, presence: { unless: :importing? }, on: :create`
- # when removing Gitlab.rails5? code.
- validate :valid_source, unless: :importing?, on: :create
+ validates :source, exclusion: { in: %w(unknown), unless: :importing? }, on: :create
after_create :keep_around_commits, unless: :importing?
@@ -76,10 +82,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :skipped, :scheduled] => :pending
+ transition [:created, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running
end
+ event :prepare do
+ transition any - [:preparing] => :preparing
+ end
+
event :run do
transition any - [:running] => :running
end
@@ -112,7 +122,7 @@ module Ci
# Do not add any operations to this state_machine
# Create a separate worker for each new operation
- before_transition [:created, :pending] => :running do |pipeline|
+ before_transition [:created, :preparing, :pending] => :running do |pipeline|
pipeline.started_at = Time.now
end
@@ -135,7 +145,7 @@ module Ci
end
end
- after_transition [:created, :pending] => :running do |pipeline|
+ after_transition [:created, :preparing, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
@@ -143,7 +153,7 @@ module Ci
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end
- after_transition [:created, :pending, :running] => :success do |pipeline|
+ after_transition [:created, :preparing, :pending, :running] => :success do |pipeline|
pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) }
end
@@ -174,12 +184,35 @@ module Ci
scope :sort_by_merge_request_pipelines, -> do
sql = 'CASE ci_pipelines.source WHEN (?) THEN 0 ELSE 1 END, ci_pipelines.id DESC'
- query = ActiveRecord::Base.send(:sanitize_sql_array, [sql, sources[:merge_request]]) # rubocop:disable GitlabSecurity/PublicSend
+ query = ApplicationRecord.send(:sanitize_sql_array, [sql, sources[:merge_request_event]]) # rubocop:disable GitlabSecurity/PublicSend
order(query)
end
scope :for_user, -> (user) { where(user: user) }
+ scope :for_sha, -> (sha) { where(sha: sha) }
+ scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
+ scope :for_sha_or_source_sha, -> (sha) { for_sha(sha).or(for_source_sha(sha)) }
+
+ scope :triggered_by_merge_request, -> (merge_request) do
+ where(source: :merge_request_event, merge_request: merge_request)
+ end
+
+ scope :detached_merge_request_pipelines, -> (merge_request, sha) do
+ triggered_by_merge_request(merge_request).for_sha(sha)
+ end
+
+ scope :merge_request_pipelines, -> (merge_request, source_sha) do
+ triggered_by_merge_request(merge_request).for_source_sha(source_sha)
+ end
+
+ scope :triggered_for_branch, -> (ref) do
+ where(source: branch_pipeline_sources).where(ref: ref, tag: false)
+ end
+
+ scope :with_reports, -> (reports_scope) do
+ where('EXISTS (?)', ::Ci::Build.latest.with_reports(reports_scope).where('ci_pipelines.id=ci_builds.commit_id').select(1))
+ end
# Returns the pipelines in descending order (= newest first), optionally
# limited to a number of references.
@@ -268,6 +301,10 @@ module Ci
sources.reject { |source| source == "external" }.values
end
+ def self.branch_pipeline_sources
+ @branch_pipeline_sources ||= sources.reject { |source| source == 'merge_request_event' }.values
+ end
+
def self.ci_sources_values
config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source)
end
@@ -303,7 +340,7 @@ module Ci
def ordered_stages
return legacy_stages unless complete?
- if Feature.enabled?('ci_pipeline_persisted_stages')
+ if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true)
stages
else
legacy_stages
@@ -383,10 +420,6 @@ module Ci
@commit ||= Commit.lazy(project, sha)
end
- def branch?
- !tag? && !merge_request?
- end
-
def stuck?
pending_builds.any?(&:stuck?)
end
@@ -432,9 +465,9 @@ module Ci
end
def latest?
- return false unless ref && commit.present?
+ return false unless git_ref && commit.present?
- project.commit(ref) == commit
+ project.commit(git_ref) == commit
end
def retried
@@ -499,7 +532,7 @@ module Ci
return @config_processor if defined?(@config_processor)
@config_processor ||= begin
- ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha })
+ ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha, user: user })
rescue Gitlab::Ci::YamlProcessor::ValidationError => e
self.yaml_errors = e.message
nil
@@ -568,6 +601,7 @@ module Ci
retry_optimistic_lock(self) do
case latest_builds_status.to_s
when 'created' then nil
+ when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
when 'success' then succeed
@@ -584,7 +618,7 @@ module Ci
end
def protected_ref?
- strong_memoize(:protected_ref) { project.protected_for?(ref) }
+ strong_memoize(:protected_ref) { project.protected_for?(git_ref) }
end
def legacy_trigger
@@ -608,8 +642,11 @@ module Ci
variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s)
variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s)
variables.append(key: 'CI_COMMIT_DESCRIPTION', value: git_commit_description.to_s)
+ variables.append(key: 'CI_COMMIT_REF_PROTECTED', value: (!!protected_ref?).to_s)
- if merge_request? && merge_request
+ if merge_request_event? && merge_request
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_SHA', value: source_sha.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_SHA', value: target_sha.to_s)
variables.concat(merge_request.predefined_variables)
end
end
@@ -637,10 +674,10 @@ module Ci
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||=
- if merge_request?
- project.merge_requests.where(id: merge_request.id)
+ if merge_request_event?
+ MergeRequest.where(id: merge_request_id)
else
- project.merge_requests.where(source_branch: ref)
+ MergeRequest.where(source_project_id: project_id, source_branch: ref)
end
end
@@ -654,16 +691,16 @@ module Ci
# We purposely cast the builds to an Array here. Because we always use the
# rows if there are more than 0 this prevents us from having to run two
# queries: one to get the count and one to get the rows.
- @latest_builds_with_artifacts ||= builds.latest.with_artifacts_archive.to_a
+ @latest_builds_with_artifacts ||= builds.latest.with_artifacts_not_expired.to_a
end
- def has_test_reports?
- complete? && builds.latest.with_test_reports.any?
+ def has_reports?(reports_scope)
+ complete? && builds.latest.with_reports(reports_scope).exists?
end
def test_reports
Gitlab::Ci::Reports::TestReports.new.tap do |test_reports|
- builds.latest.with_test_reports.each do |build|
+ builds.latest.with_reports(Ci::JobArtifact.test_reports).each do |build|
build.collect_test_reports!(test_reports)
end
end
@@ -675,9 +712,18 @@ module Ci
end
end
+ # Returns the modified paths.
+ #
+ # The returned value is
+ # * Array: List of modified paths that should be evaluated
+ # * nil: Modified path can not be evaluated
def modified_paths
strong_memoize(:modified_paths) do
- push_details.modified_paths
+ if merge_request_event?
+ merge_request.modified_paths
+ elsif branch_updated?
+ push_details.modified_paths
+ end
end
end
@@ -685,6 +731,50 @@ module Ci
ref == project.default_branch
end
+ def triggered_by_merge_request?
+ merge_request_event? && merge_request_id.present?
+ end
+
+ def detached_merge_request_pipeline?
+ triggered_by_merge_request? && target_sha.nil?
+ end
+
+ def legacy_detached_merge_request_pipeline?
+ detached_merge_request_pipeline? && !merge_request_ref?
+ end
+
+ def merge_request_pipeline?
+ triggered_by_merge_request? && target_sha.present?
+ end
+
+ def merge_request_ref?
+ MergeRequest.merge_request_ref?(ref)
+ end
+
+ def matches_sha_or_source_sha?(sha)
+ self.sha == sha || self.source_sha == sha
+ end
+
+ def triggered_by?(current_user)
+ user == current_user
+ end
+
+ def source_ref
+ if triggered_by_merge_request?
+ merge_request.source_branch
+ else
+ ref
+ end
+ end
+
+ def source_ref_slug
+ Gitlab::Utils.slugify(source_ref.to_s)
+ end
+
+ def find_stage_by_name!(name)
+ stages.find_by!(name: name)
+ end
+
private
def ci_yaml_from_repo
@@ -716,14 +806,18 @@ module Ci
end
def git_ref
- if branch?
- Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
- elsif merge_request?
- Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
- elsif tag?
- Gitlab::Git::TAG_REF_PREFIX + ref.to_s
- else
- raise ArgumentError, 'Invalid pipeline type!'
+ strong_memoize(:git_ref) do
+ if merge_request_event?
+ ##
+ # In the future, we're going to change this ref to
+ # merge request's merged reference, such as "refs/merge-requests/:iid/merge".
+ # In order to do that, we have to update GitLab-Runner's source pulling
+ # logic.
+ # See https://gitlab.com/gitlab-org/gitlab-runner/merge_requests/1092
+ Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
+ else
+ super
+ end
end
end
@@ -738,11 +832,5 @@ module Ci
project.repository.keep_around(self.sha, self.before_sha)
end
-
- def valid_source
- if source.nil? || source == "unknown"
- errors.add(:source, "invalid source")
- end
- end
end
end
diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb
new file mode 100644
index 00000000000..65466a8c6f8
--- /dev/null
+++ b/app/models/ci/pipeline_chat_data.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Ci
+ class PipelineChatData < ApplicationRecord
+ self.table_name = 'ci_pipeline_chat_data'
+
+ belongs_to :chat_name
+
+ validates :pipeline_id, presence: true
+ validates :chat_name_id, presence: true
+ validates :response_url, presence: true
+ end
+end
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 2994aaae4aa..571c4271475 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -22,7 +22,8 @@ module Ci
schedule: 4,
api: 5,
external: 6,
- merge_request: 10
+ chat: 8,
+ merge_request_event: 10
}
end
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index 1c1f203bdb2..c0a0ca9acf6 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class PipelineSchedule < ActiveRecord::Base
+ class PipelineSchedule < ApplicationRecord
extend Gitlab::Ci::Model
include Importable
include IgnorableColumn
+ include StripAttribute
ignore_column :deleted_at
@@ -22,6 +23,8 @@ module Ci
before_save :set_next_run_at
+ strip_attributes :cron
+
scope :active, -> { where(active: true) }
scope :inactive, -> { where(active: false) }
diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb
index fbb9987cab2..be6e5e76c31 100644
--- a/app/models/ci/pipeline_schedule_variable.rb
+++ b/app/models/ci/pipeline_schedule_variable.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class PipelineScheduleVariable < ActiveRecord::Base
+ class PipelineScheduleVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb
index 08514d6af4e..51a6272e1ff 100644
--- a/app/models/ci/pipeline_variable.rb
+++ b/app/models/ci/pipeline_variable.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class PipelineVariable < ActiveRecord::Base
+ class PipelineVariable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 2693386443a..07d00503861 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Runner < ActiveRecord::Base
+ class Runner < ApplicationRecord
extend Gitlab::Ci::Model
include Gitlab::SQL::Pattern
include IgnorableColumn
@@ -10,7 +10,7 @@ module Ci
include FromUnion
include TokenAuthenticatable
- add_authentication_token_field :token, encrypted: true, migrating: true
+ add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
enum access_level: {
not_protected: 0,
@@ -58,8 +58,7 @@ module Ci
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`
scope :deprecated_shared, -> { instance_type }
- # this should get replaced with `project_type.or(group_type)` once using Rails5
- scope :deprecated_specific, -> { where(runner_type: [runner_types[:project_type], runner_types[:group_type]]) }
+ scope :deprecated_specific, -> { project_type.or(group_type) }
scope :belonging_to_project, -> (project_id) {
joins(:runner_projects).where(ci_runner_projects: { project_id: project_id })
@@ -67,7 +66,7 @@ module Ci
scope :belonging_to_parent_group_of_project, -> (project_id) {
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
- hierarchy_groups = Gitlab::GroupHierarchy.new(project_groups).base_and_ancestors
+ hierarchy_groups = Gitlab::ObjectHierarchy.new(project_groups).base_and_ancestors
joins(:groups).where(namespaces: { id: hierarchy_groups })
}
@@ -98,6 +97,7 @@ module Ci
scope :order_contacted_at_asc, -> { order(contacted_at: :asc) }
scope :order_created_at_desc, -> { order(created_at: :desc) }
+ scope :with_tags, -> { preload(:tags) }
validate :tag_constraints
validates :access_level, presence: true
@@ -257,6 +257,10 @@ module Ci
end
end
+ def uncached_contacted_at
+ read_attribute(:contacted_at)
+ end
+
private
def cleanup_runner_queue
diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb
index 22b80b98551..6903e8a21a1 100644
--- a/app/models/ci/runner_namespace.rb
+++ b/app/models/ci/runner_namespace.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class RunnerNamespace < ActiveRecord::Base
+ class RunnerNamespace < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :runner, inverse_of: :runner_namespaces, validate: true
diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb
index 1a718d24141..f5bd50dc5a3 100644
--- a/app/models/ci/runner_project.rb
+++ b/app/models/ci/runner_project.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class RunnerProject < ActiveRecord::Base
+ class RunnerProject < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :runner, inverse_of: :runner_projects
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 58f3fe2460a..d90339d90dc 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class Stage < ActiveRecord::Base
+ class Stage < ApplicationRecord
extend Gitlab::Ci::Model
include Importable
include HasStatus
@@ -14,6 +14,7 @@ module Ci
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
+ has_many :bridges, foreign_key: :stage_id
with_options unless: :importing? do
validates :project, presence: true
@@ -38,10 +39,14 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition created: :pending
+ transition [:created, :preparing] => :pending
transition [:success, :failed, :canceled, :skipped] => :running
end
+ event :prepare do
+ transition any - [:preparing] => :preparing
+ end
+
event :run do
transition any - [:running] => :running
end
@@ -75,6 +80,7 @@ module Ci
retry_optimistic_lock(self) do
case statuses.latest.status
when 'created' then nil
+ when 'preparing' then prepare
when 'pending' then enqueue
when 'running' then run
when 'success' then succeed
@@ -114,5 +120,9 @@ module Ci
.new(self, current_user)
.fabricate!
end
+
+ def manual_playable?
+ blocked? || skipped?
+ end
end
end
diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb
index 55db42162ca..8927bb9bc18 100644
--- a/app/models/ci/trigger.rb
+++ b/app/models/ci/trigger.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
module Ci
- class Trigger < ActiveRecord::Base
+ class Trigger < ApplicationRecord
extend Gitlab::Ci::Model
include IgnorableColumn
+ include Presentable
ignore_column :deleted_at
@@ -29,7 +30,7 @@ module Ci
end
def short_token
- token[0...4]
+ token[0...4] if token.present?
end
def legacy?
diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb
index 0b52c690e93..5daf3dd192d 100644
--- a/app/models/ci/trigger_request.rb
+++ b/app/models/ci/trigger_request.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Ci
- class TriggerRequest < ActiveRecord::Base
+ class TriggerRequest < ApplicationRecord
extend Gitlab::Ci::Model
belongs_to :trigger
diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb
index 524d79014f8..a77bbef0fca 100644
--- a/app/models/ci/variable.rb
+++ b/app/models/ci/variable.rb
@@ -1,10 +1,11 @@
# frozen_string_literal: true
module Ci
- class Variable < ActiveRecord::Base
+ class Variable < ApplicationRecord
extend Gitlab::Ci::Model
include HasVariable
include Presentable
+ include Maskable
belongs_to :project
diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb
index 74ef7c7e145..d6a7d1d2bdd 100644
--- a/app/models/clusters/applications/cert_manager.rb
+++ b/app/models/clusters/applications/cert_manager.rb
@@ -2,8 +2,8 @@
module Clusters
module Applications
- class CertManager < ActiveRecord::Base
- VERSION = 'v0.5.0'.freeze
+ class CertManager < ApplicationRecord
+ VERSION = 'v0.5.2'.freeze
self.table_name = 'clusters_applications_cert_managers'
@@ -24,6 +24,12 @@ module Clusters
'stable/cert-manager'
end
+ # We will implement this in future MRs.
+ # Need to reverse postinstall step
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: 'certmanager',
diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb
index 423071ec024..a83d06c4b00 100644
--- a/app/models/clusters/applications/helm.rb
+++ b/app/models/clusters/applications/helm.rb
@@ -4,7 +4,7 @@ require 'openssl'
module Clusters
module Applications
- class Helm < ActiveRecord::Base
+ class Helm < ApplicationRecord
self.table_name = 'clusters_applications_helm'
attr_encrypted :ca_key,
@@ -29,6 +29,13 @@ module Clusters
self.status = 'installable' if cluster&.platform_kubernetes_active?
end
+ # We will implement this in future MRs.
+ # Basically we need to check all other applications are not installed
+ # first.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InitCommand.new(
name: name,
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 8f8790585a3..a1023f44049 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -2,8 +2,8 @@
module Clusters
module Applications
- class Ingress < ActiveRecord::Base
- VERSION = '0.23.0'.freeze
+ class Ingress < ApplicationRecord
+ VERSION = '1.1.2'.freeze
self.table_name = 'clusters_applications_ingress'
@@ -23,7 +23,7 @@ module Clusters
FETCH_IP_ADDRESS_DELAY = 30.seconds
state_machine :status do
- before_transition any => [:installed] do |application|
+ after_transition any => [:installed] do |application|
application.run_after_commit do
ClusterWaitForIngressIpAddressWorker.perform_in(
FETCH_IP_ADDRESS_DELAY, application.name, application.id)
@@ -35,6 +35,13 @@ module Clusters
'stable/nginx-ingress'
end
+ # We will implement this in future MRs.
+ # Basically we need to check all dependent applications are not installed
+ # first.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -48,6 +55,7 @@ module Clusters
def schedule_status_update
return unless installed?
return if external_ip
+ return if external_hostname
ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 421a923d386..bd9c453e2a4 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -1,9 +1,11 @@
# frozen_string_literal: true
+require 'securerandom'
+
module Clusters
module Applications
- class Jupyter < ActiveRecord::Base
- VERSION = 'v0.6'.freeze
+ class Jupyter < ApplicationRecord
+ VERSION = '0.9-174bbd5'.freeze
self.table_name = 'clusters_applications_jupyter'
@@ -18,8 +20,10 @@ module Clusters
def set_initial_status
return unless not_installable?
+ return unless cluster&.application_ingress_available?
- if cluster&.application_ingress_available? && cluster.application_ingress.external_ip
+ ingress = cluster.application_ingress
+ if ingress.external_ip || ingress.external_hostname
self.status = 'installable'
end
end
@@ -36,6 +40,12 @@ module Clusters
content_values.to_yaml
end
+ # Will be addressed in future MRs
+ # We need to investigate and document what will be permanently deleted.
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -72,20 +82,32 @@ module Clusters
"secretToken" => secret_token
},
"auth" => {
+ "state" => {
+ "cryptoKey" => crypto_key
+ },
"gitlab" => {
"clientId" => oauth_application.uid,
"clientSecret" => oauth_application.secret,
- "callbackUrl" => callback_url
+ "callbackUrl" => callback_url,
+ "gitlabProjectIdWhitelist" => [project_id]
}
},
"singleuser" => {
"extraEnv" => {
- "GITLAB_CLUSTER_ID" => cluster.id
+ "GITLAB_CLUSTER_ID" => cluster.id.to_s
}
}
}
end
+ def crypto_key
+ @crypto_key ||= SecureRandom.hex(32)
+ end
+
+ def project_id
+ cluster&.project&.id
+ end
+
def gitlab_url
Gitlab.config.gitlab.url
end
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 0c72d7d8340..9fbf5d8af04 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -2,10 +2,10 @@
module Clusters
module Applications
- class Knative < ActiveRecord::Base
- VERSION = '0.2.2'.freeze
+ class Knative < ApplicationRecord
+ VERSION = '0.5.0'.freeze
REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'.freeze
-
+ METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'.freeze
FETCH_IP_ADDRESS_DELAY = 30.seconds
self.table_name = 'clusters_applications_knative'
@@ -19,8 +19,15 @@ module Clusters
self.reactive_cache_key = ->(knative) { [knative.class.model_name.singular, knative.id] }
+ def set_initial_status
+ return unless not_installable?
+ return unless verify_cluster?
+
+ self.status = 'installable'
+ end
+
state_machine :status do
- before_transition any => [:installed] do |application|
+ after_transition any => [:installed] do |application|
application.run_after_commit do
ClusterWaitForIngressIpAddressWorker.perform_in(
FETCH_IP_ADDRESS_DELAY, application.name, application.id)
@@ -34,6 +41,8 @@ module Clusters
scope :for_cluster, -> (cluster) { where(cluster: cluster) }
+ after_save :clear_reactive_cache!
+
def chart
'knative/knative'
end
@@ -42,6 +51,12 @@ module Clusters
{ "domain" => hostname }.to_yaml
end
+ # Handled in a new issue:
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/59369
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -49,13 +64,15 @@ module Clusters
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
files: files,
- repository: REPOSITORY
+ repository: REPOSITORY,
+ postinstall: install_knative_metrics
)
end
def schedule_status_update
return unless installed?
return if external_ip
+ return if external_hostname
ClusterWaitForIngressIpAddressWorker.perform_async(name, id)
end
@@ -71,15 +88,15 @@ module Clusters
end
def calculate_reactive_cache
- { services: read_services }
+ { services: read_services, pods: read_pods }
end
def ingress_service
- cluster.kubeclient.get_service('knative-ingressgateway', 'istio-system')
+ cluster.kubeclient.get_service('istio-ingressgateway', 'istio-system')
end
def services_for(ns: namespace)
- return unless services
+ return [] unless services
return [] unless ns
services.select do |service|
@@ -87,13 +104,35 @@ module Clusters
end
end
+ def service_pod_details(ns, service)
+ with_reactive_cache do |data|
+ data[:pods].select { |pod| filter_pods(pod, ns, service) }
+ end
+ end
+
private
+ def read_pods
+ cluster.kubeclient.core_client.get_pods.as_json
+ end
+
+ def filter_pods(pod, namespace, service)
+ pod["metadata"]["namespace"] == namespace && pod["metadata"]["labels"]["serving.knative.dev/service"] == service
+ end
+
def read_services
client.get_services.as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
+
+ def install_knative_metrics
+ ["kubectl apply -f #{METRICS_CONFIG}"] if cluster.application_prometheus_available?
+ end
+
+ def verify_cluster?
+ cluster&.application_helm_available? && cluster&.platform_kubernetes_rbac?
+ end
end
end
end
diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb
index 46d0388a464..a6b7617b830 100644
--- a/app/models/clusters/applications/prometheus.rb
+++ b/app/models/clusters/applications/prometheus.rb
@@ -2,10 +2,10 @@
module Clusters
module Applications
- class Prometheus < ActiveRecord::Base
+ class Prometheus < ApplicationRecord
include PrometheusAdapter
- VERSION = '6.7.3'.freeze
+ VERSION = '6.7.3'
self.table_name = 'clusters_applications_prometheus'
@@ -16,22 +16,16 @@ module Clusters
default_value_for :version, VERSION
+ after_destroy :disable_prometheus_integration
+
state_machine :status do
after_transition any => [:installed] do |application|
application.cluster.projects.each do |project|
- project.find_or_initialize_service('prometheus').update(active: true)
+ project.find_or_initialize_service('prometheus').update!(active: true)
end
end
end
- def ready_status
- [:installed]
- end
-
- def ready?
- ready_status.include?(status_name)
- end
-
def chart
'stable/prometheus'
end
@@ -50,10 +44,37 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
+ files: files,
+ postinstall: install_knative_metrics
+ )
+ end
+
+ def uninstall_command
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: name,
+ rbac: cluster.platform_kubernetes_rbac?,
files: files
)
end
+ def upgrade_command(values)
+ ::Gitlab::Kubernetes::Helm::InstallCommand.new(
+ name: name,
+ version: VERSION,
+ rbac: cluster.platform_kubernetes_rbac?,
+ chart: chart,
+ files: files_with_replaced_values(values)
+ )
+ end
+
+ # Returns a copy of files where the values of 'values.yaml'
+ # are replaced by the argument.
+ #
+ # See #values for the data format required
+ def files_with_replaced_values(replaced_values)
+ files.merge('values.yaml': replaced_values)
+ end
+
def prometheus_client
return unless kube_client
@@ -71,9 +92,19 @@ module Clusters
private
+ def disable_prometheus_integration
+ cluster.projects.each do |project|
+ project.prometheus_service&.update!(active: false)
+ end
+ end
+
def kube_client
cluster&.kubeclient&.core_client
end
+
+ def install_knative_metrics
+ ["kubectl apply -f #{Clusters::Applications::Knative::METRICS_CONFIG}"] if cluster.application_knative_available?
+ end
end
end
end
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index c931b340b24..0dff91c3fe2 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -2,8 +2,8 @@
module Clusters
module Applications
- class Runner < ActiveRecord::Base
- VERSION = '0.1.39'.freeze
+ class Runner < ApplicationRecord
+ VERSION = '0.5.1'.freeze
self.table_name = 'clusters_applications_runners'
@@ -13,7 +13,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationData
belongs_to :runner, class_name: 'Ci::Runner', foreign_key: :runner_id
- delegate :project, to: :cluster
+ delegate :project, :group, to: :cluster
default_value_for :version, VERSION
@@ -29,6 +29,13 @@ module Clusters
content_values.to_yaml
end
+ # Need to investigate if pipelines run by this runner will stop upon the
+ # executor pod stopping
+ # I.e.run a pipeline, and uninstall runner while pipeline is running
+ def allowed_to_uninstall?
+ false
+ end
+
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
@@ -55,12 +62,19 @@ module Clusters
end
def runner_create_params
- {
+ attributes = {
name: 'kubernetes-cluster',
- runner_type: :project_type,
- tag_list: %w(kubernetes cluster),
- projects: [project]
+ runner_type: cluster.cluster_type,
+ tag_list: %w[kubernetes cluster]
}
+
+ if cluster.group_type?
+ attributes[:groups] = [group]
+ elsif cluster.project_type?
+ attributes[:projects] = [project]
+ end
+
+ attributes
end
def gitlab_url
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 7fe43cd2de0..57a1e461b2d 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -1,23 +1,28 @@
# frozen_string_literal: true
module Clusters
- class Cluster < ActiveRecord::Base
+ class Cluster < ApplicationRecord
include Presentable
include Gitlab::Utils::StrongMemoize
include FromUnion
+ include ReactiveCaching
self.table_name = 'clusters'
+ self.reactive_cache_key = -> (cluster) { [cluster.class.model_name.singular, cluster.id] }
+ PROJECT_ONLY_APPLICATIONS = {
+ Applications::Jupyter.application_name => Applications::Jupyter,
+ Applications::Knative.application_name => Applications::Knative
+ }.freeze
APPLICATIONS = {
Applications::Helm.application_name => Applications::Helm,
Applications::Ingress.application_name => Applications::Ingress,
Applications::CertManager.application_name => Applications::CertManager,
- Applications::Prometheus.application_name => Applications::Prometheus,
Applications::Runner.application_name => Applications::Runner,
- Applications::Jupyter.application_name => Applications::Jupyter,
- Applications::Knative.application_name => Applications::Knative
- }.freeze
+ Applications::Prometheus.application_name => Applications::Prometheus
+ }.merge(PROJECT_ONLY_APPLICATIONS).freeze
DEFAULT_ENVIRONMENT = '*'.freeze
+ KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'.freeze
belongs_to :user
@@ -42,18 +47,20 @@ module Clusters
has_one :application_knative, class_name: 'Clusters::Applications::Knative'
has_many :kubernetes_namespaces
- has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace'
accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true
validates :name, cluster_name: true
validates :cluster_type, presence: true
- validate :restrict_modification, on: :update
+ validates :domain, allow_blank: true, hostname: { allow_numeric_hostname: true }
+ validate :restrict_modification, on: :update
validate :no_groups, unless: :group_type?
validate :no_projects, unless: :project_type?
+ after_save :clear_reactive_cache!
+
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, allow_nil: true
delegate :on_creation?, to: :provider, allow_nil: true
@@ -63,6 +70,12 @@ module Clusters
delegate :available?, to: :application_helm, prefix: true, allow_nil: true
delegate :available?, to: :application_ingress, prefix: true, allow_nil: true
delegate :available?, to: :application_prometheus, prefix: true, allow_nil: true
+ delegate :available?, to: :application_knative, prefix: true, allow_nil: true
+ delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true
+ delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true
+
+ alias_attribute :base_domain, :domain
+ alias_attribute :provided_by_user?, :user?
enum cluster_type: {
instance_type: 1,
@@ -84,6 +97,7 @@ module Clusters
scope :user_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:user]) }
scope :gcp_provided, -> { where(provider_type: ::Clusters::Cluster.provider_types[:gcp]) }
scope :gcp_installed, -> { gcp_provided.includes(:provider_gcp).where(cluster_providers_gcp: { status: ::Clusters::Providers::Gcp.state_machines[:status].states[:created].value }) }
+ scope :managed, -> { where(managed: true) }
scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) }
@@ -93,33 +107,39 @@ module Clusters
where('NOT EXISTS (?)', subquery)
end
- scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.installed) }
+ scope :with_knative_installed, -> { joins(:application_knative).merge(Clusters::Applications::Knative.available) }
scope :preload_knative, -> {
preload(
- :kubernetes_namespace,
+ :kubernetes_namespaces,
:platform_kubernetes,
:application_knative
)
}
def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc)
+ return [] if clusterable.is_a?(Instance)
+
hierarchy_groups = clusterable.ancestors_upto(hierarchy_order: hierarchy_order).eager_load(:clusters)
hierarchy_groups = hierarchy_groups.merge(current_scope) if current_scope
- hierarchy_groups.flat_map(&:clusters)
+ hierarchy_groups.flat_map(&:clusters) + Instance.new.clusters
end
def status_name
- if provider
- provider.status_name
- else
- :created
+ provider&.status_name || connection_status.presence || :created
+ end
+
+ def connection_status
+ with_reactive_cache do |data|
+ data[:connection_status]
end
end
- def created?
- status_name == :created
+ def calculate_reactive_cache
+ return unless enabled?
+
+ { connection_status: retrieve_connection_status }
end
def applications
@@ -142,10 +162,6 @@ module Clusters
return platform_kubernetes if kubernetes?
end
- def managed?
- !user?
- end
-
def all_projects
if project_type?
projects
@@ -170,20 +186,24 @@ module Clusters
end
alias_method :group, :first_group
+ def instance
+ Instance.new if instance_type?
+ end
+
def kubeclient
platform_kubernetes.kubeclient if kubernetes?
end
+ def kubernetes_namespace_for(project)
+ find_or_initialize_kubernetes_namespace_for_project(project).namespace
+ end
+
def find_or_initialize_kubernetes_namespace_for_project(project)
- if project_type?
- kubernetes_namespaces.find_or_initialize_by(
- project: project,
- cluster_project: cluster_project
- )
- else
- kubernetes_namespaces.find_or_initialize_by(
- project: project
- )
+ attributes = { project: project }
+ attributes[:cluster_project] = cluster_project if project_type?
+
+ kubernetes_namespaces.find_or_initialize_by(attributes).tap do |namespace|
+ namespace.set_defaults
end
end
@@ -191,8 +211,69 @@ module Clusters
project_type?
end
+ def kube_ingress_domain
+ @kube_ingress_domain ||= domain.presence || instance_domain
+ end
+
+ def predefined_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless kube_ingress_domain
+
+ variables.append(key: KUBE_INGRESS_BASE_DOMAIN, value: kube_ingress_domain)
+ end
+ end
+
private
+ def instance_domain
+ @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
+ end
+
+ def retrieve_connection_status
+ kubeclient.core_client.discover
+ rescue *Gitlab::Kubernetes::Errors::CONNECTION
+ :unreachable
+ rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION
+ :authentication_failure
+ rescue Kubeclient::HttpError => e
+ kubeclient_error_status(e.message)
+ rescue => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id })
+
+ :unknown_failure
+ else
+ :connected
+ end
+
+ # KubeClient uses the same error class
+ # For connection errors (eg. timeout) and
+ # for Kubernetes errors.
+ def kubeclient_error_status(message)
+ if message&.match?(/timed out|timeout/i)
+ :unreachable
+ else
+ :authentication_failure
+ end
+ end
+
+ # To keep backward compatibility with AUTO_DEVOPS_DOMAIN
+ # environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
+ # is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
+ # ProjectAutoDevops#Domain, project variables or group variables,
+ # as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL
+ #
+ # This method should is scheduled to be removed on
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/56959
+ def legacy_auto_devops_domain
+ if project_type?
+ project&.auto_devops&.domain.presence ||
+ project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence ||
+ project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
+ elsif group_type?
+ group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
+ end
+ end
+
def restrict_modification
if provider&.on_creation?
errors.add(:base, "cannot modify during creation")
diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb
index 683b45331f6..4514498b84b 100644
--- a/app/models/clusters/concerns/application_core.rb
+++ b/app/models/clusters/concerns/application_core.rb
@@ -18,6 +18,16 @@ module Clusters
self.status = 'installable' if cluster&.application_helm_available?
end
+ def can_uninstall?
+ allowed_to_uninstall?
+ end
+
+ # All new applications should uninstall by default
+ # Override if there's dependencies that needs to be uninstalled first
+ def allowed_to_uninstall?
+ true
+ end
+
def self.application_name
self.to_s.demodulize.underscore
end
@@ -30,6 +40,12 @@ module Clusters
# Override if you need extra data synchronized
# from K8s after installation
end
+
+ def update_command
+ install_command.tap do |command|
+ command.version = version
+ end
+ end
end
end
end
diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb
index 52498f123ff..3479fea415e 100644
--- a/app/models/clusters/concerns/application_data.rb
+++ b/app/models/clusters/concerns/application_data.rb
@@ -3,48 +3,52 @@
module Clusters
module Concerns
module ApplicationData
- extend ActiveSupport::Concern
+ def uninstall_command
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: name,
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ )
+ end
- included do
- def repository
- nil
- end
+ def repository
+ nil
+ end
- def values
- File.read(chart_values_file)
- end
+ def values
+ File.read(chart_values_file)
+ end
- def files
- @files ||= begin
- files = { 'values.yaml': values }
+ def files
+ @files ||= begin
+ files = { 'values.yaml': values }
- files.merge!(certificate_files) if cluster.application_helm.has_ssl?
+ files.merge!(certificate_files) if cluster.application_helm.has_ssl?
- files
- end
+ files
end
+ end
- private
+ private
- def certificate_files
- {
- 'ca.pem': ca_cert,
- 'cert.pem': helm_cert.cert_string,
- 'key.pem': helm_cert.key_string
- }
- end
+ def certificate_files
+ {
+ 'ca.pem': ca_cert,
+ 'cert.pem': helm_cert.cert_string,
+ 'key.pem': helm_cert.key_string
+ }
+ end
- def ca_cert
- cluster.application_helm.ca_cert
- end
+ def ca_cert
+ cluster.application_helm.ca_cert
+ end
- def helm_cert
- @helm_cert ||= cluster.application_helm.issue_client_cert
- end
+ def helm_cert
+ @helm_cert ||= cluster.application_helm.issue_client_cert
+ end
- def chart_values_file
- "#{Rails.root}/vendor/#{name}/values.yaml"
- end
+ def chart_values_file
+ "#{Rails.root}/vendor/#{name}/values.yaml"
end
end
end
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 0e74cce29b7..54a3dda6d75 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -6,7 +6,14 @@ module Clusters
extend ActiveSupport::Concern
included do
- scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) }
+ scope :available, -> do
+ where(
+ status: [
+ self.state_machines[:status].states[:installed].value,
+ self.state_machines[:status].states[:updated].value
+ ]
+ )
+ end
state_machine :status, initial: :not_installable do
state :not_installable, value: -2
@@ -18,9 +25,11 @@ module Clusters
state :updating, value: 4
state :updated, value: 5
state :update_errored, value: 6
+ state :uninstalling, value: 7
+ state :uninstall_errored, value: 8
event :make_scheduled do
- transition [:installable, :errored] => :scheduled
+ transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled
end
event :make_installing do
@@ -29,24 +38,27 @@ module Clusters
event :make_installed do
transition [:installing] => :installed
+ transition [:updating] => :updated
end
event :make_errored do
- transition any => :errored
+ transition any - [:updating, :uninstalling] => :errored
+ transition [:updating] => :update_errored
+ transition [:uninstalling] => :uninstall_errored
end
event :make_updating do
- transition [:installed, :updated, :update_errored] => :updating
- end
-
- event :make_updated do
- transition [:updating] => :updated
+ transition [:installed, :updated, :update_errored, :scheduled] => :updating
end
event :make_update_errored do
transition any => :update_errored
end
+ event :make_uninstalling do
+ transition [:scheduled] => :uninstalling
+ end
+
before_transition any => [:scheduled] do |app_status, _|
app_status.status_reason = nil
end
@@ -60,7 +72,7 @@ module Clusters
app_status.status_reason = nil
end
- before_transition any => [:update_errored] do |app_status, transition|
+ before_transition any => [:update_errored, :uninstall_errored] do |app_status, transition|
status_reason = transition.args.first
app_status.status_reason = status_reason if status_reason
end
@@ -74,9 +86,17 @@ module Clusters
end
end
+ def updateable?
+ installed? || updated? || update_errored?
+ end
+
def available?
installed? || updated?
end
+
+ def update_in_progress?
+ updating?
+ end
end
end
end
diff --git a/app/models/clusters/concerns/application_version.rb b/app/models/clusters/concerns/application_version.rb
index ccad74dc35a..db94e8e08c9 100644
--- a/app/models/clusters/concerns/application_version.rb
+++ b/app/models/clusters/concerns/application_version.rb
@@ -7,11 +7,15 @@ module Clusters
included do
state_machine :status do
- after_transition any => [:installing] do |application|
- application.update(version: application.class.const_get(:VERSION))
+ before_transition any => [:installed, :updated] do |application|
+ application.version = application.class.const_get(:VERSION)
end
end
end
+
+ def update_available?
+ version != self.class.const_get(:VERSION)
+ end
end
end
end
diff --git a/app/models/clusters/group.rb b/app/models/clusters/group.rb
index 2b08a9e47f0..27f39b53579 100644
--- a/app/models/clusters/group.rb
+++ b/app/models/clusters/group.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Clusters
- class Group < ActiveRecord::Base
+ class Group < ApplicationRecord
self.table_name = 'cluster_groups'
belongs_to :cluster, class_name: 'Clusters::Cluster'
diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb
new file mode 100644
index 00000000000..d8a888d53ba
--- /dev/null
+++ b/app/models/clusters/instance.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ class Instance
+ def clusters
+ Clusters::Cluster.instance_type
+ end
+
+ def feature_available?(feature)
+ ::Feature.enabled?(feature, default_enabled: true)
+ end
+
+ def self.enabled?
+ ::Feature.enabled?(:instance_clusters, default_enabled: true)
+ end
+ end
+end
diff --git a/app/models/clusters/kubernetes_namespace.rb b/app/models/clusters/kubernetes_namespace.rb
index 73da6cb37d7..b0c4900546e 100644
--- a/app/models/clusters/kubernetes_namespace.rb
+++ b/app/models/clusters/kubernetes_namespace.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Clusters
- class KubernetesNamespace < ActiveRecord::Base
+ class KubernetesNamespace < ApplicationRecord
include Gitlab::Kubernetes
self.table_name = 'clusters_kubernetes_namespaces'
@@ -37,7 +37,7 @@ module Clusters
variables
.append(key: 'KUBE_SERVICE_ACCOUNT', value: service_account_name.to_s)
.append(key: 'KUBE_NAMESPACE', value: namespace.to_s)
- .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false)
+ .append(key: 'KUBE_TOKEN', value: service_account_token.to_s, public: false, masked: true)
.append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
end
end
diff --git a/app/models/clusters/platforms/kubernetes.rb b/app/models/clusters/platforms/kubernetes.rb
index 867f0edcb07..9b951578aee 100644
--- a/app/models/clusters/platforms/kubernetes.rb
+++ b/app/models/clusters/platforms/kubernetes.rb
@@ -2,7 +2,7 @@
module Clusters
module Platforms
- class Kubernetes < ActiveRecord::Base
+ class Kubernetes < ApplicationRecord
include Gitlab::Kubernetes
include ReactiveCaching
include EnumWithNil
@@ -41,8 +41,9 @@ module Clusters
validate :no_namespace, unless: :allow_user_defined_namespace?
# We expect to be `active?` only when enabled and cluster is created (the api_url is assigned)
- validates :api_url, url: true, presence: true
+ validates :api_url, public_url: true, presence: true
validates :token, presence: true
+ validates :ca_cert, certificate: true, allow_blank: true, if: :ca_cert_changed?
validate :prevent_modification, on: :update
@@ -51,11 +52,14 @@ module Clusters
alias_attribute :ca_pem, :ca_cert
- delegate :project, to: :cluster, allow_nil: true
delegate :enabled?, to: :cluster, allow_nil: true
- delegate :managed?, to: :cluster, allow_nil: true
+ delegate :provided_by_user?, to: :cluster, allow_nil: true
delegate :allow_user_defined_namespace?, to: :cluster, allow_nil: true
- delegate :kubernetes_namespace, to: :cluster
+
+ # This is just to maintain compatibility with KubernetesService, which
+ # will be removed in https://gitlab.com/gitlab-org/gitlab-ce/issues/39217.
+ # It can be removed once KubernetesService is gone.
+ delegate :kubernetes_namespace_for, to: :cluster, allow_nil: true
alias_method :active?, :enabled?
@@ -65,13 +69,7 @@ module Clusters
abac: 2
}
- def actual_namespace
- if namespace.present?
- namespace
- else
- default_namespace
- end
- end
+ default_value_for :authorization_type, :rbac
def predefined_variables(project:)
Gitlab::Ci::Variables::Collection.new.tap do |variables|
@@ -85,17 +83,22 @@ module Clusters
if kubernetes_namespace = cluster.kubernetes_namespaces.has_service_account_token.find_by(project: project)
variables.concat(kubernetes_namespace.predefined_variables)
- elsif cluster.project_type?
- # From 11.5, every Clusters::Project should have at least one
- # Clusters::KubernetesNamespace, so once migration has been completed,
- # this 'else' branch will be removed. For more information, please see
- # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/22433
+ elsif cluster.project_type? || !cluster.managed?
+ # As of 11.11 a user can create a cluster that they manage themselves,
+ # which replicates the existing project-level cluster behaviour.
+ # Once we have marked all project-level clusters that make use of this
+ # behaviour as "unmanaged", we can remove the `cluster.project_type?`
+ # check here.
+ project_namespace = cluster.kubernetes_namespace_for(project)
+
variables
.append(key: 'KUBE_URL', value: api_url)
- .append(key: 'KUBE_TOKEN', value: token, public: false)
- .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
- .append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
+ .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true)
+ .append(key: 'KUBE_NAMESPACE', value: project_namespace)
+ .append(key: 'KUBECONFIG', value: kubeconfig(project_namespace), public: false, file: true)
end
+
+ variables.concat(cluster.predefined_variables)
end
end
@@ -105,8 +108,10 @@ module Clusters
# short time later
def terminals(environment)
with_reactive_cache do |data|
- pods = filter_by_label(data[:pods], app: environment.slug)
- terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ project = environment.project
+
+ pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, cluster.kubernetes_namespace_for(project), pod) }.compact
terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
@@ -114,7 +119,7 @@ module Clusters
# Caches resources in the namespace so other calls don't need to block on
# network access
def calculate_reactive_cache
- return unless enabled? && project && !project.pending_delete?
+ return unless enabled?
# We may want to cache extra things in the future
{ pods: read_pods }
@@ -126,33 +131,16 @@ module Clusters
private
- def kubeconfig
+ def kubeconfig(namespace)
to_kubeconfig(
url: api_url,
- namespace: actual_namespace,
+ namespace: namespace,
token: token,
ca_pem: ca_pem)
end
- def default_namespace
- kubernetes_namespace&.namespace.presence || fallback_default_namespace
- end
-
- # DEPRECATED
- #
- # On 11.4 Clusters::KubernetesNamespace was introduced, this model will allow to
- # have multiple namespaces per project. This method will be removed after migration
- # has been completed.
- def fallback_default_namespace
- return unless project
-
- slug = "#{project.path}-#{project.id}".downcase
- Gitlab::NamespaceSanitizer.sanitize(slug)
- end
-
def build_kube_client!
raise "Incomplete settings" unless api_url
- raise "No namespace" if cluster.project_type? && actual_namespace.empty? # can probably remove this line once we remove #actual_namespace
unless (username && password) || token
raise "Either username/password or token is required to access API"
@@ -168,9 +156,13 @@ module Clusters
# Returns a hash of all pods in the namespace
def read_pods
- kubeclient = build_kube_client!
+ # TODO: The project lookup here should be moved (to environment?),
+ # which will enable reading pods from the correct namespace for group
+ # and instance clusters.
+ # This will be done in https://gitlab.com/gitlab-org/gitlab-ce/issues/61156
+ return [] unless cluster.project_type?
- kubeclient.get_pods(namespace: actual_namespace).as_json
+ kubeclient.get_pods(namespace: cluster.kubernetes_namespace_for(cluster.first_project)).as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
@@ -214,7 +206,7 @@ module Clusters
end
def prevent_modification
- return unless managed?
+ return if provided_by_user?
if api_url_changed? || token_changed? || ca_pem_changed?
errors.add(:base, _('Cannot modify managed Kubernetes cluster'))
@@ -225,10 +217,10 @@ module Clusters
end
def update_kubernetes_namespace
- return unless namespace_changed?
+ return unless saved_change_to_namespace?
run_after_commit do
- ClusterPlatformConfigureWorker.perform_async(cluster_id)
+ ClusterConfigureWorker.perform_async(cluster_id)
end
end
end
diff --git a/app/models/clusters/project.rb b/app/models/clusters/project.rb
index 15092b1c9d2..e0bf60164ba 100644
--- a/app/models/clusters/project.rb
+++ b/app/models/clusters/project.rb
@@ -1,13 +1,12 @@
# frozen_string_literal: true
module Clusters
- class Project < ActiveRecord::Base
+ class Project < ApplicationRecord
self.table_name = 'cluster_projects'
belongs_to :cluster, class_name: 'Clusters::Cluster'
belongs_to :project, class_name: '::Project'
has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id
- has_one :kubernetes_namespace, -> { order(id: :desc) }, class_name: 'Clusters::KubernetesNamespace', foreign_key: :cluster_project_id
end
end
diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb
index 16b59cd9d14..390748bf252 100644
--- a/app/models/clusters/providers/gcp.rb
+++ b/app/models/clusters/providers/gcp.rb
@@ -2,7 +2,7 @@
module Clusters
module Providers
- class Gcp < ActiveRecord::Base
+ class Gcp < ApplicationRecord
self.table_name = 'cluster_providers_gcp'
belongs_to :cluster, inverse_of: :provider_gcp, class_name: 'Clusters::Cluster'
diff --git a/app/models/commit.rb b/app/models/commit.rb
index a422a0995ff..f412d252e5c 100644
--- a/app/models/commit.rb
+++ b/app/models/commit.rb
@@ -11,6 +11,7 @@ class Commit
include Mentionable
include Referable
include StaticModel
+ include Presentable
include ::Gitlab::Utils::StrongMemoize
attr_mentionable :safe_message, pipeline: :single_line
@@ -304,7 +305,9 @@ class Commit
end
def last_pipeline
- @last_pipeline ||= pipelines.last
+ strong_memoize(:last_pipeline) do
+ pipelines.last
+ end
end
def status(ref = nil)
@@ -376,7 +379,7 @@ class Commit
end
def merge_commit?
- parents.size > 1
+ parent_ids.size > 1
end
def merged_merge_request(current_user)
@@ -469,6 +472,10 @@ class Commit
!!merged_merge_request(user)
end
+ def cache_key
+ "commit:#{sha}"
+ end
+
private
def commit_reference(from, referable_commit_id, full: false)
diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb
index e349f0fe971..e8df46e1cc3 100644
--- a/app/models/commit_collection.rb
+++ b/app/models/commit_collection.rb
@@ -3,6 +3,7 @@
# A collection of Commit instances for a specific project and Git reference.
class CommitCollection
include Enumerable
+ include Gitlab::Utils::StrongMemoize
attr_reader :project, :ref, :commits
@@ -19,6 +20,51 @@ class CommitCollection
commits.each(&block)
end
+ def committers
+ emails = without_merge_commits.map(&:committer_email).uniq
+
+ User.by_any_email(emails)
+ end
+
+ def without_merge_commits
+ strong_memoize(:without_merge_commits) do
+ # `#enrich!` the collection to ensure all commits contain
+ # the necessary parent data
+ enrich!.commits.reject(&:merge_commit?)
+ end
+ end
+
+ def unenriched
+ commits.reject(&:gitaly_commit?)
+ end
+
+ def fully_enriched?
+ unenriched.empty?
+ end
+
+ # Batch load any commits that are not backed by full gitaly data, and
+ # replace them in the collection.
+ def enrich!
+ # A project is needed in order to fetch data from gitaly. Projects
+ # can be absent from commits in certain rare situations (like when
+ # viewing a MR of a deleted fork). In these cases, assume that the
+ # enriched data is not needed.
+ return self if project.blank? || fully_enriched?
+
+ # Batch load full Commits from the repository
+ # and map to a Hash of id => Commit
+ replacements = Hash[unenriched.map do |c|
+ [c.id, Commit.lazy(project, c.id)]
+ end.compact]
+
+ # Replace the commits, keeping the same order
+ @commits = @commits.map do |c|
+ replacements.fetch(c.id, c)
+ end
+
+ self
+ end
+
# Sets the pipeline status for every commit.
#
# Setting this status ahead of time removes the need for running a query for
diff --git a/app/models/commit_range.rb b/app/models/commit_range.rb
index 094747ee48d..08ca86bc902 100644
--- a/app/models/commit_range.rb
+++ b/app/models/commit_range.rb
@@ -28,12 +28,12 @@ class CommitRange
# The beginning and ending refs can be named or SHAs, and
# the range notation can be double- or triple-dot.
- REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/
- PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/
+ REF_PATTERN = /[0-9a-zA-Z][0-9a-zA-Z_.-]*[0-9a-zA-Z\^]/.freeze
+ PATTERN = /#{REF_PATTERN}\.{2,3}#{REF_PATTERN}/.freeze
# In text references, the beginning and ending refs can only be SHAs
# between 7 and 40 hex characters.
- STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/
+ STRICT_PATTERN = /\h{7,40}\.{2,3}\h{7,40}/.freeze
def self.reference_prefix
'@'
@@ -134,25 +134,25 @@ class CommitRange
end
def sha_from
- return nil unless @commit_from
+ return unless @commit_from
@commit_from.id
end
def sha_to
- return nil unless @commit_to
+ return unless @commit_to
@commit_to.id
end
def sha_start
- return nil unless sha_from
+ return unless sha_from
exclude_start? ? sha_from + '^' : sha_from
end
def commit_start
- return nil unless sha_start
+ return unless sha_start
if exclude_start?
@commit_start ||= project.commit(sha_start)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 0f50bd39131..be6f3e9c5b0 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class CommitStatus < ActiveRecord::Base
+class CommitStatus < ApplicationRecord
include HasStatus
include Importable
include AfterCommitQueue
@@ -41,6 +41,7 @@ class CommitStatus < ActiveRecord::Base
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
+ scope :processables, -> { where(type: %w[Ci::Build Ci::Bridge]) }
# We use `CommitStatusEnums.failure_reasons` here so that EE can more easily
# extend this `Hash` with new values.
@@ -65,7 +66,10 @@ class CommitStatus < ActiveRecord::Base
end
event :enqueue do
- transition [:created, :skipped, :manual, :scheduled] => :pending
+ # A CommitStatus will never have prerequisites, but this event
+ # is shared by Ci::Build, which cannot progress unless prerequisites
+ # are satisfied.
+ transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending, unless: :any_unmet_prerequisites?
end
event :run do
@@ -73,26 +77,26 @@ class CommitStatus < ActiveRecord::Base
end
event :skip do
- transition [:created, :pending] => :skipped
+ transition [:created, :preparing, :pending] => :skipped
end
event :drop do
- transition [:created, :pending, :running, :scheduled] => :failed
+ transition [:created, :preparing, :pending, :running, :scheduled] => :failed
end
event :success do
- transition [:created, :pending, :running] => :success
+ transition [:created, :preparing, :pending, :running] => :success
end
event :cancel do
- transition [:created, :pending, :running, :manual, :scheduled] => :canceled
+ transition [:created, :preparing, :pending, :running, :manual, :scheduled] => :canceled
end
- before_transition [:created, :skipped, :manual, :scheduled] => :pending do |commit_status|
+ before_transition [:created, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status|
commit_status.queued_at = Time.now
end
- before_transition [:created, :pending] => :running do |commit_status|
+ before_transition [:created, :preparing, :pending] => :running do |commit_status|
commit_status.started_at = Time.now
end
@@ -136,7 +140,7 @@ class CommitStatus < ActiveRecord::Base
end
def locking_enabled?
- status_changed?
+ will_save_change_to_status?
end
def before_sha
@@ -179,6 +183,10 @@ class CommitStatus < ActiveRecord::Base
false
end
+ def any_unmet_prerequisites?
+ false
+ end
+
def auto_canceled?
canceled? && auto_canceled_by_id?
end
diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb
index 152105d9429..45e08fa18fe 100644
--- a/app/models/commit_status_enums.rb
+++ b/app/models/commit_status_enums.rb
@@ -14,7 +14,8 @@ module CommitStatusEnums
runner_unsupported: 6,
stale_schedule: 7,
job_execution_timeout: 8,
- archived_failure: 9
+ archived_failure: 9,
+ unmet_prerequisites: 10
}
end
end
diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb
index cbd63ba8876..7c9f579b480 100644
--- a/app/models/concerns/artifact_migratable.rb
+++ b/app/models/concerns/artifact_migratable.rb
@@ -13,7 +13,7 @@ module ArtifactMigratable
end
def artifacts?
- !artifacts_expired? && artifacts_file.exists?
+ !artifacts_expired? && artifacts_file&.exists?
end
def artifacts_metadata?
@@ -43,4 +43,16 @@ module ArtifactMigratable
def artifacts_size
read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i
end
+
+ def legacy_artifacts_file
+ return unless Feature.enabled?(:ci_enable_legacy_artifacts)
+
+ super
+ end
+
+ def legacy_artifacts_metadata
+ return unless Feature.enabled?(:ci_enable_legacy_artifacts)
+
+ super
+ end
end
diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb
index 4e15b60ccd1..dc1735a7e48 100644
--- a/app/models/concerns/atomic_internal_id.rb
+++ b/app/models/concerns/atomic_internal_id.rb
@@ -7,7 +7,7 @@
#
# For example, let's generate internal ids for Issue per Project:
# ```
-# class Issue < ActiveRecord::Base
+# class Issue < ApplicationRecord
# has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
# end
# ```
@@ -53,6 +53,20 @@ module AtomicInternalId
value
end
+
+ define_method("reset_#{scope}_#{column}") do
+ if value = read_attribute(column)
+ scope_value = association(scope).reader
+ scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value }
+ usage = self.class.table_name.to_sym
+
+ if InternalId.reset(self, scope_attrs, usage, value)
+ write_attribute(column, nil)
+ end
+ end
+
+ read_attribute(column)
+ end
end
end
end
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index b42236c1fa2..80278e07e65 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -43,7 +43,18 @@ module Avatarable
end
def avatar_path(only_path: true, size: nil)
- return unless self[:avatar].present?
+ unless self.try(:id)
+ return uncached_avatar_path(only_path: only_path, size: size)
+ end
+
+ # Cache this avatar path only within the request because avatars in
+ # object storage may be generated with time-limited, signed URLs.
+ key = "#{self.class.name}:#{self.id}:#{only_path}:#{size}"
+ Gitlab::SafeRequestStore[key] ||= uncached_avatar_path(only_path: only_path, size: size)
+ end
+
+ def uncached_avatar_path(only_path: true, size: nil)
+ return unless self.try(:avatar).present?
asset_host = ActionController::Base.asset_host
use_asset_host = asset_host.present?
@@ -80,7 +91,8 @@ module Avatarable
private
def retrieve_upload_from_batch(identifier)
- BatchLoader.for(identifier: identifier, model: self).batch(key: self.class) do |upload_params, loader, args|
+ BatchLoader.for(identifier: identifier, model: self)
+ .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args|
model_class = args[:key]
paths = upload_params.flat_map do |params|
params[:model].upload_paths(params[:identifier])
diff --git a/app/models/concerns/blob_language_from_git_attributes.rb b/app/models/concerns/blob_language_from_git_attributes.rb
index 70213d22147..56e1276a220 100644
--- a/app/models/concerns/blob_language_from_git_attributes.rb
+++ b/app/models/concerns/blob_language_from_git_attributes.rb
@@ -5,7 +5,7 @@ module BlobLanguageFromGitAttributes
extend ActiveSupport::Concern
def language_from_gitattributes
- return nil unless project
+ return unless project
repository = project.repository
repository.gitattribute(path, 'gitlab-language')
diff --git a/app/models/concerns/blob_like.rb b/app/models/concerns/blob_like.rb
index f20f01486a5..dc80f8d62f4 100644
--- a/app/models/concerns/blob_like.rb
+++ b/app/models/concerns/blob_like.rb
@@ -28,7 +28,7 @@ module BlobLike
nil
end
- def binary?
+ def binary_in_repo?
false
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index a8c9e54f00c..f90cd1ea690 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -7,15 +7,15 @@
# cache_markdown_field :foo
# cache_markdown_field :bar
# cache_markdown_field :baz, pipeline: :single_line
+# cache_markdown_field :baz, whitelisted: true
#
# Corresponding foo_html, bar_html and baz_html fields should exist.
module CacheMarkdownField
extend ActiveSupport::Concern
# Increment this number every time the renderer changes its output
- CACHE_REDCARPET_VERSION = 3
CACHE_COMMONMARK_VERSION_START = 10
- CACHE_COMMONMARK_VERSION = 12
+ CACHE_COMMONMARK_VERSION = 16
# changes to these attributes cause the cache to be invalidates
INVALIDATED_BY = %w[author project].freeze
@@ -38,18 +38,14 @@ module CacheMarkdownField
end
def html_fields
- markdown_fields.map {|field| html_field(field) }
+ markdown_fields.map { |field| html_field(field) }
end
- end
-
- class MarkdownEngine
- def self.from_version(version = nil)
- return :common_mark if version.nil? || version == 0
- if version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- :redcarpet
- else
- :common_mark
+ def html_fields_whitelisted
+ markdown_fields.each_with_object([]) do |field, fields|
+ if @data[field].fetch(:whitelisted, false)
+ fields << html_field(field)
+ end
end
end
end
@@ -71,7 +67,7 @@ module CacheMarkdownField
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
- context[:markdown_engine] = MarkdownEngine.from_version(latest_cached_markdown_version)
+ context[:markdown_engine] = :common_mark
context
end
@@ -128,12 +124,27 @@ module CacheMarkdownField
end
def latest_cached_markdown_version
- return CacheMarkdownField::CACHE_COMMONMARK_VERSION unless cached_markdown_version
+ @latest_cached_markdown_version ||= (CacheMarkdownField::CACHE_COMMONMARK_VERSION << 16) | local_version
+ end
- if cached_markdown_version < CacheMarkdownField::CACHE_COMMONMARK_VERSION_START
- CacheMarkdownField::CACHE_REDCARPET_VERSION
+ def local_version
+ # because local_markdown_version is stored in application_settings which
+ # uses cached_markdown_version too, we check explicitly to avoid
+ # endless loop
+ return local_markdown_version if has_attribute?(:local_markdown_version)
+
+ settings = Gitlab::CurrentSettings.current_application_settings
+
+ # Following migrations are not properly isolated and
+ # use real models (by calling .ghost method), in these migrations
+ # local_markdown_version attribute doesn't exist yet, so we
+ # use a default value:
+ # db/migrate/20170825104051_migrate_issues_to_ghost_user.rb
+ # db/migrate/20171114150259_merge_requests_author_id_foreign_key.rb
+ if settings.respond_to?(:local_markdown_version)
+ settings.local_markdown_version
else
- CacheMarkdownField::CACHE_COMMONMARK_VERSION
+ 0
end
end
@@ -147,13 +158,18 @@ module CacheMarkdownField
alias_method :attributes_before_markdown_cache, :attributes
def attributes
attrs = attributes_before_markdown_cache
+ html_fields = cached_markdown_fields.html_fields
+ whitelisted = cached_markdown_fields.html_fields_whitelisted
+ exclude_fields = html_fields - whitelisted
- attrs.delete('cached_markdown_version')
-
- cached_markdown_fields.html_fields.each do |field|
+ exclude_fields.each do |field|
attrs.delete(field)
end
+ if whitelisted.empty?
+ attrs.delete('cached_markdown_version')
+ end
+
attrs
end
@@ -178,7 +194,9 @@ module CacheMarkdownField
# author and project invalidate the cache in all circumstances.
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
- invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
+ invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
+
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb
index 75592bb63e2..3d60f6924c1 100644
--- a/app/models/concerns/cacheable_attributes.rb
+++ b/app/models/concerns/cacheable_attributes.rb
@@ -23,7 +23,12 @@ module CacheableAttributes
end
def build_from_defaults(attributes = {})
- new(defaults.merge(attributes))
+ final_attributes = defaults
+ .merge(attributes)
+ .stringify_keys
+ .slice(*column_names)
+
+ new(final_attributes)
end
def cached
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
new file mode 100644
index 00000000000..e1d5ce7f7d4
--- /dev/null
+++ b/app/models/concerns/ci/contextable.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # This module implements methods that provide context in form of
+ # essential CI/CD variables that can be used by a build / bridge job.
+ #
+ module Contextable
+ ##
+ # Variables in the environment name scope.
+ #
+ def scoped_variables(environment: expanded_environment_name)
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.concat(predefined_variables)
+ variables.concat(project.predefined_variables)
+ variables.concat(pipeline.predefined_variables)
+ variables.concat(runner.predefined_variables) if runnable? && runner
+ variables.concat(project.deployment_variables(environment: environment)) if environment
+ variables.concat(yaml_variables)
+ variables.concat(user_variables)
+ variables.concat(secret_group_variables)
+ variables.concat(secret_project_variables(environment: environment))
+ variables.concat(trigger_request.user_variables) if trigger_request
+ variables.concat(pipeline.variables)
+ variables.concat(pipeline.pipeline_schedule.job_variables) if pipeline.pipeline_schedule
+ end
+ end
+
+ ##
+ # Regular Ruby hash of scoped variables, without duplicates that are
+ # possible to be present in an array of hashes returned from `variables`.
+ #
+ def scoped_variables_hash
+ scoped_variables.to_hash
+ end
+
+ ##
+ # Variables that do not depend on the environment name.
+ #
+ def simple_variables
+ strong_memoize(:simple_variables) do
+ scoped_variables(environment: nil).to_runner_variables
+ end
+ end
+
+ def user_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables if user.blank?
+
+ variables.append(key: 'GITLAB_USER_ID', value: user.id.to_s)
+ variables.append(key: 'GITLAB_USER_EMAIL', value: user.email)
+ variables.append(key: 'GITLAB_USER_LOGIN', value: user.username)
+ variables.append(key: 'GITLAB_USER_NAME', value: user.name)
+ end
+ end
+
+ def predefined_variables # rubocop:disable Metrics/AbcSize
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI', value: 'true')
+ variables.append(key: 'GITLAB_CI', value: 'true')
+ variables.append(key: 'GITLAB_FEATURES', value: project.licensed_features.join(','))
+ variables.append(key: 'CI_SERVER_NAME', value: 'GitLab')
+ variables.append(key: 'CI_SERVER_VERSION', value: Gitlab::VERSION)
+ variables.append(key: 'CI_SERVER_VERSION_MAJOR', value: Gitlab.version_info.major.to_s)
+ variables.append(key: 'CI_SERVER_VERSION_MINOR', value: Gitlab.version_info.minor.to_s)
+ variables.append(key: 'CI_SERVER_VERSION_PATCH', value: Gitlab.version_info.patch.to_s)
+ variables.append(key: 'CI_SERVER_REVISION', value: Gitlab.revision)
+ variables.append(key: 'CI_JOB_NAME', value: name)
+ variables.append(key: 'CI_JOB_STAGE', value: stage)
+ variables.append(key: 'CI_COMMIT_SHA', value: sha)
+ variables.append(key: 'CI_COMMIT_SHORT_SHA', value: short_sha)
+ variables.append(key: 'CI_COMMIT_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_COMMIT_REF_NAME', value: source_ref)
+ variables.append(key: 'CI_COMMIT_REF_SLUG', value: source_ref_slug)
+ variables.append(key: "CI_COMMIT_TAG", value: ref) if tag?
+ variables.append(key: "CI_PIPELINE_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_JOB_MANUAL", value: 'true') if action?
+ variables.append(key: "CI_NODE_INDEX", value: self.options[:instance].to_s) if self.options&.include?(:instance)
+ variables.append(key: "CI_NODE_TOTAL", value: (self.options&.dig(:parallel) || 1).to_s)
+ variables.concat(legacy_variables)
+ end
+ end
+
+ def legacy_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_BUILD_REF', value: sha)
+ variables.append(key: 'CI_BUILD_BEFORE_SHA', value: before_sha)
+ variables.append(key: 'CI_BUILD_REF_NAME', value: source_ref)
+ variables.append(key: 'CI_BUILD_REF_SLUG', value: source_ref_slug)
+ variables.append(key: 'CI_BUILD_NAME', value: name)
+ variables.append(key: 'CI_BUILD_STAGE', value: stage)
+ variables.append(key: "CI_BUILD_TAG", value: ref) if tag?
+ variables.append(key: "CI_BUILD_TRIGGERED", value: 'true') if trigger_request
+ variables.append(key: "CI_BUILD_MANUAL", value: 'true') if action?
+ end
+ end
+
+ def secret_group_variables
+ return [] unless project.group
+
+ project.group.ci_variables_for(git_ref, project)
+ end
+
+ def secret_project_variables(environment: persisted_environment)
+ project.ci_variables_for(ref: git_ref, environment: environment)
+ end
+ end
+end
diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb
new file mode 100644
index 00000000000..9eed9492b37
--- /dev/null
+++ b/app/models/concerns/ci/metadatable.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # This module implements methods that need to read and write
+ # metadata for CI/CD entities.
+ #
+ module Metadatable
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :metadata, class_name: 'Ci::BuildMetadata',
+ foreign_key: :build_id,
+ inverse_of: :build,
+ autosave: true
+
+ delegate :timeout, to: :metadata, prefix: true, allow_nil: true
+ before_create :ensure_metadata
+ end
+
+ def ensure_metadata
+ metadata || build_metadata(project: project)
+ end
+
+ def degenerated?
+ self.options.blank?
+ end
+
+ def degenerate!
+ self.class.transaction do
+ self.update!(options: nil, yaml_variables: nil)
+ self.metadata&.destroy
+ end
+ end
+
+ def options
+ read_metadata_attribute(:options, :config_options, {})
+ end
+
+ def yaml_variables
+ read_metadata_attribute(:yaml_variables, :config_variables, [])
+ end
+
+ def options=(value)
+ write_metadata_attribute(:options, :config_options, value)
+ end
+
+ def yaml_variables=(value)
+ write_metadata_attribute(:yaml_variables, :config_variables, value)
+ end
+
+ private
+
+ def read_metadata_attribute(legacy_key, metadata_key, default_value = nil)
+ read_attribute(legacy_key) || metadata&.read_attribute(metadata_key) || default_value
+ end
+
+ def write_metadata_attribute(legacy_key, metadata_key, value)
+ # save to metadata or this model depending on the state of feature flag
+ if Feature.enabled?(:ci_build_metadata_config)
+ ensure_metadata.write_attribute(metadata_key, value)
+ write_attribute(legacy_key, nil)
+ else
+ write_attribute(legacy_key, value)
+ metadata&.write_attribute(metadata_key, nil)
+ end
+ end
+ end
+end
diff --git a/app/models/concerns/ci/pipeline_delegator.rb b/app/models/concerns/ci/pipeline_delegator.rb
new file mode 100644
index 00000000000..dbc5ed1bc9a
--- /dev/null
+++ b/app/models/concerns/ci/pipeline_delegator.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+##
+# This module is mainly used by child associations of `Ci::Pipeline` that needs to look up
+# single source of truth. For example, `Ci::Build` has `git_ref` method, which behaves
+# slightly different from `Ci::Pipeline`'s `git_ref`. This is very confusing as
+# the system could behave differently time to time.
+# We should have a single interface in `Ci::Pipeline` and access the method always.
+module Ci
+ module PipelineDelegator
+ extend ActiveSupport::Concern
+
+ included do
+ delegate :merge_request_event?,
+ :merge_request_ref?,
+ :source_ref,
+ :source_ref_slug,
+ :legacy_detached_merge_request_pipeline?, to: :pipeline
+ end
+ end
+end
diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb
new file mode 100644
index 00000000000..268fa8ec692
--- /dev/null
+++ b/app/models/concerns/ci/processable.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Ci
+ ##
+ # This module implements methods that need to be implemented by CI/CD
+ # entities that are supposed to go through pipeline processing
+ # services.
+ #
+ #
+ module Processable
+ def schedulable?
+ raise NotImplementedError
+ end
+
+ def action?
+ raise NotImplementedError
+ end
+
+ def when
+ read_attribute(:when) || 'on_success'
+ end
+
+ def expanded_environment_name
+ raise NotImplementedError
+ end
+
+ def scoped_variables_hash
+ raise NotImplementedError
+ end
+ end
+end
diff --git a/app/models/concerns/closed_at_filterable.rb b/app/models/concerns/closed_at_filterable.rb
new file mode 100644
index 00000000000..239c2e47611
--- /dev/null
+++ b/app/models/concerns/closed_at_filterable.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module ClosedAtFilterable
+ extend ActiveSupport::Concern
+
+ included do
+ scope :closed_before, ->(date) { where(scoped_table[:closed_at].lteq(date)) }
+ scope :closed_after, ->(date) { where(scoped_table[:closed_at].gteq(date)) }
+
+ def self.scoped_table
+ arel_table.alias(table_name)
+ end
+ end
+end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index 0107af5f8ec..9ac0d612db3 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -14,6 +14,7 @@ module DeploymentPlatform
def find_deployment_platform(environment)
find_cluster_platform_kubernetes(environment: environment) ||
find_group_cluster_platform_kubernetes_with_feature_guard(environment: environment) ||
+ find_instance_cluster_platform_kubernetes_with_feature_guard(environment: environment) ||
find_kubernetes_service_integration ||
build_cluster_and_deployment_platform
end
@@ -36,6 +37,18 @@ module DeploymentPlatform
.first&.platform_kubernetes
end
+ def find_instance_cluster_platform_kubernetes_with_feature_guard(environment: nil)
+ return unless Clusters::Instance.enabled?
+
+ find_instance_cluster_platform_kubernetes(environment: environment)
+ end
+
+ # EE would override this and utilize environment argument
+ def find_instance_cluster_platform_kubernetes(environment: nil)
+ Clusters::Instance.new.clusters.enabled.default_environment
+ .first&.platform_kubernetes
+ end
+
def find_kubernetes_service_integration
services.deployment.reorder(nil).find_by(active: true)
end
diff --git a/app/models/concerns/deprecated_assignee.rb b/app/models/concerns/deprecated_assignee.rb
new file mode 100644
index 00000000000..7f12ce39c96
--- /dev/null
+++ b/app/models/concerns/deprecated_assignee.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# This module handles backward compatibility for import/export of Merge Requests after
+# multiple assignees feature was introduced. Also, it handles the scenarios where
+# the #26496 background migration hasn't finished yet.
+# Ideally, most of this code should be removed at #59457.
+module DeprecatedAssignee
+ extend ActiveSupport::Concern
+
+ def assignee_ids=(ids)
+ nullify_deprecated_assignee
+ super
+ end
+
+ def assignees=(users)
+ nullify_deprecated_assignee
+ super
+ end
+
+ def assignee_id=(id)
+ self.assignee_ids = Array(id)
+ end
+
+ def assignee=(user)
+ self.assignees = Array(user)
+ end
+
+ def assignee
+ assignees.first
+ end
+
+ def assignee_id
+ assignee_ids.first
+ end
+
+ def assignee_ids
+ if Gitlab::Database.read_only? && pending_assignees_population?
+ return Array(deprecated_assignee_id)
+ end
+
+ update_assignees_relation
+ super
+ end
+
+ def assignees
+ if Gitlab::Database.read_only? && pending_assignees_population?
+ return User.where(id: deprecated_assignee_id)
+ end
+
+ update_assignees_relation
+ super
+ end
+
+ private
+
+ # This will make the background migration process quicker (#26496) as it'll have less
+ # assignee_id rows to look through.
+ def nullify_deprecated_assignee
+ return unless persisted? && Gitlab::Database.read_only?
+
+ update_column(:assignee_id, nil)
+ end
+
+ # This code should be removed in the clean-up phase of the
+ # background migration (#59457).
+ def pending_assignees_population?
+ persisted? && deprecated_assignee_id && merge_request_assignees.empty?
+ end
+
+ # If there's an assignee_id and no relation, it means the background
+ # migration at #26496 didn't reach this merge request yet.
+ # This code should be removed in the clean-up phase of the
+ # background migration (#59457).
+ def update_assignees_relation
+ if pending_assignees_population?
+ transaction do
+ merge_request_assignees.create!(user_id: deprecated_assignee_id, merge_request_id: id)
+ update_column(:assignee_id, nil)
+ end
+ end
+ end
+
+ def deprecated_assignee_id
+ read_attribute(:assignee_id)
+ end
+end
diff --git a/app/models/concerns/descendant.rb b/app/models/concerns/descendant.rb
new file mode 100644
index 00000000000..4c436522122
--- /dev/null
+++ b/app/models/concerns/descendant.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Descendant
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def supports_nested_objects?
+ Gitlab::Database.postgresql?
+ end
+ end
+end
diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb
index 266c37fa3a1..e4e5928f5cf 100644
--- a/app/models/concerns/discussion_on_diff.rb
+++ b/app/models/concerns/discussion_on_diff.rb
@@ -9,7 +9,7 @@ module DiscussionOnDiff
included do
delegate :line_code,
:original_line_code,
- :diff_file,
+ :note_diff_file,
:diff_line,
:active?,
:created_at_diff?,
@@ -39,6 +39,7 @@ module DiscussionOnDiff
# Returns an array of at most 16 highlighted lines above a diff note
def truncated_diff_lines(highlight: true, diff_limit: nil)
+ return [] unless on_text?
return [] if diff_line.nil? && first_note.is_a?(LegacyDiffNote)
diff_limit = [diff_limit, NUMBER_OF_TRUNCATED_DIFF_LINES].compact.min
@@ -59,6 +60,13 @@ module DiscussionOnDiff
prev_lines
end
+ def diff_file
+ strong_memoize(:diff_file) do
+ # Falling back here is important as `note_diff_files` are created async.
+ fetch_preloaded_diff_file || first_note.diff_file
+ end
+ end
+
def line_code_in_diffs(diff_refs)
if active?(diff_refs)
line_code
@@ -66,4 +74,15 @@ module DiscussionOnDiff
original_line_code
end
end
+
+ private
+
+ def fetch_preloaded_diff_file
+ fetch_preloaded_diff =
+ context_noteable &&
+ context_noteable.preloads_discussion_diff_highlighting? &&
+ note_diff_file
+
+ context_noteable.discussions_diffs.find_by_id(note_diff_file.id) if fetch_preloaded_diff
+ end
end
diff --git a/app/models/concerns/enum_with_nil.rb b/app/models/concerns/enum_with_nil.rb
index 23acfe9a55f..6d0a21cf070 100644
--- a/app/models/concerns/enum_with_nil.rb
+++ b/app/models/concerns/enum_with_nil.rb
@@ -16,7 +16,7 @@ module EnumWithNil
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
# this overrides auto-generated method `unknown_failure?`
define_method("#{key_with_nil}?") do
- Gitlab.rails5? ? self[name].nil? : super()
+ self[name].nil?
end
# E.g. for enum_with_nil failure_reason: { unknown_failure: nil }
@@ -24,7 +24,6 @@ module EnumWithNil
define_method(name) do
orig = super()
- return orig unless Gitlab.rails5?
return orig unless orig.nil?
self.class.public_send(name.to_s.pluralize).key(nil) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/concerns/fast_destroy_all.rb b/app/models/concerns/fast_destroy_all.rb
index 1e3afd641ed..f862031bce0 100644
--- a/app/models/concerns/fast_destroy_all.rb
+++ b/app/models/concerns/fast_destroy_all.rb
@@ -11,7 +11,7 @@
# it is difficult to accomplish it.
#
# This module defines a format to use `delete_all` and delete associated external data.
-# Here is an exmaple
+# Here is an example
#
# Situation
# - `Project` has many `Ci::BuildTraceChunk` through `Ci::Build`
diff --git a/app/models/concerns/feature_gate.rb b/app/models/concerns/feature_gate.rb
index 3f84de54ad5..bb095f113e2 100644
--- a/app/models/concerns/feature_gate.rb
+++ b/app/models/concerns/feature_gate.rb
@@ -2,7 +2,7 @@
module FeatureGate
def flipper_id
- return nil if new_record?
+ return if new_record?
"#{self.class.name}:#{id}"
end
diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb
index 05cd4265133..cfffd845e43 100644
--- a/app/models/concerns/group_descendant.rb
+++ b/app/models/concerns/group_descendant.rb
@@ -22,7 +22,7 @@ module GroupDescendant
return [] if descendants.empty?
unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) }
- raise ArgumentError.new('element is not a hierarchy')
+ raise ArgumentError.new(_('element is not a hierarchy'))
end
all_hierarchies = descendants.map do |descendant|
@@ -56,7 +56,7 @@ module GroupDescendant
end
if parent.nil? && hierarchy_top.present?
- raise ArgumentError.new('specified top is not part of the tree')
+ raise ArgumentError.new(_('specified top is not part of the tree'))
end
if parent && parent != hierarchy_top
diff --git a/app/models/concerns/has_ref.rb b/app/models/concerns/has_ref.rb
new file mode 100644
index 00000000000..fa0cf5ddfd2
--- /dev/null
+++ b/app/models/concerns/has_ref.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+##
+# We will disable `ref` and `sha` attributes in `Ci::Build` in the future
+# and remove this module in favor of Ci::PipelineDelegator.
+module HasRef
+ extend ActiveSupport::Concern
+
+ def branch?
+ !tag? && !merge_request_event?
+ end
+
+ def git_ref
+ if branch?
+ Gitlab::Git::BRANCH_REF_PREFIX + ref.to_s
+ elsif tag?
+ Gitlab::Git::TAG_REF_PREFIX + ref.to_s
+ end
+ end
+
+ # A slugified version of the build ref, suitable for inclusion in URLs and
+ # domain names. Rules:
+ #
+ # * Lowercased
+ # * Anything not matching [a-z0-9-] is replaced with a -
+ # * Maximum length is 63 bytes
+ # * First/Last Character is not a hyphen
+ def ref_slug
+ Gitlab::Utils.slugify(ref.to_s)
+ end
+end
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index b92643f87f8..78bcce2f592 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -5,14 +5,14 @@ module HasStatus
DEFAULT_STATUS = 'created'.freeze
BLOCKED_STATUS = %w[manual scheduled].freeze
- AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual scheduled].freeze
+ AVAILABLE_STATUSES = %w[created preparing pending running success failed canceled skipped manual scheduled].freeze
STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
- ACTIVE_STATUSES = %w[pending running].freeze
+ ACTIVE_STATUSES = %w[preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
- ORDERED_STATUSES = %w[failed pending running manual scheduled canceled success skipped created].freeze
+ ORDERED_STATUSES = %w[failed preparing pending running manual scheduled canceled success skipped created].freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
- scheduled: 8 }.freeze
+ scheduled: 8, preparing: 9 }.freeze
UnknownStatusError = Class.new(StandardError)
@@ -26,6 +26,7 @@ module HasStatus
success = scope_relevant.success.select('count(*)').to_sql
manual = scope_relevant.manual.select('count(*)').to_sql
scheduled = scope_relevant.scheduled.select('count(*)').to_sql
+ preparing = scope_relevant.preparing.select('count(*)').to_sql
pending = scope_relevant.pending.select('count(*)').to_sql
running = scope_relevant.running.select('count(*)').to_sql
skipped = scope_relevant.skipped.select('count(*)').to_sql
@@ -37,12 +38,14 @@ module HasStatus
WHEN (#{builds})=(#{skipped}) THEN 'skipped'
WHEN (#{builds})=(#{success}) THEN 'success'
WHEN (#{builds})=(#{created}) THEN 'created'
+ WHEN (#{builds})=(#{preparing}) THEN 'preparing'
WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success'
WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
WHEN (#{running})+(#{pending})>0 THEN 'running'
WHEN (#{manual})>0 THEN 'manual'
WHEN (#{scheduled})>0 THEN 'scheduled'
+ WHEN (#{preparing})>0 THEN 'preparing'
WHEN (#{created})>0 THEN 'running'
ELSE 'failed'
END)"
@@ -63,6 +66,10 @@ module HasStatus
def all_state_names
state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) }
end
+
+ def completed_statuses
+ COMPLETED_STATUSES.map(&:to_sym)
+ end
end
included do
@@ -70,6 +77,7 @@ module HasStatus
state_machine :status, initial: :created do
state :created, value: 'created'
+ state :preparing, value: 'preparing'
state :pending, value: 'pending'
state :running, value: 'running'
state :failed, value: 'failed'
@@ -81,23 +89,24 @@ module HasStatus
end
scope :created, -> { where(status: 'created') }
+ scope :preparing, -> { where(status: 'preparing') }
scope :relevant, -> { where(status: AVAILABLE_STATUSES - ['created']) }
scope :running, -> { where(status: 'running') }
scope :pending, -> { where(status: 'pending') }
scope :success, -> { where(status: 'success') }
- scope :failed, -> { where(status: 'failed') }
- scope :canceled, -> { where(status: 'canceled') }
- scope :skipped, -> { where(status: 'skipped') }
- scope :manual, -> { where(status: 'manual') }
- scope :scheduled, -> { where(status: 'scheduled') }
- scope :alive, -> { where(status: [:created, :pending, :running]) }
+ scope :failed, -> { where(status: 'failed') }
+ scope :canceled, -> { where(status: 'canceled') }
+ scope :skipped, -> { where(status: 'skipped') }
+ scope :manual, -> { where(status: 'manual') }
+ scope :scheduled, -> { where(status: 'scheduled') }
+ scope :alive, -> { where(status: [:created, :preparing, :pending, :running]) }
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]) }
scope :cancelable, -> do
- where(status: [:running, :pending, :created, :scheduled])
+ where(status: [:running, :preparing, :pending, :created, :scheduled])
end
end
diff --git a/app/models/concerns/has_variable.rb b/app/models/concerns/has_variable.rb
index dfbe413a878..b4e99569071 100644
--- a/app/models/concerns/has_variable.rb
+++ b/app/models/concerns/has_variable.rb
@@ -4,6 +4,11 @@ module HasVariable
extend ActiveSupport::Concern
included do
+ enum variable_type: {
+ env_var: 1,
+ file: 2
+ }
+
validates :key,
presence: true,
length: { maximum: 255 },
@@ -21,9 +26,9 @@ module HasVariable
def key=(new_key)
super(new_key.to_s.strip)
end
+ end
- def to_runner_variable
- { key: key, value: value, public: false }
- end
+ def to_runner_variable
+ { key: key, value: value, public: false, file: file? }
end
end
diff --git a/app/models/concerns/ignorable_column.rb b/app/models/concerns/ignorable_column.rb
index 5c1f7dfcd2a..3bec44dc79b 100644
--- a/app/models/concerns/ignorable_column.rb
+++ b/app/models/concerns/ignorable_column.rb
@@ -5,7 +5,7 @@
#
# Example:
#
-# class User < ActiveRecord::Base
+# class User < ApplicationRecord
# include IgnorableColumn
#
# ignore_column :updated_at
diff --git a/app/models/concerns/iid_routes.rb b/app/models/concerns/iid_routes.rb
index b7f99e845ca..3eeb29b6595 100644
--- a/app/models/concerns/iid_routes.rb
+++ b/app/models/concerns/iid_routes.rb
@@ -4,7 +4,7 @@ module IidRoutes
##
# This automagically enforces all related routes to use `iid` instead of `id`
# If you want to use `iid` for some routes and `id` for other routes, this module should not to be included,
- # instead you should define `iid` or `id` explictly at each route generators. e.g. pipeline_path(project.id, pipeline.iid)
+ # instead you should define `iid` or `id` explicitly at each route generators. e.g. pipeline_path(project.id, pipeline.iid)
def to_param
iid.to_s
end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 0d363ec68b7..127430cc68f 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -23,11 +23,13 @@ module Issuable
include Sortable
include CreatedAtFilterable
include UpdatedAtFilterable
+ include IssuableStates
+ include ClosedAtFilterable
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
# lists avoiding n+1 queries and improving performance.
- IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count)
+ IssuableMeta = Struct.new(:upvotes, :downvotes, :user_notes_count, :merge_requests_count)
included do
cache_markdown_field :title, pipeline: :single_line
@@ -35,8 +37,8 @@ module Issuable
redact_field :description
- belongs_to :author, class_name: "User"
- belongs_to :updated_by, class_name: "User"
+ belongs_to :author, class_name: 'User'
+ belongs_to :updated_by, class_name: 'User'
belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone
@@ -65,15 +67,9 @@ module Issuable
allow_nil: true,
prefix: true
- delegate :name,
- :email,
- :public_email,
- to: :assignee,
- allow_nil: true,
- prefix: true
-
validates :author, presence: true
validates :title, presence: true, length: { maximum: 255 }
+ validate :milestone_is_valid
scope :authored, ->(user) { where(author_id: user) }
scope :recent, -> { reorder(id: :desc) }
@@ -85,6 +81,19 @@ module Issuable
scope :only_opened, -> { with_state(:opened) }
scope :closed, -> { with_state(:closed) }
+ # rubocop:disable GitlabSecurity/SqlInjection
+ # The `to_ability_name` method is not an user input.
+ scope :assigned, -> do
+ where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ end
+ scope :unassigned, -> do
+ where("NOT EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE #{to_ability_name}_id = #{to_ability_name}s.id)")
+ end
+ scope :assigned_to, ->(u) do
+ where("EXISTS (SELECT TRUE FROM #{to_ability_name}_assignees WHERE user_id = ? AND #{to_ability_name}_id = #{to_ability_name}s.id)", u.id)
+ end
+ # rubocop:enable GitlabSecurity/SqlInjection
+
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') }
scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') }
@@ -101,13 +110,14 @@ module Issuable
participant :author
participant :notes_with_associations
+ participant :assignees
strip_attributes :title
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
- title_changed? || description_changed?
+ will_save_change_to_title? || will_save_change_to_description?
end
def allows_multiple_assignees?
@@ -117,6 +127,12 @@ module Issuable
def has_multiple_assignees?
assignees.count > 1
end
+
+ private
+
+ def milestone_is_valid
+ errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available?
+ end
end
class_methods do
@@ -131,15 +147,36 @@ module Issuable
fuzzy_search(query, [:title])
end
+ # Available state values persisted in state_id column using state machine
+ #
+ # Override this on subclasses if different states are needed
+ #
+ # Check MergeRequest.available_states for example
+ def available_states
+ @available_states ||= { opened: 1, closed: 2 }.with_indifferent_access
+ end
+
# Searches for records with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
+ # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
#
# Returns an ActiveRecord::Relation.
- def full_search(query)
- fuzzy_search(query, [:title, :description])
+ def full_search(query, matched_columns: 'title,description')
+ allowed_columns = [:title, :description]
+ matched_columns = matched_columns.to_s.split(',').map(&:to_sym)
+ matched_columns &= allowed_columns
+
+ # Matching title or description if the matched_columns did not contain any allowed columns.
+ matched_columns = [:title, :description] if matched_columns.empty?
+
+ fuzzy_search(query, matched_columns)
+ end
+
+ def simple_sorts
+ super.except('name_asc', 'name_desc')
end
def sort_by_attribute(method, excluded_labels: [])
@@ -236,6 +273,14 @@ module Issuable
end
end
+ def milestone_available?
+ project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
+ end
+
+ def assignee_or_author?(user)
+ author_id == user.id || assignees.exists?(user.id)
+ end
+
def today?
Date.today == created_at.to_date
end
@@ -270,26 +315,25 @@ module Issuable
def to_hook_data(user, old_associations: {})
changes = previous_changes
- old_labels = old_associations.fetch(:labels, [])
- old_assignees = old_associations.fetch(:assignees, [])
- if old_labels != labels
- changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
- end
+ if old_associations
+ old_labels = old_associations.fetch(:labels, [])
+ old_assignees = old_associations.fetch(:assignees, [])
- if old_assignees != assignees
- if self.is_a?(Issue)
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
+ end
+
+ if old_assignees != assignees
changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- else
- changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
end
- end
- if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ end
end
end
@@ -318,10 +362,18 @@ module Issuable
def card_attributes
{
'Author' => author.try(:name),
- 'Assignee' => assignee.try(:name)
+ 'Assignee' => assignee_list
}
end
+ def assignee_list
+ assignees.map(&:name).to_sentence
+ end
+
+ def assignee_username_list
+ assignees.map(&:username).to_sentence
+ end
+
def notes_with_associations
# If A has_many Bs, and B has_many Cs, and you do
# `A.includes(b: :c).each { |a| a.b.includes(:c) }`, sadly ActiveRecord
diff --git a/app/models/concerns/issuable_states.rb b/app/models/concerns/issuable_states.rb
new file mode 100644
index 00000000000..b722c541580
--- /dev/null
+++ b/app/models/concerns/issuable_states.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module IssuableStates
+ extend ActiveSupport::Concern
+
+ # The state:string column is being migrated to state_id:integer column
+ # This is a temporary hook to populate state_id column with new values
+ # and should be removed after the state column is removed.
+ # Check https://gitlab.com/gitlab-org/gitlab-ce/issues/51789 for more information
+ included do
+ before_save :set_state_id
+ end
+
+ def set_state_id
+ return if state.nil? || state.empty?
+
+ # Needed to prevent breaking some migration specs that
+ # rollback database to a point where state_id does not exist.
+ # We can use this guard clause for now since this file will
+ # be removed in the next release.
+ return unless self.has_attribute?(:state_id)
+
+ self.state_id = self.class.available_states[state]
+ end
+end
diff --git a/app/models/concerns/manual_inverse_association.rb b/app/models/concerns/manual_inverse_association.rb
index e18edd33ba7..ff61412767e 100644
--- a/app/models/concerns/manual_inverse_association.rb
+++ b/app/models/concerns/manual_inverse_association.rb
@@ -5,8 +5,8 @@ module ManualInverseAssociation
class_methods do
def manual_inverse_association(association, inverse)
- define_method(association) do |*args|
- super(*args).tap do |value|
+ define_method(association) do
+ super().tap do |value|
next unless value
child_association = value.association(inverse)
diff --git a/app/models/concerns/maskable.rb b/app/models/concerns/maskable.rb
new file mode 100644
index 00000000000..2943872ffab
--- /dev/null
+++ b/app/models/concerns/maskable.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Maskable
+ extend ActiveSupport::Concern
+
+ # * Single line
+ # * No escape characters
+ # * No variables
+ # * No spaces
+ # * Minimal length of 8 characters
+ # * Absolutely no fun is allowed
+ REGEX = /\A\w{8,}\z/.freeze
+
+ included do
+ validates :masked, inclusion: { in: [true, false] }
+ validates :value, format: { with: REGEX }, if: :masked?
+ end
+
+ def to_runner_variable
+ super.merge(masked: masked?)
+ end
+end
diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb
index e44a069b730..e65bbb8ca07 100644
--- a/app/models/concerns/milestoneish.rb
+++ b/app/models/concerns/milestoneish.rb
@@ -42,16 +42,35 @@ module Milestoneish
def issues_visible_to_user(user)
memoize_per_user(user, :issues_visible_to_user) do
IssuesFinder.new(user, issues_finder_params)
- .execute.preload(:assignees).where(milestone_id: milestoneish_ids)
+ .execute.preload(:assignees).where(milestone_id: milestoneish_id)
end
end
+ def issue_participants_visible_by_user(user)
+ User.joins(:issue_assignees)
+ .where('issue_assignees.issue_id' => issues_visible_to_user(user).select(:id))
+ .distinct
+ end
+
+ def issue_labels_visible_by_user(user)
+ Label.joins(:label_links)
+ .where('label_links.target_id' => issues_visible_to_user(user).select(:id), 'label_links.target_type' => 'Issue')
+ .distinct
+ end
+
def sorted_issues(user)
issues_visible_to_user(user).preload_associations.sort_by_attribute('label_priority')
end
- def sorted_merge_requests
- merge_requests.sort_by_attribute('label_priority')
+ def sorted_merge_requests(user)
+ merge_requests_visible_to_user(user).sort_by_attribute('label_priority')
+ end
+
+ def merge_requests_visible_to_user(user)
+ memoize_per_user(user, :merge_requests_visible_to_user) do
+ MergeRequestsFinder.new(user, issues_finder_params)
+ .execute.where(milestone_id: milestoneish_id)
+ end
end
def upcoming?
diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb
index e3e1a0441f8..948094221e5 100644
--- a/app/models/concerns/mirror_authentication.rb
+++ b/app/models/concerns/mirror_authentication.rb
@@ -79,7 +79,7 @@ module MirrorAuthentication
end
def ssh_public_key
- return nil if ssh_private_key.blank?
+ return if ssh_private_key.blank?
comment = "git@#{::Gitlab.config.gitlab.host}"
::SSHKey.new(ssh_private_key, comment: comment).ssh_public_key
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index f2cad09e779..bfd0c36942b 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -1,9 +1,26 @@
# frozen_string_literal: true
module Noteable
- # Names of all implementers of `Noteable` that support resolvable notes.
+ extend ActiveSupport::Concern
+
+ # `Noteable` class names that support resolvable notes.
RESOLVABLE_TYPES = %w(MergeRequest).freeze
+ class_methods do
+ # `Noteable` class names that support replying to individual notes.
+ def replyable_types
+ %w(Issue MergeRequest)
+ end
+ end
+
+ # The timestamp of the note (e.g. the :created_at or :updated_at attribute if provided via
+ # API call)
+ def system_note_timestamp
+ @system_note_timestamp || Time.now # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
+ attr_writer :system_note_timestamp
+
def base_class_name
self.class.base_class.name
end
@@ -26,6 +43,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_replying_to_individual_notes?
+ supports_discussions? && self.class.replyable_types.include?(base_class_name)
+ end
+
def supports_suggestion?
false
end
@@ -34,6 +55,10 @@ module Noteable
false
end
+ def preloads_discussion_diff_highlighting?
+ false
+ end
+
def discussion_notes
notes
end
diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb
index 614c3242874..b140fca9b83 100644
--- a/app/models/concerns/participable.rb
+++ b/app/models/concerns/participable.rb
@@ -7,7 +7,7 @@
#
# Usage:
#
-# class Issue < ActiveRecord::Base
+# class Issue < ApplicationRecord
# include Participable
#
# # ...
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index a29e80fe0c1..258c819f243 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -36,7 +36,7 @@ module PrometheusAdapter
def calculate_reactive_cache(query_class_name, *args)
return unless prometheus_client
- data = Kernel.const_get(query_class_name).new(prometheus_client_wrapper).query(*args)
+ data = Object.const_get(query_class_name, false).new(prometheus_client_wrapper).query(*args)
{
success: true,
data: data,
@@ -51,7 +51,7 @@ module PrometheusAdapter
end
def build_query_args(*args)
- args.map(&:id)
+ args.map { |arg| arg.respond_to?(:id) ? arg.id : arg }
end
end
end
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index d3572875fb3..1e09cd89550 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -7,7 +7,7 @@
#
# Example of use:
#
-# class Foo < ActiveRecord::Base
+# class Foo < ApplicationRecord
# include ReactiveCaching
#
# self.reactive_cache_key = ->(thing) { ["foo", thing.id] }
@@ -29,6 +29,40 @@
# However, it will enqueue a background worker to call `#calculate_reactive_cache`
# and set an initial cache lifetime of ten minutes.
#
+# The background worker needs to find or generate the object on which
+# `with_reactive_cache` was called.
+# The default behaviour can be overridden by defining a custom
+# `reactive_cache_worker_finder`.
+# Otherwise the background worker will use the class name and primary key to get
+# the object using the ActiveRecord find_by method.
+#
+# class Bar
+# include ReactiveCaching
+#
+# self.reactive_cache_key = ->() { ["bar", "thing"] }
+# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+#
+# def self.from_cache(var1, var2)
+# # This method will be called by the background worker with "bar1" and
+# # "bar2" as arguments.
+# new(var1, var2)
+# end
+#
+# def initialize(var1, var2)
+# # ...
+# end
+#
+# def calculate_reactive_cache
+# # Expensive operation here. The return value of this method is cached
+# end
+#
+# def result
+# with_reactive_cache("bar1", "bar2") do |data|
+# # ...
+# end
+# end
+# end
+#
# Each time the background job completes, it stores the return value of
# `#calculate_reactive_cache`. It is also re-enqueued to run again after
# `reactive_cache_refresh_interval`, so keeping the stored value up to date.
@@ -52,6 +86,7 @@ module ReactiveCaching
class_attribute :reactive_cache_key
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_refresh_interval
+ class_attribute :reactive_cache_worker_finder
# defaults
self.reactive_cache_lease_timeout = 2.minutes
@@ -59,6 +94,10 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_worker_finder = ->(id, *_args) do
+ find_by(primary_key => id)
+ end
+
def calculate_reactive_cache(*args)
raise NotImplementedError
end
@@ -69,14 +108,14 @@ module ReactiveCaching
def with_reactive_cache(*args, &blk)
unless within_reactive_cache_lifetime?(*args)
refresh_reactive_cache!(*args)
- return nil
+ return
end
keep_alive_reactive_cache!(*args)
begin
data = Rails.cache.read(full_reactive_cache_key(*args))
- yield data if data.present?
+ yield data unless data.nil?
rescue InvalidateReactiveCache
refresh_reactive_cache!(*args)
nil
diff --git a/app/models/concerns/redactable.rb b/app/models/concerns/redactable.rb
index 5ad96d6cc46..53ae300ee2d 100644
--- a/app/models/concerns/redactable.rb
+++ b/app/models/concerns/redactable.rb
@@ -10,7 +10,7 @@
module Redactable
extend ActiveSupport::Concern
- UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}
+ UNSUBSCRIBE_PATTERN = %r{/sent_notifications/\h{32}/unsubscribe}.freeze
class_methods do
def redact_field(field)
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 69554f18ea2..4bb4ffe2a8e 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -49,10 +49,6 @@ module RedisCacheable
end
def cast_value_from_cache(attribute, value)
- if Gitlab.rails5?
- self.class.type_for_attribute(attribute.to_s).cast(value)
- else
- self.class.column_for_attribute(attribute).type_cast_from_database(value)
- end
+ self.class.type_for_attribute(attribute.to_s).cast(value)
end
end
diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb
index e51b4e22c96..70ac873a030 100644
--- a/app/models/concerns/sha_attribute.rb
+++ b/app/models/concerns/sha_attribute.rb
@@ -16,6 +16,8 @@ module ShaAttribute
# the column is the correct type. In production it should behave like any other attribute.
# See https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5502 for more discussion
def validate_binary_column_exists!(name)
+ return unless database_exists?
+
unless table_exists?
warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations"
return
@@ -35,5 +37,13 @@ module ShaAttribute
Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}"
raise
end
+
+ def database_exists?
+ ApplicationRecord.connection
+
+ true
+ rescue
+ false
+ end
end
end
diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb
index 29e48f0c5f7..df1a9e3fe6e 100644
--- a/app/models/concerns/sortable.rb
+++ b/app/models/concerns/sortable.rb
@@ -21,19 +21,21 @@ module Sortable
class_methods do
def order_by(method)
- case method.to_s
- when 'created_asc' then order_created_asc
- when 'created_date' then order_created_desc
- when 'created_desc' then order_created_desc
- when 'id_asc' then order_id_asc
- when 'id_desc' then order_id_desc
- when 'name_asc' then order_name_asc
- when 'name_desc' then order_name_desc
- when 'updated_asc' then order_updated_asc
- when 'updated_desc' then order_updated_desc
- else
- all
- end
+ simple_sorts.fetch(method.to_s, -> { all }).call
+ end
+
+ def simple_sorts
+ {
+ 'created_asc' => -> { order_created_asc },
+ 'created_date' => -> { order_created_desc },
+ 'created_desc' => -> { order_created_desc },
+ 'id_asc' => -> { order_id_asc },
+ 'id_desc' => -> { order_id_desc },
+ 'name_asc' => -> { order_name_asc },
+ 'name_desc' => -> { order_name_desc },
+ 'updated_asc' => -> { order_updated_asc },
+ 'updated_desc' => -> { order_updated_desc }
+ }
end
private
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index 498996f4f80..a15dc19e07a 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -13,20 +13,20 @@ module Storage
raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry")
end
- parent_was = if parent_changed? && parent_id_was.present?
- Namespace.find(parent_id_was) # raise NotFound early if needed
+ parent_was = if saved_change_to_parent? && parent_id_before_last_save.present?
+ Namespace.find(parent_id_before_last_save) # raise NotFound early if needed
end
move_repositories
- if parent_changed?
+ if saved_change_to_parent?
former_parent_full_path = parent_was&.full_path
parent_full_path = parent&.full_path
Gitlab::UploadsTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
Gitlab::PagesTransfer.new.move_namespace(path, former_parent_full_path, parent_full_path)
else
- Gitlab::UploadsTransfer.new.rename_namespace(full_path_was, full_path)
- Gitlab::PagesTransfer.new.rename_namespace(full_path_was, full_path)
+ Gitlab::UploadsTransfer.new.rename_namespace(full_path_before_last_save, full_path)
+ Gitlab::PagesTransfer.new.rename_namespace(full_path_before_last_save, full_path)
end
# If repositories moved successfully we need to
@@ -38,7 +38,7 @@ module Storage
write_projects_repository_config
rescue => e
# Raise if development/test environment, else just notify Sentry
- Gitlab::Sentry.track_exception(e, extra: { full_path_was: full_path_was, full_path: full_path, action: 'move_dir' })
+ Gitlab::Sentry.track_exception(e, extra: { full_path_before_last_save: full_path_before_last_save, full_path: full_path, action: 'move_dir' })
end
true # false would cancel later callbacks but not rollback
@@ -57,14 +57,14 @@ module Storage
# Move the namespace directory in all storages used by member projects
repository_storages.each do |repository_storage|
# Ensure old directory exists before moving it
- gitlab_shell.add_namespace(repository_storage, full_path_was)
+ gitlab_shell.add_namespace(repository_storage, full_path_before_last_save)
# Ensure new directory exists before moving it (if there's a parent)
gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent
- unless gitlab_shell.mv_namespace(repository_storage, full_path_was, full_path)
+ unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path)
- Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_was} to #{full_path}"
+ Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}"
# if we cannot move namespace directory we should rollback
# db changes in order to prevent out of sync between db and fs
@@ -104,11 +104,5 @@ module Storage
end
end
end
-
- def remove_legacy_exports!
- legacy_export_path = File.join(Gitlab::ImportExport.storage_path, full_path_was)
-
- FileUtils.rm_rf(legacy_export_path)
- end
end
end
diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb
index c9f5ba7793d..8f6a6244dd3 100644
--- a/app/models/concerns/strip_attribute.rb
+++ b/app/models/concerns/strip_attribute.rb
@@ -6,7 +6,7 @@
#
# Usage:
#
-# class Milestone < ActiveRecord::Base
+# class Milestone < ApplicationRecord
# strip_attributes :title
# end
#
diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb
index 603d4d62578..2f0e078c807 100644
--- a/app/models/concerns/taskable.rb
+++ b/app/models/concerns/taskable.rb
@@ -9,15 +9,17 @@ require 'task_list/filter'
#
# Used by MergeRequest and Issue
module Taskable
- COMPLETED = 'completed'.freeze
- INCOMPLETE = 'incomplete'.freeze
- ITEM_PATTERN = %r{
+ COMPLETED = 'completed'.freeze
+ INCOMPLETE = 'incomplete'.freeze
+ COMPLETE_PATTERN = /(\[[xX]\])/.freeze
+ INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze
+ ITEM_PATTERN = %r{
^
\s*(?:[-+*]|(?:\d+\.)) # list prefix required - task item has to be always in a list
\s+ # whitespace prefix has to be always presented for a list item
(\[\s\]|\[[xX]\]) # checkbox
(\s.+) # followed by whitespace and some text.
- }x
+ }x.freeze
def self.get_tasks(content)
content.to_s.scan(ITEM_PATTERN).map do |checkbox, label|
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index f5bb559ceda..8c769be0489 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -26,34 +26,41 @@ module TokenAuthenticatable
end
end
- define_method(token_field) do
+ mod = token_authenticatable_module
+
+ mod.define_method(token_field) do
strategy.get_token(self)
end
- define_method("set_#{token_field}") do |token|
+ mod.define_method("set_#{token_field}") do |token|
strategy.set_token(self, token)
end
- define_method("ensure_#{token_field}") do
+ mod.define_method("ensure_#{token_field}") do
strategy.ensure_token(self)
end
# Returns a token, but only saves when the database is in read & write mode
- define_method("ensure_#{token_field}!") do
+ mod.define_method("ensure_#{token_field}!") do
strategy.ensure_token!(self)
end
# Resets the token, but only saves when the database is in read & write mode
- define_method("reset_#{token_field}!") do
+ mod.define_method("reset_#{token_field}!") do
strategy.reset_token!(self)
end
- define_method("#{token_field}_matches?") do |other_token|
+ mod.define_method("#{token_field}_matches?") do |other_token|
token = read_attribute(token_field)
token.present? && ActiveSupport::SecurityUtils.variable_size_secure_compare(other_token, token)
end
end
+ def token_authenticatable_module
+ @token_authenticatable_module ||=
+ const_set(:TokenAuthenticatable, Module.new).tap(&method(:include))
+ end
+
def token_authenticatable_fields
@token_authenticatable_fields ||= []
end
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 01fb194281a..aafd0b538a3 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -39,25 +39,9 @@ module TokenAuthenticatableStrategies
instance.save! if Gitlab::Database.read_write?
end
- def fallback?
- unless options[:fallback].in?([true, false, nil])
- raise ArgumentError, 'fallback: needs to be a boolean value!'
- end
-
- options[:fallback] == true
- end
-
- def migrating?
- unless options[:migrating].in?([true, false, nil])
- raise ArgumentError, 'migrating: needs to be a boolean value!'
- end
-
- options[:migrating] == true
- end
-
def self.fabricate(model, field, options)
if options[:digest] && options[:encrypted]
- raise ArgumentError, 'Incompatible options set!'
+ raise ArgumentError, _('Incompatible options set!')
end
if options[:digest]
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index 152491aa6e9..4728cb658dc 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -2,28 +2,18 @@
module TokenAuthenticatableStrategies
class Encrypted < Base
- def initialize(*)
- super
-
- if migrating? && fallback?
- raise ArgumentError, '`fallback` and `migrating` options are not compatible!'
- end
- end
-
def find_token_authenticatable(token, unscoped = false)
return if token.blank?
- if fully_encrypted?
- return find_by_encrypted_token(token, unscoped)
- end
-
- if fallback?
+ if required?
+ find_by_encrypted_token(token, unscoped)
+ elsif optional?
find_by_encrypted_token(token, unscoped) ||
find_by_plaintext_token(token, unscoped)
elsif migrating?
find_by_plaintext_token(token, unscoped)
else
- raise ArgumentError, 'Unknown encryption phase!'
+ raise ArgumentError, _("Unknown encryption strategy: %{encrypted_strategy}!") % { encrypted_strategy: encrypted_strategy }
end
end
@@ -41,8 +31,8 @@ module TokenAuthenticatableStrategies
return super if instance.has_attribute?(encrypted_field)
- if fully_encrypted?
- raise ArgumentError, 'Using encrypted strategy when encrypted field is missing!'
+ if required?
+ raise ArgumentError, _('Using required encryption strategy when encrypted field is missing!')
else
insecure_strategy.ensure_token(instance)
end
@@ -53,8 +43,7 @@ module TokenAuthenticatableStrategies
encrypted_token = instance.read_attribute(encrypted_field)
token = Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
-
- token || (insecure_strategy.get_token(instance) if fallback?)
+ token || (insecure_strategy.get_token(instance) if optional?)
end
def set_token(instance, token)
@@ -62,16 +51,35 @@ module TokenAuthenticatableStrategies
instance[encrypted_field] = Gitlab::CryptoHelper.aes256_gcm_encrypt(token)
instance[token_field] = token if migrating?
- instance[token_field] = nil if fallback?
+ instance[token_field] = nil if optional?
token
end
- def fully_encrypted?
- !migrating? && !fallback?
+ def required?
+ encrypted_strategy == :required
+ end
+
+ def migrating?
+ encrypted_strategy == :migrating
+ end
+
+ def optional?
+ encrypted_strategy == :optional
end
protected
+ def encrypted_strategy
+ value = options[:encrypted]
+ value = value.call if value.is_a?(Proc)
+
+ unless value.in?([:required, :optional, :migrating])
+ raise ArgumentError, _('encrypted: needs to be a :required, :optional or :migrating!')
+ end
+
+ value
+ end
+
def find_by_plaintext_token(token, unscoped)
insecure_strategy.find_token_authenticatable(token, unscoped)
end
@@ -89,7 +97,7 @@ module TokenAuthenticatableStrategies
def token_set?(instance)
raw_token = instance.read_attribute(encrypted_field)
- unless fully_encrypted?
+ unless required?
raw_token ||= insecure_strategy.get_token(instance)
end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
new file mode 100644
index 00000000000..67e1f0ec930
--- /dev/null
+++ b/app/models/concerns/update_project_statistics.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+# This module is providing helpers for updating `ProjectStatistics` with `after_save` and `before_destroy` hooks.
+#
+# It deals with `ProjectStatistics.increment_statistic` making sure not to update statistics on a cascade delete from the
+# project, and keeping track of value deltas on each save. It updates the DB only when a change is needed.
+#
+# How to use
+# - Invoke `update_project_statistics stat: :a_project_statistics_column, attribute: :an_attr_to_track` in a model class body.
+#
+# Expectation
+# - `attribute` must be an ActiveRecord attribute
+# - The model must implement `project` and `project_id`. i.e. direct Project relationship or delegation
+module UpdateProjectStatistics
+ extend ActiveSupport::Concern
+
+ class_methods do
+ attr_reader :statistic_name, :statistic_attribute
+
+ # Configure the model to update +stat+ on ProjectStatistics when +attribute+ changes
+ #
+ # +stat+:: a column of ProjectStatistics to update
+ # +attribute+:: an attribute of the current model, default to +:size+
+ def update_project_statistics(stat:, attribute: :size)
+ @statistic_name = stat
+ @statistic_attribute = attribute
+
+ after_save(:update_project_statistics_after_save, if: :update_project_statistics_attribute_changed?)
+ after_destroy(:update_project_statistics_after_destroy, unless: :project_destroyed?)
+ end
+ private :update_project_statistics
+ end
+
+ included do
+ private
+
+ def project_destroyed?
+ project.pending_delete?
+ end
+
+ def update_project_statistics_attribute_changed?
+ saved_change_to_attribute?(self.class.statistic_attribute)
+ end
+
+ def update_project_statistics_after_save
+ attr = self.class.statistic_attribute
+ delta = read_attribute(attr).to_i - attribute_before_last_save(attr).to_i
+
+ update_project_statistics(delta)
+ end
+
+ def update_project_statistics_after_destroy
+ update_project_statistics(-read_attribute(self.class.statistic_attribute).to_i)
+ end
+
+ def update_project_statistics(delta)
+ ProjectStatistics.increment_statistic(project_id, self.class.statistic_name, delta)
+ end
+ end
+end
diff --git a/app/models/concerns/with_uploads.rb b/app/models/concerns/with_uploads.rb
index d79c0eae77e..6c6febd186c 100644
--- a/app/models/concerns/with_uploads.rb
+++ b/app/models/concerns/with_uploads.rb
@@ -27,40 +27,14 @@ module WithUploads
included do
has_many :uploads, as: :model
- has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) }, class_name: 'Upload', as: :model
+ has_many :file_uploads, -> { where(uploader: FILE_UPLOADERS) },
+ class_name: 'Upload', as: :model,
+ dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- # TODO: when feature flag is removed, we can use just dependent: destroy
- # option on :file_uploads
- before_destroy :remove_file_uploads
-
- use_fast_destroy :file_uploads, if: :fast_destroy_enabled?
+ use_fast_destroy :file_uploads
end
def retrieve_upload(_identifier, paths)
uploads.find_by(path: paths)
end
-
- private
-
- # mounted uploads are deleted in carrierwave's after_commit hook,
- # but FileUploaders which are not mounted must be deleted explicitly and
- # it can not be done in after_commit because FileUploader requires loads
- # associated model on destroy (which is already deleted in after_commit)
- def remove_file_uploads
- fast_destroy_enabled? ? delete_uploads : destroy_uploads
- end
-
- def delete_uploads
- file_uploads.delete_all(:delete_all)
- end
-
- def destroy_uploads
- file_uploads.find_each do |upload|
- upload.destroy
- end
- end
-
- def fast_destroy_enabled?
- Feature.enabled?(:fast_destroy_uploads, self)
- end
end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 2c08a8e1acf..39e12ac2b06 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
-class ContainerRepository < ActiveRecord::Base
+class ContainerRepository < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+
belongs_to :project
validates :name, length: { minimum: 0, allow_nil: false }
@@ -8,6 +10,8 @@ class ContainerRepository < ActiveRecord::Base
delegate :client, to: :registry
+ scope :ordered, -> { order(:name) }
+
# rubocop: disable CodeReuse/ServiceClass
def registry
@registry ||= begin
@@ -39,11 +43,12 @@ class ContainerRepository < ActiveRecord::Base
end
def tags
- return @tags if defined?(@tags)
return [] unless manifest && manifest['tags']
- @tags = manifest['tags'].map do |tag|
- ContainerRegistry::Tag.new(self, tag)
+ strong_memoize(:tags) do
+ manifest['tags'].sort.map do |tag|
+ ContainerRegistry::Tag.new(self, tag)
+ end
end
end
diff --git a/app/models/conversational_development_index/metric.rb b/app/models/conversational_development_index/metric.rb
index c54537572d6..b91123be87e 100644
--- a/app/models/conversational_development_index/metric.rb
+++ b/app/models/conversational_development_index/metric.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module ConversationalDevelopmentIndex
- class Metric < ActiveRecord::Base
+ class Metric < ApplicationRecord
include Presentable
self.table_name = 'conversational_development_index_metrics'
diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb
index 32e8104125c..74aa04ab7d0 100644
--- a/app/models/dashboard_group_milestone.rb
+++ b/app/models/dashboard_group_milestone.rb
@@ -5,36 +5,18 @@ class DashboardGroupMilestone < GlobalMilestone
attr_reader :group_name
- override :initialize
def initialize(milestone)
- super(milestone.title, Array(milestone))
+ super
@group_name = milestone.group.full_name
end
- def self.build_collection(groups)
- Milestone.of_groups(groups.select(:id))
+ def self.build_collection(groups, params)
+ milestones = Milestone.of_groups(groups.select(:id))
.reorder_by_due_date_asc
.order_by_name_asc
.active
- .map { |m| new(m) }
- end
-
- override :group_milestone?
- def group_milestone?
- @first_milestone.group_milestone?
- end
-
- override :milestoneish_ids
- def milestoneish_ids
- milestones.map(&:id)
- end
-
- def group
- @first_milestone.group
- end
-
- def iid
- @first_milestone.iid
+ milestones = milestones.search_title(params[:search_title]) if params[:search_title].present?
+ milestones.map { |m| new(m) }
end
end
diff --git a/app/models/dashboard_milestone.rb b/app/models/dashboard_milestone.rb
index 96bc8090b81..9b377b70e5b 100644
--- a/app/models/dashboard_milestone.rb
+++ b/app/models/dashboard_milestone.rb
@@ -1,11 +1,15 @@
# frozen_string_literal: true
class DashboardMilestone < GlobalMilestone
- def issues_finder_params
- { authorized_only: true }
+ attr_reader :project_name
+
+ def initialize(milestone)
+ super
+
+ @project_name = milestone.project.full_name
end
- def dashboard_milestone?
+ def project_milestone?
true
end
end
diff --git a/app/models/deploy_keys_project.rb b/app/models/deploy_keys_project.rb
index 71fd02fac86..15906ed8e06 100644
--- a/app/models/deploy_keys_project.rb
+++ b/app/models/deploy_keys_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class DeployKeysProject < ActiveRecord::Base
+class DeployKeysProject < ApplicationRecord
belongs_to :project
belongs_to :deploy_key, inverse_of: :deploy_keys_projects
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index e3524305346..b0e570f52ba 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class DeployToken < ActiveRecord::Base
+class DeployToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include PolicyActor
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 811e623b7f7..92c7311014a 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Deployment < ActiveRecord::Base
+class Deployment < ApplicationRecord
include AtomicInternalId
include IidRoutes
include AfterCommitQueue
@@ -47,6 +47,12 @@ class Deployment < ActiveRecord::Base
Deployments::SuccessWorker.perform_async(id)
end
end
+
+ after_transition any => [:success, :failed, :canceled] do |deployment|
+ deployment.run_after_commit do
+ Deployments::FinishedWorker.perform_async(id)
+ end
+ end
end
enum status: {
@@ -78,6 +84,19 @@ class Deployment < ActiveRecord::Base
Commit.truncate_sha(sha)
end
+ def cluster
+ platform = project.deployment_platform(environment: environment.name)
+
+ if platform.present? && platform.respond_to?(:cluster)
+ platform.cluster
+ end
+ end
+
+ def execute_hooks
+ deployment_data = Gitlab::DataBuilder::Deployment.build(self)
+ project.execute_services(deployment_data, :deployment_hooks)
+ end
+
def last?
self == environment.last_deployment
end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 279603496b0..feabea9b8ba 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -41,6 +41,14 @@ class DiffNote < Note
create_note_diff_file(creation_params)
end
+ # Returns the diff file from `position`
+ def latest_diff_file
+ strong_memoize(:latest_diff_file) do
+ position.diff_file(project.repository)
+ end
+ end
+
+ # Returns the diff file from `original_position`
def diff_file
strong_memoize(:diff_file) do
enqueue_diff_file_creation_job if should_create_diff_file?
@@ -69,8 +77,8 @@ class DiffNote < Note
def supports_suggestion?
return false unless noteable.supports_suggestion? && on_text?
# We don't want to trigger side-effects of `diff_file` call.
- return false unless file = fetch_diff_file
- return false unless line = file.line_for_position(self.original_position)
+ return false unless file = latest_diff_file
+ return false unless line = file.line_for_position(self.position)
line&.suggestible?
end
@@ -80,7 +88,7 @@ class DiffNote < Note
end
def banzai_render_context(field)
- super.merge(suggestions_filter_enabled: supports_suggestion?)
+ super.merge(project: project, suggestions_filter_enabled: supports_suggestion?)
end
private
diff --git a/app/models/diff_viewer/base.rb b/app/models/diff_viewer/base.rb
index 1176861a827..527ee33b83b 100644
--- a/app/models/diff_viewer/base.rb
+++ b/app/models/diff_viewer/base.rb
@@ -18,7 +18,7 @@ module DiffViewer
def initialize(diff_file)
@diff_file = diff_file
- @initially_binary = diff_file.binary?
+ @initially_binary = diff_file.binary_in_repo?
end
def self.partial_path
@@ -48,7 +48,7 @@ module DiffViewer
def self.can_render_blob?(blob, verify_binary: true)
return true if blob.nil?
- return false if verify_binary && binary? != blob.binary?
+ return false if verify_binary && binary? != blob.binary_in_repo?
return true if extensions&.include?(blob.extension)
return true if file_types&.include?(blob.file_type)
@@ -70,20 +70,49 @@ module DiffViewer
end
def binary_detected_after_load?
- !@initially_binary && diff_file.binary?
+ !@initially_binary && diff_file.binary_in_repo?
end
# This method is used on the server side to check whether we can attempt to
- # render the diff_file at all. Human-readable error messages are found in the
- # `BlobHelper#diff_render_error_reason` helper.
+ # render the diff_file at all. The human-readable error message can be
+ # retrieved by #render_error_message.
def render_error
if too_large?
:too_large
end
end
+ def render_error_message
+ return unless render_error
+
+ _("This %{viewer} could not be displayed because %{reason}. You can %{options} instead.") %
+ {
+ viewer: switcher_title,
+ reason: render_error_reason,
+ options: render_error_options.to_sentence(two_words_connector: _(' or '), last_word_connector: _(', or '))
+ }
+ end
+
def prepare!
# To be overridden by subclasses
end
+
+ private
+
+ def render_error_options
+ options = []
+
+ blob_url = Gitlab::Routing.url_helpers.project_blob_path(diff_file.repository.project,
+ File.join(diff_file.content_sha, diff_file.file_path))
+ options << ActionController::Base.helpers.link_to(_('view the blob'), blob_url)
+
+ options
+ end
+
+ def render_error_reason
+ if render_error == :too_large
+ _("it is too large")
+ end
+ end
end
end
diff --git a/app/models/diff_viewer/image.rb b/app/models/diff_viewer/image.rb
index c356c2ca50e..350bef1d42a 100644
--- a/app/models/diff_viewer/image.rb
+++ b/app/models/diff_viewer/image.rb
@@ -9,6 +9,6 @@ module DiffViewer
self.extensions = UploaderHelper::IMAGE_EXT
self.binary = true
self.switcher_icon = 'picture-o'
- self.switcher_title = 'image diff'
+ self.switcher_title = _('image diff')
end
end
diff --git a/app/models/diff_viewer/rich.rb b/app/models/diff_viewer/rich.rb
index 2faa1be6567..5caefa2031c 100644
--- a/app/models/diff_viewer/rich.rb
+++ b/app/models/diff_viewer/rich.rb
@@ -7,7 +7,7 @@ module DiffViewer
included do
self.type = :rich
self.switcher_icon = 'file-text-o'
- self.switcher_title = 'rendered diff'
+ self.switcher_title = _('rendered diff')
end
end
end
diff --git a/app/models/diff_viewer/server_side.rb b/app/models/diff_viewer/server_side.rb
index 977204e6c97..0877c9dddec 100644
--- a/app/models/diff_viewer/server_side.rb
+++ b/app/models/diff_viewer/server_side.rb
@@ -24,5 +24,17 @@ module DiffViewer
super
end
+
+ private
+
+ def render_error_reason
+ return super unless render_error == :server_side_but_stored_externally
+
+ if diff_file.external_storage == :lfs
+ _('it is stored in LFS')
+ else
+ _('it is stored externally')
+ end
+ end
end
end
diff --git a/app/models/diff_viewer/simple.rb b/app/models/diff_viewer/simple.rb
index 8d28ca5239a..929d8ad5a7e 100644
--- a/app/models/diff_viewer/simple.rb
+++ b/app/models/diff_viewer/simple.rb
@@ -7,7 +7,7 @@ module DiffViewer
included do
self.type = :simple
self.switcher_icon = 'code'
- self.switcher_title = 'source diff'
+ self.switcher_title = _('source diff')
end
end
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index dbc7b6e67be..32529ebf71d 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -116,6 +116,10 @@ class Discussion
false
end
+ def can_convert_to_discussion?
+ false
+ end
+
def new_discussion?
notes.length == 1
end
diff --git a/app/models/email.rb b/app/models/email.rb
index b6a977dfa22..0ddaa049c3b 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
-class Email < ActiveRecord::Base
+class Email < ApplicationRecord
include Sortable
include Gitlab::SQL::Pattern
belongs_to :user
validates :user_id, presence: true
- validates :email, presence: true, uniqueness: true, email: true
+ validates :email, presence: true, uniqueness: true, devise_email: true
validate :unique_email, if: ->(email) { email.email_changed? }
scope :confirmed, -> { where.not(confirmed_at: nil) }
@@ -15,7 +15,7 @@ class Email < ActiveRecord::Base
after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') }
devise :confirmable
- self.reconfirmable = false # currently email can't be changed, no need to reconfirm
+ self.reconfirmable = false # currently email can't be changed, no need to reconfirm
delegate :username, to: :user
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 934828946b9..aff20dae09b 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-class Environment < ActiveRecord::Base
+class Environment < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
# Used to generate random suffixes for the slug
- LETTERS = 'a'..'z'
- NUMBERS = '0'..'9'
+ LETTERS = ('a'..'z').freeze
+ NUMBERS = ('0'..'9').freeze
SUFFIX_CHARS = LETTERS.to_a + NUMBERS.to_a
belongs_to :project, required: true
@@ -34,7 +35,7 @@ class Environment < ActiveRecord::Base
validates :external_url,
length: { maximum: 255 },
allow_nil: true,
- url: true
+ addressable_url: true
delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
@@ -49,6 +50,14 @@ class Environment < ActiveRecord::Base
end
scope :in_review_folder, -> { where(environment_type: "review") }
scope :for_name, -> (name) { where(name: name) }
+
+ ##
+ # Search environments which have names like the given query.
+ # Do not set a large limit unless you've confirmed that it works on gitlab.com scale.
+ scope :for_name_like, -> (query, limit: 5) do
+ where('name LIKE ?', "#{sanitize_sql_like(query)}%").limit(limit)
+ end
+
scope :for_project, -> (project) { where(project_id: project) }
scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) }
@@ -69,6 +78,10 @@ class Environment < ActiveRecord::Base
end
end
+ def self.pluck_names
+ pluck(:name)
+ end
+
def predefined_variables
Gitlab::Ci::Variables::Collection.new
.append(key: 'CI_ENVIRONMENT_NAME', value: name)
@@ -106,7 +119,7 @@ class Environment < ActiveRecord::Base
def first_deployment_for(commit_sha)
ref = project.repository.ref_name_for_sha(ref_path, commit_sha)
- return nil unless ref
+ return unless ref
deployment_iid = ref.split('/').last
deployments.find_by(iid: deployment_iid)
@@ -117,7 +130,7 @@ class Environment < ActiveRecord::Base
end
def formatted_external_url
- return nil unless external_url
+ return unless external_url
external_url.gsub(%r{\A.*?://}, '')
end
@@ -142,11 +155,11 @@ class Environment < ActiveRecord::Base
end
def has_terminals?
- project.deployment_platform.present? && available? && last_deployment.present?
+ deployment_platform.present? && available? && last_deployment.present?
end
def terminals
- project.deployment_platform.terminals(self) if has_terminals?
+ deployment_platform.terminals(self) if has_terminals?
end
def has_metrics?
@@ -157,8 +170,10 @@ class Environment < ActiveRecord::Base
prometheus_adapter.query(:environment, self) if has_metrics?
end
- def additional_metrics
- prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics?
+ def additional_metrics(*args)
+ return unless has_metrics?
+
+ prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f))
end
# rubocop: disable CodeReuse/ServiceClass
@@ -230,8 +245,14 @@ class Environment < ActiveRecord::Base
self.environment_type || self.name
end
+ def name_without_type
+ @name_without_type ||= name.delete_prefix("#{environment_type}/")
+ end
+
def deployment_platform
- project.deployment_platform(environment: self.name)
+ strong_memoize(:deployment_platform) do
+ project.deployment_platform(environment: self.name)
+ end
end
private
diff --git a/app/models/epic.rb b/app/models/epic.rb
index ccd10593434..3693db1de33 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -2,7 +2,7 @@
# Placeholder class for model that is implemented in EE
# It reserves '&' as a reference prefix, but the table does not exists in CE
-class Epic < ActiveRecord::Base
+class Epic < ApplicationRecord
def self.link_reference_pattern
nil
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
new file mode 100644
index 00000000000..0b4fef5eac1
--- /dev/null
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ProjectErrorTrackingSetting < ApplicationRecord
+ include Gitlab::Utils::StrongMemoize
+ include ReactiveCaching
+
+ SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response'
+ SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry'
+
+ API_URL_PATH_REGEXP = %r{
+ \A
+ (?<prefix>/api/0/projects/+)
+ (?:
+ (?<organization>[^/]+)/+
+ (?<project>[^/]+)/*
+ )?
+ \z
+ }x.freeze
+
+ self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
+
+ belongs_to :project
+
+ validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true
+
+ validates :api_url, presence: { message: 'is a required field' }, if: :enabled
+
+ validate :validate_api_url_path, if: :enabled
+
+ validates :token, presence: { message: 'is a required field' }, if: :enabled
+
+ attr_encrypted :token,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_truncated,
+ algorithm: 'aes-256-gcm'
+
+ after_save :clear_reactive_cache!
+
+ def api_url=(value)
+ super
+ clear_memoization(:api_url_slugs)
+ end
+
+ def project_name
+ super || project_name_from_slug
+ end
+
+ def organization_name
+ super || organization_name_from_slug
+ end
+
+ def project_slug
+ project_slug_from_api_url
+ end
+
+ def organization_slug
+ organization_slug_from_api_url
+ end
+
+ def self.build_api_url_from(api_host:, project_slug:, organization_slug:)
+ return if api_host.blank?
+
+ uri = Addressable::URI.parse("#{api_host}/api/0/projects/#{organization_slug}/#{project_slug}/")
+ uri.path = uri.path.squeeze('/')
+
+ uri.to_s
+ rescue Addressable::URI::InvalidURIError
+ api_host
+ end
+
+ def sentry_client
+ Sentry::Client.new(api_url, token)
+ end
+
+ def sentry_external_url
+ self.class.extract_sentry_external_url(api_url)
+ end
+
+ def list_sentry_issues(opts = {})
+ with_reactive_cache('list_issues', opts.stringify_keys) do |result|
+ result
+ end
+ end
+
+ def list_sentry_projects
+ { projects: sentry_client.list_projects }
+ end
+
+ def calculate_reactive_cache(request, opts)
+ case request
+ when 'list_issues'
+ { issues: sentry_client.list_issues(**opts.symbolize_keys) }
+ end
+ rescue Sentry::Client::Error => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE }
+ rescue Sentry::Client::MissingKeysError => e
+ { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS }
+ end
+
+ # http://HOST/api/0/projects/ORG/PROJECT
+ # ->
+ # http://HOST/ORG/PROJECT
+ def self.extract_sentry_external_url(url)
+ url.sub('api/0/projects/', '')
+ end
+
+ def api_host
+ return if api_url.blank?
+
+ # This returns http://example.com/
+ Addressable::URI.join(api_url, '/').to_s
+ end
+
+ private
+
+ def project_name_from_slug
+ @project_name_from_slug ||= project_slug_from_api_url&.titleize
+ end
+
+ def organization_name_from_slug
+ @organization_name_from_slug ||= organization_slug_from_api_url&.titleize
+ end
+
+ def project_slug_from_api_url
+ api_url_slug(:project)
+ end
+
+ def organization_slug_from_api_url
+ api_url_slug(:organization)
+ end
+
+ def api_url_slug(capture)
+ slugs = strong_memoize(:api_url_slugs) { extract_api_url_slugs || {} }
+ slugs[capture]
+ end
+
+ def extract_api_url_slugs
+ return if api_url.blank?
+
+ begin
+ url = Addressable::URI.parse(api_url)
+ rescue Addressable::URI::InvalidURIError
+ return
+ end
+
+ url.path.match(API_URL_PATH_REGEXP)
+ end
+
+ def validate_api_url_path
+ return if api_url.blank?
+
+ unless api_url_slug(:prefix)
+ return errors.add(:api_url, 'is invalid')
+ end
+
+ unless api_url_slug(:organization)
+ errors.add(:project, 'is a required field')
+ end
+ end
+ end
+end
diff --git a/app/models/event.rb b/app/models/event.rb
index 2ceef412af5..738080eb584 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Event < ActiveRecord::Base
+class Event < ApplicationRecord
include Sortable
include IgnorableColumn
include FromUnion
@@ -68,7 +68,7 @@ class Event < ActiveRecord::Base
# Callbacks
after_create :reset_project_activity
- after_create :set_last_repository_updated_at, if: :push?
+ after_create :set_last_repository_updated_at, if: :push_action?
after_create :track_user_interacted_projects
# Scopes
@@ -114,19 +114,6 @@ class Event < ActiveRecord::Base
end
end
- # Remove this method when removing Gitlab.rails5? code.
- def subclass_from_attributes(attrs)
- return super if Gitlab.rails5?
-
- # Without this Rails will keep calling this method on the returned class,
- # resulting in an infinite loop.
- return unless self == Event
-
- action = attrs.with_indifferent_access[inheritance_column].to_i
-
- PushEvent if action == PUSHED
- end
-
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
@@ -151,11 +138,11 @@ class Event < ActiveRecord::Base
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/PerceivedComplexity
def visible_to_user?(user = nil)
- if push? || commit_note?
+ if push_action? || commit_note?
Ability.allowed?(user, :download_code, project)
elsif membership_changed?
Ability.allowed?(user, :read_project, project)
- elsif created_project?
+ elsif created_project_action?
Ability.allowed?(user, :read_project, project)
elsif issue? || issue_note?
Ability.allowed?(user, :read_issue, note? ? note_target : target)
@@ -186,56 +173,56 @@ class Event < ActiveRecord::Base
target.try(:title)
end
- def created?
+ def created_action?
action == CREATED
end
- def push?
+ def push_action?
false
end
- def merged?
+ def merged_action?
action == MERGED
end
- def closed?
+ def closed_action?
action == CLOSED
end
- def reopened?
+ def reopened_action?
action == REOPENED
end
- def joined?
+ def joined_action?
action == JOINED
end
- def left?
+ def left_action?
action == LEFT
end
- def expired?
+ def expired_action?
action == EXPIRED
end
- def destroyed?
+ def destroyed_action?
action == DESTROYED
end
- def commented?
+ def commented_action?
action == COMMENTED
end
def membership_changed?
- joined? || left? || expired?
+ joined_action? || left_action? || expired_action?
end
- def created_project?
- created? && !target && target_type.nil?
+ def created_project_action?
+ created_action? && !target && target_type.nil?
end
def created_target?
- created? && target
+ created_action? && target
end
def milestone?
@@ -271,23 +258,23 @@ class Event < ActiveRecord::Base
end
def action_name
- if push?
+ if push_action?
push_action_name
- elsif closed?
+ elsif closed_action?
"closed"
- elsif merged?
+ elsif merged_action?
"accepted"
- elsif joined?
+ elsif joined_action?
'joined'
- elsif left?
+ elsif left_action?
'left'
- elsif expired?
+ elsif expired_action?
'removed due to membership expiration from'
- elsif destroyed?
+ elsif destroyed_action?
'destroyed'
- elsif commented?
+ elsif commented_action?
"commented on"
- elsif created_project?
+ elsif created_project_action?
created_project_action_name
else
"opened"
@@ -350,7 +337,7 @@ class Event < ActiveRecord::Base
end
def body?
- if push?
+ if push_action?
push_with_commits?
elsif note?
true
diff --git a/app/models/external_issue.rb b/app/models/external_issue.rb
index 4f73beaafc5..68b2353556e 100644
--- a/app/models/external_issue.rb
+++ b/app/models/external_issue.rb
@@ -3,6 +3,8 @@
class ExternalIssue
include Referable
+ attr_reader :project
+
def initialize(issue_identifier, project)
@issue_identifier, @project = issue_identifier, project
end
@@ -32,12 +34,8 @@ class ExternalIssue
[self.class, to_s].hash
end
- def project
- @project
- end
-
def project_id
- @project.id
+ project.id
end
def to_reference(_from = nil, full: nil)
diff --git a/app/models/fork_network.rb b/app/models/fork_network.rb
index 1b9bf93cbbc..0323a8d222a 100644
--- a/app/models/fork_network.rb
+++ b/app/models/fork_network.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForkNetwork < ActiveRecord::Base
+class ForkNetwork < ApplicationRecord
belongs_to :root_project, class_name: 'Project'
has_many :fork_network_members
has_many :projects, through: :fork_network_members
diff --git a/app/models/fork_network_member.rb b/app/models/fork_network_member.rb
index 36c66f21b0b..f18c306cf91 100644
--- a/app/models/fork_network_member.rb
+++ b/app/models/fork_network_member.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ForkNetworkMember < ActiveRecord::Base
+class ForkNetworkMember < ApplicationRecord
belongs_to :fork_network
belongs_to :project
belongs_to :forked_from_project, class_name: 'Project'
diff --git a/app/models/generic_commit_status.rb b/app/models/generic_commit_status.rb
index 3028bf21301..8a768b3a2c0 100644
--- a/app/models/generic_commit_status.rb
+++ b/app/models/generic_commit_status.rb
@@ -3,7 +3,7 @@
class GenericCommitStatus < CommitStatus
before_validation :set_default_values
- validates :target_url, url: true,
+ validates :target_url, addressable_url: true,
length: { maximum: 255 },
allow_nil: true
diff --git a/app/models/global_label.rb b/app/models/global_label.rb
index c5b2492bbf6..7c020dd3b3d 100644
--- a/app/models/global_label.rb
+++ b/app/models/global_label.rb
@@ -1,10 +1,12 @@
# frozen_string_literal: true
class GlobalLabel
+ include Presentable
+
attr_accessor :title, :labels
alias_attribute :name, :title
- delegate :color, :text_color, :description, to: :@first_label
+ delegate :color, :text_color, :description, :scoped_label?, to: :@first_label
def for_display
@first_label
@@ -23,4 +25,8 @@ class GlobalLabel
@labels = labels
@first_label = labels.find { |lbl| lbl.description.present? } || labels.first
end
+
+ def present(attributes)
+ super(attributes.merge(presenter_class: ::LabelPresenter))
+ end
end
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index 085ffd16c6a..59f5a7703e2 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -3,69 +3,81 @@
class GlobalMilestone
include Milestoneish
- EPOCH = DateTime.parse('1970-01-01')
STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
- attr_accessor :title, :milestones
+ attr_reader :milestone
alias_attribute :name, :title
+ delegate :title, :state, :due_date, :start_date, :participants, :project,
+ :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title,
+ :milestoneish_id, :parent, to: :milestone
+
+ def to_hash
+ {
+ name: title,
+ title: title,
+ group_name: group&.full_name,
+ project_name: project&.full_name
+ }
+ end
+
def for_display
- @first_milestone
+ @milestone
end
def self.build_collection(projects, params)
- params =
- { project_ids: projects.map(&:id), state: params[:state] }
-
- child_milestones = MilestonesFinder.new(params).execute # rubocop: disable CodeReuse/Finder
-
- milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
- milestones_relation = Milestone.where(id: grouped.map(&:id))
- new(title, milestones_relation)
- end
+ items = Milestone.of_projects(projects)
+ .reorder_by_due_date_asc
+ .order_by_name_asc
+ items = items.search_title(params[:search_title]) if params[:search_title].present?
- milestones.sort_by { |milestone| milestone.due_date || EPOCH }
+ Milestone.filter_by_state(items, params[:state]).map { |m| new(m) }
end
+ # necessary for legacy milestones
def self.build(projects, title)
- child_milestones = Milestone.of_projects(projects).where(title: title)
- return if child_milestones.blank?
+ milestones = Milestone.of_projects(projects).where(title: title)
+ return if milestones.blank?
- new(title, child_milestones)
+ new(milestones.first)
end
- def self.count_by_state(milestones_by_state_and_title, state)
- milestones_by_state_and_title.count do |(milestone_state, _), _|
- milestone_state == state
+ def self.states_count(projects, group = nil)
+ legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
+ group_milestones_count = group_milestones_states_count(group)
+
+ legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
+ legacy_group_milestones_count + group_milestones_count
end
end
- private_class_method :count_by_state
- def initialize(title, milestones)
- @title = title
- @name = title
- @milestones = milestones
- @first_milestone = milestones.find {|m| m.description.present? } || milestones.first
- end
+ def self.group_milestones_states_count(group)
+ return STATE_COUNT_HASH unless group
- def milestoneish_ids
- milestones.select(:id)
- end
+ counts_by_state = Milestone.of_groups(group).count_by_state
- def safe_title
- @title.to_slug.normalize.to_s
+ {
+ opened: counts_by_state['active'] || 0,
+ closed: counts_by_state['closed'] || 0,
+ all: counts_by_state.values.sum
+ }
end
- def projects
- @projects ||= Project.for_milestones(milestoneish_ids)
- end
+ def self.legacy_group_milestone_states_count(projects)
+ return STATE_COUNT_HASH unless projects
- def state
- milestones.each do |milestone|
- return 'active' if milestone.state != 'closed'
- end
+ # We need to reorder(nil) on the projects, because the controller passes them in sorted.
+ relation = Milestone.of_projects(projects.reorder(nil)).count_by_state
- 'closed'
+ {
+ opened: relation['active'] || 0,
+ closed: relation['closed'] || 0,
+ all: relation.values.sum
+ }
+ end
+
+ def initialize(milestone)
+ @milestone = milestone
end
def active?
@@ -77,37 +89,14 @@ class GlobalMilestone
end
def issues
- @issues ||= Issue.of_milestones(milestoneish_ids).includes(:project, :assignees, :labels)
+ @issues ||= Issue.of_milestones(milestone).includes(:project, :assignees, :labels)
end
def merge_requests
- @merge_requests ||= MergeRequest.of_milestones(milestoneish_ids).includes(:target_project, :assignee, :labels)
- end
-
- def participants
- @participants ||= milestones.map(&:participants).flatten.uniq
+ @merge_requests ||= MergeRequest.of_milestones(milestone).includes(:target_project, :assignee, :labels)
end
def labels
- @labels ||= GlobalLabel.build_collection(milestones.includes(:labels).map(&:labels).flatten)
- .sort_by!(&:title)
- end
-
- def due_date
- return @due_date if defined?(@due_date)
-
- @due_date =
- if @milestones.all? { |x| x.due_date == @milestones.first.due_date }
- @milestones.first.due_date
- end
- end
-
- def start_date
- return @start_date if defined?(@start_date)
-
- @start_date =
- if @milestones.all? { |x| x.start_date == @milestones.first.start_date }
- @milestones.first.start_date
- end
+ @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title)
end
end
diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb
index 077afffd358..116beac5c2a 100644
--- a/app/models/gpg_key.rb
+++ b/app/models/gpg_key.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgKey < ActiveRecord::Base
+class GpgKey < ApplicationRecord
KEY_PREFIX = '-----BEGIN PGP PUBLIC KEY BLOCK-----'.freeze
KEY_SUFFIX = '-----END PGP PUBLIC KEY BLOCK-----'.freeze
diff --git a/app/models/gpg_key_subkey.rb b/app/models/gpg_key_subkey.rb
index 440b588bc78..110bf451136 100644
--- a/app/models/gpg_key_subkey.rb
+++ b/app/models/gpg_key_subkey.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgKeySubkey < ActiveRecord::Base
+class GpgKeySubkey < ApplicationRecord
include ShaAttribute
sha_attribute :keyid
diff --git a/app/models/gpg_signature.rb b/app/models/gpg_signature.rb
index 0816778deae..46cac1d41bb 100644
--- a/app/models/gpg_signature.rb
+++ b/app/models/gpg_signature.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GpgSignature < ActiveRecord::Base
+class GpgSignature < ApplicationRecord
include ShaAttribute
sha_attribute :commit_sha
@@ -33,6 +33,20 @@ class GpgSignature < ActiveRecord::Base
)
end
+ def self.safe_create!(attributes)
+ create_with(attributes)
+ .safe_find_or_create_by!(commit_sha: attributes[:commit_sha])
+ end
+
+ # Find commits that are lacking a signature in the database at present
+ def self.unsigned_commit_shas(commit_shas)
+ return [] if commit_shas.empty?
+
+ signed = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
+
+ commit_shas - signed
+ end
+
def gpg_key=(model)
case model
when GpgKey
diff --git a/app/models/group.rb b/app/models/group.rb
index 233747cc2c2..53331a19776 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -10,6 +10,7 @@ class Group < Namespace
include Referable
include SelectForProjectAuthorization
include LoadedInGroupList
+ include Descendant
include GroupDescendant
include TokenAuthenticatable
include WithUploads
@@ -55,18 +56,14 @@ class Group < Namespace
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
- add_authentication_token_field :runners_token, encrypted: true, migrating: true
+ add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
after_create :post_create_hook
after_destroy :post_destroy_hook
after_save :update_two_factor_requirement
- after_update :path_changed_hook, if: :path_changed?
+ after_update :path_changed_hook, if: :saved_change_to_path?
class << self
- def supports_nested_groups?
- Gitlab::Database.postgresql?
- end
-
def sort_by_attribute(method)
if method == 'storage_size_desc'
# storage_size is a virtual column so we need to
@@ -101,7 +98,7 @@ class Group < Namespace
def select_for_project_authorization
if current_scope.joins_values.include?(:shared_projects)
joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id')
- .where('project_namespace.share_with_group_lock = ?', false)
+ .where('project_namespace.share_with_group_lock = ?', false)
.select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level")
else
super
@@ -231,22 +228,21 @@ class Group < Namespace
def has_owner?(user)
return false unless user
- members_with_parents.owners.where(user_id: user).any?
+ members_with_parents.owners.exists?(user_id: user)
end
def has_maintainer?(user)
return false unless user
- members_with_parents.maintainers.where(user_id: user).any?
+ members_with_parents.maintainers.exists?(user_id: user)
end
# @deprecated
alias_method :has_master?, :has_maintainer?
# Check if user is a last owner of the group.
- # Parent owners are ignored for nested groups.
def last_owner?(user)
- owners.include?(user) && owners.size == 1
+ has_owner?(user) && members_with_parents.owners.size == 1
end
def ldap_synced?
@@ -385,6 +381,10 @@ class Group < Namespace
end
end
+ def highest_group_member(user)
+ GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last
+ end
+
def hashed_storage?(_feature)
false
end
@@ -404,10 +404,14 @@ class Group < Namespace
Feature.enabled?(:group_clusters, root_ancestor, default_enabled: true)
end
+ def project_creation_level
+ super || ::Gitlab::CurrentSettings.default_project_creation
+ end
+
private
def update_two_factor_requirement
- return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
+ return unless saved_change_to_require_two_factor_authentication? || saved_change_to_two_factor_grace_period?
users.find_each(&:update_two_factor_requirement)
end
diff --git a/app/models/group_custom_attribute.rb b/app/models/group_custom_attribute.rb
index 22f14885657..5ac6e5f2550 100644
--- a/app/models/group_custom_attribute.rb
+++ b/app/models/group_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class GroupCustomAttribute < ActiveRecord::Base
+class GroupCustomAttribute < ApplicationRecord
belongs_to :group
validates :group, :key, :value, presence: true
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index 9dfaebacc83..97cb26c6ea9 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -1,18 +1,36 @@
# frozen_string_literal: true
# Group Milestones are milestones that can be shared among many projects within the same group
class GroupMilestone < GlobalMilestone
- attr_accessor :group
+ attr_reader :group, :milestones
def self.build_collection(group, projects, params)
- super(projects, params).each do |milestone|
- milestone.group = group
+ params =
+ { state: params[:state], search_title: params[:search_title] }
+
+ project_milestones = Milestone.of_projects(projects)
+ project_milestones = project_milestones.search_title(params[:search_title]) if params[:search_title].present?
+ child_milestones = Milestone.filter_by_state(project_milestones, params[:state])
+ grouped_milestones = child_milestones.group_by(&:title)
+
+ grouped_milestones.map do |title, grouped|
+ new(title, grouped, group)
end
end
def self.build(group, projects, title)
- super(projects, title).tap do |milestone|
- milestone&.group = group
- end
+ child_milestones = Milestone.of_projects(projects).where(title: title)
+ return if child_milestones.blank?
+
+ new(title, child_milestones, group)
+ end
+
+ def initialize(title, milestones, group)
+ @milestones = milestones
+ @group = group
+ end
+
+ def milestone
+ @milestone ||= milestones.find { |m| m.description.present? } || milestones.first
end
def issues_finder_params
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 1a8662db9fb..daf7ff4b771 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class WebHook < ActiveRecord::Base
+class WebHook < ApplicationRecord
include Sortable
attr_encrypted :token,
diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb
index 2d9f7594e8c..cfb1f3ec63b 100644
--- a/app/models/hooks/web_hook_log.rb
+++ b/app/models/hooks/web_hook_log.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class WebHookLog < ActiveRecord::Base
+class WebHookLog < ApplicationRecord
belongs_to :web_hook
serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize
diff --git a/app/models/identity.rb b/app/models/identity.rb
index d63dd432426..1cbd50205ed 100644
--- a/app/models/identity.rb
+++ b/app/models/identity.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ActiveRecord::Base
+class Identity < ApplicationRecord
include Sortable
include CaseSensitivity
@@ -8,11 +8,12 @@ class Identity < ActiveRecord::Base
validates :provider, presence: true
validates :extern_uid, allow_blank: true, uniqueness: { scope: UniquenessScopes.scopes, case_sensitive: false }
- validates :user_id, uniqueness: { scope: UniquenessScopes.scopes }
+ validates :user, uniqueness: { scope: UniquenessScopes.scopes }
before_save :ensure_normalized_extern_uid, if: :extern_uid_changed?
after_destroy :clear_user_synced_attributes, if: :user_synced_attributes_metadata_from_provider?
+ scope :for_user, ->(user) { where(user: user) }
scope :with_provider, ->(provider) { where(provider: provider) }
scope :with_extern_uid, ->(provider, extern_uid) do
iwhere(extern_uid: normalize_uid(provider, extern_uid)).with_provider(provider)
diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb
index 674b735903f..ce68371ae87 100644
--- a/app/models/identity/uniqueness_scopes.rb
+++ b/app/models/identity/uniqueness_scopes.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Identity < ActiveRecord::Base
+class Identity < ApplicationRecord
# This module and method are defined in a separate file to allow EE to
# redefine the `scopes` method before it is used in the `Identity` model.
module UniquenessScopes
diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb
index f0cc5aafcd4..60f5491849a 100644
--- a/app/models/import_export_upload.rb
+++ b/app/models/import_export_upload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ImportExportUpload < ActiveRecord::Base
+class ImportExportUpload < ApplicationRecord
include WithUploads
include ObjectStorage::BackgroundMove
diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb
index 07ee7470ea2..d926e39f96e 100644
--- a/app/models/individual_note_discussion.rb
+++ b/app/models/individual_note_discussion.rb
@@ -13,6 +13,18 @@ class IndividualNoteDiscussion < Discussion
true
end
+ def can_convert_to_discussion?
+ noteable.supports_replying_to_individual_notes?
+ end
+
+ def convert_to_discussion!(save: false)
+ first_note.becomes!(Discussion.note_class).to_discussion.tap do
+ # Save needs to be called on first_note instead of the transformed note
+ # because of https://gitlab.com/gitlab-org/gitlab-ce/issues/57324
+ first_note.save if save
+ end
+ end
+
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 11289887e00..a9b1962f24c 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -39,7 +39,7 @@ class InstanceConfiguration
def gitlab_ci
Settings.gitlab_ci
.to_h
- .merge(artifacts_max_size: { value: Settings.artifacts.max_size&.megabytes,
+ .merge(artifacts_max_size: { value: Gitlab::CurrentSettings.max_artifacts_size.megabytes,
default: 100.megabytes })
end
diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb
index e7168d49db9..237401899db 100644
--- a/app/models/internal_id.rb
+++ b/app/models/internal_id.rb
@@ -15,7 +15,7 @@
# In order to leverage InternalId for other usages, the idea is to
# * Add `usage` value to enum
# * (Optionally) add columns to `internal_ids` if needed for scope.
-class InternalId < ActiveRecord::Base
+class InternalId < ApplicationRecord
belongs_to :project
belongs_to :namespace
@@ -55,7 +55,8 @@ class InternalId < ActiveRecord::Base
def track_greatest(subject, scope, usage, new_value, init)
return new_value unless available?
- InternalIdGenerator.new(subject, scope, usage, init).track_greatest(new_value)
+ InternalIdGenerator.new(subject, scope, usage)
+ .track_greatest(init, new_value)
end
def generate_next(subject, scope, usage, init)
@@ -63,16 +64,39 @@ class InternalId < ActiveRecord::Base
# This can be the case in other (unrelated) migration specs
return (init.call(subject) || 0) + 1 unless available?
- InternalIdGenerator.new(subject, scope, usage, init).generate
+ InternalIdGenerator.new(subject, scope, usage)
+ .generate(init)
+ end
+
+ def reset(subject, scope, usage, value)
+ return false unless available?
+
+ InternalIdGenerator.new(subject, scope, usage)
+ .reset(value)
+ end
+
+ # Flushing records is generally safe in a sense that those
+ # records are going to be re-created when needed.
+ #
+ # A filter condition has to be provided to not accidentally flush
+ # records for all projects.
+ def flush_records!(filter)
+ raise ArgumentError, "filter cannot be empty" if filter.blank?
+
+ where(filter).delete_all
end
def available?
- @available_flag ||= ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION # rubocop:disable Gitlab/PredicateMemoization
+ return true unless Rails.env.test?
+
+ Gitlab::SafeRequestStore.fetch(:internal_ids_available_flag) do
+ ActiveRecord::Migrator.current_version >= REQUIRED_SCHEMA_VERSION
+ end
end
# Flushes cached information about schema
def reset_column_information
- @available_flag = nil
+ Gitlab::SafeRequestStore[:internal_ids_available_flag] = nil
super
end
end
@@ -92,14 +116,11 @@ class InternalId < ActiveRecord::Base
# subject: The instance we're generating an internal id for. Gets passed to init if called.
# scope: Attributes that define the scope for id generation.
# usage: Symbol to define the usage of the internal id, see InternalId.usages
- # init: Block that gets called to initialize InternalId record if not present
- # Make sure to not throw exceptions in the absence of records (if this is expected).
- attr_reader :subject, :scope, :init, :scope_attrs, :usage
+ attr_reader :subject, :scope, :scope_attrs, :usage
- def initialize(subject, scope, usage, init)
+ def initialize(subject, scope, usage)
@subject = subject
@scope = scope
- @init = init
@usage = usage
raise ArgumentError, 'Scope is not well-defined, need at least one column for scope (given: 0)' if scope.empty?
@@ -110,23 +131,40 @@ class InternalId < ActiveRecord::Base
end
# Generates next internal id and returns it
- def generate
- InternalId.transaction do
+ # init: Block that gets called to initialize InternalId record if not present
+ # Make sure to not throw exceptions in the absence of records (if this is expected).
+ def generate(init)
+ subject.transaction do
# Create a record in internal_ids if one does not yet exist
# and increment its last value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- (lookup || create_record).increment_and_save!
+ (lookup || create_record(init)).increment_and_save!
end
end
+ # Reset tries to rewind to `value-1`. This will only succeed,
+ # if `value` stored in database is equal to `last_value`.
+ # value: The expected last_value to decrement
+ def reset(value)
+ return false unless value
+
+ updated =
+ InternalId
+ .where(**scope, usage: usage_value)
+ .where(last_value: value)
+ .update_all('last_value = last_value - 1')
+
+ updated > 0
+ end
+
# Create a record in internal_ids if one does not yet exist
# and set its new_value if it is higher than the current last_value
#
# Note this will acquire a ROW SHARE lock on the InternalId record
- def track_greatest(new_value)
- InternalId.transaction do
- (lookup || create_record).track_greatest_and_save!(new_value)
+ def track_greatest(init, new_value)
+ subject.transaction do
+ (lookup || create_record(init)).track_greatest_and_save!(new_value)
end
end
@@ -147,8 +185,8 @@ class InternalId < ActiveRecord::Base
# was faster in doing this, we'll realize once we hit the unique key constraint
# violation. We can safely roll-back the nested transaction and perform
# a lookup instead to retrieve the record.
- def create_record
- InternalId.transaction(requires_new: true) do
+ def create_record(init)
+ subject.transaction(requires_new: true) do
InternalId.create!(
**scope,
usage: usage_value,
diff --git a/app/models/issue.rb b/app/models/issue.rb
index b7e13bcbccf..eb5544f2a12 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class Issue < ActiveRecord::Base
+class Issue < ApplicationRecord
include AtomicInternalId
include IidRoutes
include Issuable
@@ -26,6 +26,8 @@ class Issue < ActiveRecord::Base
DueThisMonth = DueDateStruct.new('Due This Month', 'month').freeze
DueNextMonthAndPreviousTwoWeeks = DueDateStruct.new('Due Next Month And Previous Two Weeks', 'next_month_and_previous_two_weeks').freeze
+ SORTING_PREFERENCE_FIELD = :issues_sort
+
belongs_to :project
belongs_to :moved_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
@@ -47,10 +49,6 @@ class Issue < ActiveRecord::Base
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
- scope :assigned, -> { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
- scope :unassigned, -> { where('NOT EXISTS (SELECT TRUE FROM issue_assignees WHERE issue_id = issues.id)') }
- scope :assigned_to, ->(u) { where('EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = ? AND issue_id = issues.id)', u.id)}
-
scope :with_due_date, -> { where.not(due_date: nil) }
scope :without_due_date, -> { where(due_date: nil) }
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
@@ -62,8 +60,10 @@ class Issue < ActiveRecord::Base
scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') }
scope :preload_associations, -> { preload(:labels, project: :namespace) }
+ scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
scope :public_only, -> { where(confidential: false) }
+ scope :confidential_only, -> { where(confidential: true) }
after_save :expire_etag_cache
after_save :ensure_metrics, unless: :imported?
@@ -71,8 +71,6 @@ class Issue < ActiveRecord::Base
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
- participant :assignees
-
state_machine :state, initial: :opened do
event :close do
transition [:opened] => :closed
@@ -86,7 +84,7 @@ class Issue < ActiveRecord::Base
state :closed
before_transition any => :closed do |issue|
- issue.closed_at = Time.zone.now
+ issue.closed_at = issue.system_note_timestamp
end
before_transition closed: :opened do |issue|
@@ -151,22 +149,6 @@ class Issue < ActiveRecord::Base
Gitlab::HookData::IssueBuilder.new(self).build
end
- # Returns a Hash of attributes to be used for Twitter card metadata
- def card_attributes
- {
- 'Author' => author.try(:name),
- 'Assignee' => assignee_list
- }
- end
-
- def assignee_or_author?(user)
- author_id == user.id || assignees.exists?(user.id)
- end
-
- def assignee_list
- assignees.map(&:name).to_sentence
- end
-
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -226,11 +208,18 @@ class Issue < ActiveRecord::Base
def visible_to_user?(user = nil)
return false unless project && project.feature_available?(:issues, user)
- user ? readable_by?(user) : publicly_visible?
+ return publicly_visible? unless user
+
+ return false unless readable_by?(user)
+
+ user.full_private_access? ||
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ user, project.external_authorization_classification_label)
end
def check_for_spam?
- project.public? && (title_changed? || description_changed?)
+ publicly_visible? &&
+ (title_changed? || description_changed? || confidential_changed?)
end
def as_json(options = {})
@@ -259,6 +248,10 @@ class Issue < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
+ def merge_requests_count
+ merge_requests_closing_issues.count
+ end
+
private
def ensure_metrics
@@ -289,7 +282,7 @@ class Issue < ActiveRecord::Base
# Returns `true` if this Issue is visible to everybody.
def publicly_visible?
- project.public? && !confidential?
+ project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled?
end
def expire_etag_cache
diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb
index 0f5ee957ec9..8010cbc3d78 100644
--- a/app/models/issue/metrics.rb
+++ b/app/models/issue/metrics.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Issue::Metrics < ActiveRecord::Base
+class Issue::Metrics < ApplicationRecord
belongs_to :issue
def record!
diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb
index 400c0256945..fbd9be1fb43 100644
--- a/app/models/issue_assignee.rb
+++ b/app/models/issue_assignee.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class IssueAssignee < ActiveRecord::Base
+class IssueAssignee < ApplicationRecord
belongs_to :issue
belongs_to :assignee, class_name: "User", foreign_key: :user_id
end
diff --git a/app/models/key.rb b/app/models/key.rb
index 8f93418b88b..b097be8cc89 100644
--- a/app/models/key.rb
+++ b/app/models/key.rb
@@ -2,7 +2,7 @@
require 'digest/md5'
-class Key < ActiveRecord::Base
+class Key < ApplicationRecord
include AfterCommitQueue
include Sortable
diff --git a/app/models/label.rb b/app/models/label.rb
index 5d2d1afd1d9..e9085e8bd25 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Label < ActiveRecord::Base
+class Label < ApplicationRecord
include CacheMarkdownField
include Referable
include Subscribable
@@ -8,6 +8,7 @@ class Label < ActiveRecord::Base
include OptionallySearch
include Sortable
include FromUnion
+ include Presentable
cache_markdown_field :description, pipeline: :single_line
@@ -126,6 +127,17 @@ class Label < ActiveRecord::Base
fuzzy_search(query, [:title, :description])
end
+ # Override Gitlab::SQL::Pattern.min_chars_for_partial_matching as
+ # label queries are never global, and so will not use a trigram
+ # index. That means we can have just one character in the LIKE.
+ def self.min_chars_for_partial_matching
+ 1
+ end
+
+ def self.by_ids(ids)
+ where(id: ids)
+ end
+
def open_issues_count(user = nil)
issues_count(user, state: 'opened')
end
@@ -214,6 +226,7 @@ class Label < ActiveRecord::Base
super(options).tap do |json|
json[:type] = self.try(:type)
json[:priority] = priority(options[:project]) if options.key?(:project)
+ json[:textColor] = text_color
end
end
@@ -221,6 +234,10 @@ class Label < ActiveRecord::Base
attributes
end
+ def present(attributes)
+ super(attributes.merge(presenter_class: ::LabelPresenter))
+ end
+
private
def issues_count(user, params = {})
diff --git a/app/models/label_link.rb b/app/models/label_link.rb
index 1d93a55e8e9..ffc0afd8e85 100644
--- a/app/models/label_link.rb
+++ b/app/models/label_link.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LabelLink < ActiveRecord::Base
+class LabelLink < ApplicationRecord
include Importable
belongs_to :target, polymorphic: true, inverse_of: :label_links # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/label_note.rb b/app/models/label_note.rb
index 680952cf421..d6814f4a948 100644
--- a/app/models/label_note.rb
+++ b/app/models/label_note.rb
@@ -81,7 +81,7 @@ class LabelNote < Note
deleted = label_refs.count - existing_refs.count
deleted_str = deleted == 0 ? nil : "#{deleted} deleted"
- return nil unless refs_str || deleted_str
+ return unless refs_str || deleted_str
label_list_str = [refs_str, deleted_str].compact.join(' + ')
suffix = 'label'.pluralize(deleted > 0 ? deleted : existing_refs.count)
diff --git a/app/models/label_priority.rb b/app/models/label_priority.rb
index 8ed8bb7577f..8f8f36efbfe 100644
--- a/app/models/label_priority.rb
+++ b/app/models/label_priority.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LabelPriority < ActiveRecord::Base
+class LabelPriority < ApplicationRecord
belongs_to :project
belongs_to :label
diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb
index 00dec6bb92b..e2c75bc7ee9 100644
--- a/app/models/legacy_diff_note.rb
+++ b/app/models/legacy_diff_note.rb
@@ -73,7 +73,7 @@ class LegacyDiffNote < Note
private
def find_diff
- return nil unless noteable
+ return unless noteable
return @diff if defined?(@diff)
@diff = noteable.raw_diffs(Commit.max_diff_options).find do |d|
diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb
new file mode 100644
index 00000000000..6383f95d546
--- /dev/null
+++ b/app/models/lfs_download_object.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+class LfsDownloadObject
+ include ActiveModel::Validations
+
+ attr_accessor :oid, :size, :link
+ delegate :sanitized_url, :credentials, to: :sanitized_uri
+
+ validates :oid, format: { with: /\A\h{64}\z/ }
+ validates :size, numericality: { greater_than_or_equal_to: 0 }
+ validates :link, public_url: { protocols: %w(http https) }
+
+ def initialize(oid:, size:, link:)
+ @oid = oid
+ @size = size
+ @link = link
+ end
+
+ def sanitized_uri
+ @sanitized_uri ||= Gitlab::UrlSanitizer.new(link)
+ end
+end
diff --git a/app/models/lfs_file_lock.rb b/app/models/lfs_file_lock.rb
index 431d37e12e9..624b1d02e1a 100644
--- a/app/models/lfs_file_lock.rb
+++ b/app/models/lfs_file_lock.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsFileLock < ActiveRecord::Base
+class LfsFileLock < ApplicationRecord
belongs_to :project
belongs_to :user
diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb
index 69c563545bb..5245dbc8d15 100644
--- a/app/models/lfs_object.rb
+++ b/app/models/lfs_object.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsObject < ActiveRecord::Base
+class LfsObject < ApplicationRecord
include AfterCommitQueue
include ObjectStorage::BackgroundMove
@@ -13,7 +13,7 @@ class LfsObject < ActiveRecord::Base
mount_uploader :file, LfsObjectUploader
- after_save :update_file_store, if: :file_changed?
+ after_save :update_file_store, if: :saved_change_to_file?
def update_file_store
# The file.object_store is set during `uploader.store!`
diff --git a/app/models/lfs_objects_project.rb b/app/models/lfs_objects_project.rb
index 353602800d7..f9afb18c1d7 100644
--- a/app/models/lfs_objects_project.rb
+++ b/app/models/lfs_objects_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class LfsObjectsProject < ActiveRecord::Base
+class LfsObjectsProject < ApplicationRecord
belongs_to :project
belongs_to :lfs_object
diff --git a/app/models/list.rb b/app/models/list.rb
index 029685be927..17b1a8510cf 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class List < ActiveRecord::Base
+class List < ApplicationRecord
belongs_to :board
belongs_to :label
@@ -54,6 +54,6 @@ class List < ActiveRecord::Base
private
def can_be_destroyed
- destroyable?
+ throw(:abort) unless destroyable?
end
end
diff --git a/app/models/member.rb b/app/models/member.rb
index 9fc95ea00c3..c7583434148 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Member < ActiveRecord::Base
+class Member < ApplicationRecord
include AfterCommitQueue
include Sortable
include Importable
@@ -28,7 +28,7 @@ class Member < ActiveRecord::Base
presence: {
if: :invite?
},
- email: {
+ devise_email: {
allow_nil: true
},
uniqueness: {
@@ -76,14 +76,19 @@ class Member < ActiveRecord::Base
scope :maintainers, -> { active.where(access_level: MAINTAINER) }
scope :masters, -> { maintainers } # @deprecated
scope :owners, -> { active.where(access_level: OWNER) }
- scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
+ scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) }
scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated
+ scope :with_user, -> (user) { where(user: user) }
+
+ scope :with_source_id, ->(source_id) { where(source_id: source_id) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
+ scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) }
+
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite?, unless: :importing?
@@ -443,10 +448,10 @@ class Member < ActiveRecord::Base
end
def higher_access_level_than_group
- if highest_group_member && highest_group_member.access_level >= access_level
+ if highest_group_member && highest_group_member.access_level > access_level
error_parameters = { access: highest_group_member.human_access, group_name: highest_group_member.group.name }
- errors.add(:access_level, s_("should be higher than %{access} inherited membership from group %{group_name}") % error_parameters)
+ errors.add(:access_level, s_("should be greater than or equal to %{access} inherited membership from group %{group_name}") % error_parameters)
end
end
end
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index fc49ee7ac8c..4cba69069bb 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -12,6 +12,10 @@ class GroupMember < Member
validates :source_type, format: { with: /\ANamespace\z/ }
default_scope { where(source_type: SOURCE_TYPE) }
+ scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) }
+
+ scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count }
+
after_create :update_two_factor_requirement, unless: :invite?
after_destroy :update_two_factor_requirement, unless: :invite?
@@ -51,7 +55,7 @@ class GroupMember < Member
end
def post_update_hook
- if access_level_changed?
+ if saved_change_to_access_level?
run_after_commit { notification_service.update_group_member(self) }
end
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 016c18ce6c8..c64e2669b6a 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -12,6 +12,10 @@ class ProjectMember < Member
default_scope { where(source_type: SOURCE_TYPE) }
scope :in_project, ->(project) { where(source_id: project.id) }
+ scope :in_namespaces, ->(groups) do
+ joins('INNER JOIN projects ON projects.id = members.source_id')
+ .where('projects.namespace_id in (?)', groups.select(:id))
+ end
class << self
# Add users to projects with passed access option
@@ -107,7 +111,7 @@ class ProjectMember < Member
end
def post_update_hook
- if access_level_changed?
+ if saved_change_to_access_level?
run_after_commit { notification_service.update_project_member(self) }
end
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 8052a54c504..311ba1ce6bd 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequest < ActiveRecord::Base
+class MergeRequest < ApplicationRecord
include AtomicInternalId
include IidRoutes
include Issuable
@@ -16,11 +16,14 @@ class MergeRequest < ActiveRecord::Base
include LabelEventable
include ReactiveCaching
include FromUnion
+ include DeprecatedAssignee
self.reactive_cache_key = ->(model) { [model.project.id, model.iid] }
self.reactive_cache_refresh_interval = 10.minutes
self.reactive_cache_lifetime = 10.minutes
+ SORTING_PREFERENCE_FIELD = :merge_requests_sort
+
ignore_column :locked_at,
:ref_fetched,
:deleted_at
@@ -48,8 +51,8 @@ class MergeRequest < ActiveRecord::Base
# is the inverse of MergeRequest#merge_request_diff, which means it may not be
# the latest diff, because we could have loaded any diff from this particular
# MR. If we haven't already loaded a diff, then it's fine to load the latest.
- def merge_request_diff(*args)
- fallback = latest_merge_request_diff if args.empty? && !association(:merge_request_diff).loaded?
+ def merge_request_diff
+ fallback = latest_merge_request_diff unless association(:merge_request_diff).loaded?
fallback || super
end
@@ -63,13 +66,15 @@ class MergeRequest < ActiveRecord::Base
dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue
- has_many :merge_request_pipelines, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline'
+ has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline'
+ has_many :suggestions, through: :notes
- belongs_to :assignee, class_name: "User"
+ has_many :merge_request_assignees
+ has_many :assignees, class_name: "User", through: :merge_request_assignees
serialize :merge_params, Hash # rubocop:disable Cop/ActiveRecordSerialize
- after_create :ensure_merge_request_diff, unless: :importing?
+ after_create :ensure_merge_request_diff
after_update :clear_memoized_shas
after_update :reload_diff_if_branch_changed
after_save :ensure_metrics
@@ -105,7 +110,9 @@ class MergeRequest < ActiveRecord::Base
before_transition any => :opened do |merge_request|
merge_request.merge_jid = nil
+ end
+ after_transition any => :opened do |merge_request|
merge_request.run_after_commit do
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
@@ -177,18 +184,43 @@ class MergeRequest < ActiveRecord::Base
end
scope :join_project, -> { joins(:target_project) }
scope :references_project, -> { references(:target_project) }
- scope :assigned, -> { where("assignee_id IS NOT NULL") }
- scope :unassigned, -> { where("assignee_id IS NULL") }
- scope :assigned_to, ->(u) { where(assignee_id: u.id)}
-
- participant :assignee
+ scope :with_api_entity_associations, -> {
+ preload(:assignees, :author, :notes, :labels, :milestone, :timelogs,
+ latest_merge_request_diff: [:merge_request_diff_commits],
+ metrics: [:latest_closed_by, :merged_by],
+ target_project: [:route, { namespace: :route }],
+ source_project: [:route, { namespace: :route }])
+ }
after_save :keep_around_commit
+ alias_attribute :project, :target_project
+ alias_attribute :project_id, :target_project_id
+
def self.reference_prefix
'!'
end
+ def self.available_states
+ @available_states ||= super.merge(merged: 3, locked: 4)
+ end
+
+ # Returns the top 100 target branches
+ #
+ # The returned value is a Array containing branch names
+ # sort by updated_at of merge request:
+ #
+ # ['master', 'develop', 'production']
+ #
+ # limit - The maximum number of target branch to return.
+ def self.recent_target_branches(limit: 100)
+ group(:target_branch)
+ .select(:target_branch)
+ .reorder('MAX(merge_requests.updated_at) DESC')
+ .limit(limit)
+ .pluck(:target_branch)
+ end
+
def rebase_in_progress?
strong_memoize(:rebase_in_progress) do
# The source project can be deleted
@@ -202,7 +234,7 @@ class MergeRequest < ActiveRecord::Base
# branch head commit, for example checking if a merge request can be merged.
# For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004
def actual_head_pipeline
- head_pipeline&.sha == diff_head_sha ? head_pipeline : nil
+ head_pipeline&.matches_sha_or_source_sha?(diff_head_sha) ? head_pipeline : nil
end
def merge_pipeline
@@ -282,6 +314,10 @@ class MergeRequest < ActiveRecord::Base
work_in_progress?(title) ? title : "WIP: #{title}"
end
+ def committers
+ @committers ||= commits.committers
+ end
+
# Verifies if title has changed not taking into account WIP prefix
# for merge requests.
def wipless_title_changed(old_title)
@@ -292,31 +328,6 @@ class MergeRequest < ActiveRecord::Base
Gitlab::HookData::MergeRequestBuilder.new(self).build
end
- # Returns a Hash of attributes to be used for Twitter card metadata
- def card_attributes
- {
- 'Author' => author.try(:name),
- 'Assignee' => assignee.try(:name)
- }
- end
-
- # These method are needed for compatibility with issues to not mess view and other code
- def assignees
- Array(assignee)
- end
-
- def assignee_ids
- Array(assignee_id)
- end
-
- def assignee_ids=(ids)
- write_attribute(:assignee_id, ids.last)
- end
-
- def assignee_or_author?(user)
- author_id == user.id || assignee_id == user.id
- end
-
# `from` argument can be a Namespace or Project.
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
@@ -325,13 +336,15 @@ class MergeRequest < ActiveRecord::Base
end
def commits
- if persisted?
- merge_request_diff.commits
- elsif compare_commits
- compare_commits.reverse
- else
- []
- end
+ return merge_request_diff.commits if persisted?
+
+ commits_arr = if compare_commits
+ compare_commits.reverse
+ else
+ []
+ end
+
+ CommitCollection.new(source_project, commits_arr, source_branch)
end
def commits_count
@@ -364,8 +377,7 @@ class MergeRequest < ActiveRecord::Base
end
def supports_suggestion?
- # Should be `true` when removing the FF.
- Suggestion.feature_enabled?
+ true
end
# Calls `MergeWorker` to proceed with the merge process and
@@ -409,6 +421,28 @@ class MergeRequest < ActiveRecord::Base
merge_request_diffs.where.not(id: merge_request_diff.id)
end
+ def preloads_discussion_diff_highlighting?
+ true
+ end
+
+ def preload_discussions_diff_highlight
+ preloadable_files = note_diff_files.for_commit_or_unresolved
+
+ discussions_diffs.load_highlight(preloadable_files.pluck(:id))
+ end
+
+ def discussions_diffs
+ strong_memoize(:discussions_diffs) do
+ Gitlab::DiscussionsDiff::FileCollection.new(note_diff_files.to_a)
+ end
+ end
+
+ def note_diff_files
+ NoteDiffFile
+ .where(diff_note: discussion_notes)
+ .includes(diff_note: :project)
+ end
+
def diff_size
# Calling `merge_request_diff.diffs.real_size` will also perform
# highlighting, which we don't need here.
@@ -527,15 +561,19 @@ class MergeRequest < ActiveRecord::Base
end
def diff_refs
- if persisted?
- merge_request_diff.diff_refs
- else
- Gitlab::Diff::DiffRefs.new(
- base_sha: diff_base_sha,
- start_sha: diff_start_sha,
- head_sha: diff_head_sha
- )
- end
+ persisted? ? merge_request_diff.diff_refs : repository_diff_refs
+ end
+
+ # Instead trying to fetch the
+ # persisted diff_refs, this method goes
+ # straight to the repository to get the
+ # most recent data possible.
+ def repository_diff_refs
+ Gitlab::Diff::DiffRefs.new(
+ base_sha: branch_merge_base_sha,
+ start_sha: target_branch_sha,
+ head_sha: source_branch_sha
+ )
end
def branch_merge_base_sha
@@ -543,6 +581,8 @@ class MergeRequest < ActiveRecord::Base
end
def validate_branches
+ return unless target_project && source_project
+
if target_project == source_project && target_branch == source_branch
errors.add :branch_conflict, "You can't use same project/branch for source and target"
return
@@ -620,10 +660,6 @@ class MergeRequest < ActiveRecord::Base
end
end
- def reload_merge_request_diff
- merge_request_diff(true)
- end
-
def viewable_diffs
@viewable_diffs ||= merge_request_diffs.viewable.to_a
end
@@ -664,7 +700,7 @@ class MergeRequest < ActiveRecord::Base
end
def reload_diff_if_branch_changed
- if (source_branch_changed? || target_branch_changed?) &&
+ if (saved_change_to_source_branch? || saved_change_to_target_branch?) &&
(source_branch_head && target_branch_head)
reload_diff
end
@@ -729,6 +765,15 @@ class MergeRequest < ActiveRecord::Base
true
end
+ def mergeable_to_ref?
+ return false unless mergeable_state?(skip_ci_check: true, skip_discussions_check: true)
+
+ # Given the `merge_ref_path` will have the same
+ # state the `target_branch` would have. Ideally
+ # we need to check if it can be merged to it.
+ project.repository.can_be_merged?(diff_head_sha, target_branch)
+ end
+
def ff_merge_possible?
project.repository.ancestor?(target_branch_sha, diff_head_sha)
end
@@ -772,15 +817,6 @@ class MergeRequest < ActiveRecord::Base
end
def related_notes
- # Fetch comments only from last 100 commits
- commits_for_notes_limit = 100
- commit_ids = commit_shas.take(commits_for_notes_limit)
-
- commit_notes = Note
- .except(:order)
- .where(project_id: [source_project_id, target_project_id])
- .for_commit_id(commit_ids)
-
# We're using a UNION ALL here since this results in better performance
# compared to using OR statements. We're using UNION ALL since the queries
# used won't produce any duplicates (e.g. a note for a commit can't also be
@@ -792,6 +828,16 @@ class MergeRequest < ActiveRecord::Base
alias_method :discussion_notes, :related_notes
+ def commit_notes
+ # Fetch comments only from last 100 commits
+ commit_ids = commit_shas.take(100)
+
+ Note
+ .user
+ .where(project_id: [source_project_id, target_project_id])
+ .for_commit_id(commit_ids)
+ end
+
def mergeable_discussions_state?
return true unless project.only_allow_merge_if_all_discussions_are_resolved?
@@ -802,10 +848,6 @@ class MergeRequest < ActiveRecord::Base
target_project != source_project
end
- def project
- target_project
- end
-
# If the merge request closes any issues, save this information in the
# `MergeRequestsClosingIssues` model. This is a performance optimization.
# Calculating this information for a number of merge requests requires
@@ -904,7 +946,7 @@ class MergeRequest < ActiveRecord::Base
self.target_project.repository.branch_exists?(self.target_branch)
end
- def merge_commit_message(include_description: false)
+ def default_merge_commit_message(include_description: false)
closes_issues_references = visible_closing_issues_for.map do |issue|
issue.to_reference(target_project)
end
@@ -924,6 +966,13 @@ class MergeRequest < ActiveRecord::Base
message.join("\n\n")
end
+ # Returns the oldest multi-line commit message, or the MR title if none found
+ def default_squash_commit_message
+ strong_memoize(:default_squash_commit_message) do
+ commits.without_merge_commits.reverse.find(&:description?)&.safe_message || title
+ end
+ end
+
def reset_merge_when_pipeline_succeeds
return unless merge_when_pipeline_succeeds?
@@ -932,6 +981,7 @@ class MergeRequest < ActiveRecord::Base
if merge_params
merge_params.delete('should_remove_source_branch')
merge_params.delete('commit_message')
+ merge_params.delete('squash_commit_message')
end
self.save
@@ -1006,6 +1056,16 @@ class MergeRequest < ActiveRecord::Base
@environments[current_user]
end
+ ##
+ # This method is for looking for active environments which created via pipelines for merge requests.
+ # Since deployments run on a merge request ref (e.g. `refs/merge-requests/:iid/head`),
+ # we cannot look up environments with source branch name.
+ def environments
+ return Environment.none unless actual_head_pipeline&.triggered_by_merge_request?
+
+ actual_head_pipeline.environments
+ end
+
def state_human_name
if merged?
"Merged"
@@ -1034,6 +1094,14 @@ class MergeRequest < ActiveRecord::Base
"refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head"
end
+ def merge_ref_path
+ "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge"
+ end
+
+ def self.merge_request_ref?(ref)
+ ref.start_with?("refs/#{Repository::REF_MERGE_REQUEST}/")
+ end
+
def in_locked_state
begin
lock_mr
@@ -1070,56 +1138,45 @@ class MergeRequest < ActiveRecord::Base
diverged_commits_count > 0
end
- def all_pipelines(shas: all_commit_shas)
+ def all_pipelines
return Ci::Pipeline.none unless source_project
- @all_pipelines ||= source_project.ci_pipelines
- .where(sha: shas, ref: source_branch)
- .where(merge_request: [nil, self])
- .sort_by_merge_request_pipelines
+ shas = all_commit_shas
+
+ strong_memoize(:all_pipelines) do
+ Ci::Pipeline.from_union(
+ [source_project.ci_pipelines.merge_request_pipelines(self, shas),
+ source_project.ci_pipelines.detached_merge_request_pipelines(self, shas),
+ source_project.ci_pipelines.triggered_for_branch(source_branch).for_sha(shas)],
+ remove_duplicates: false).sort_by_merge_request_pipelines
+ end
end
- def merge_request_pipeline_exists?
- merge_request_pipelines.exists?(sha: diff_head_sha)
+ def update_head_pipeline
+ find_actual_head_pipeline.try do |pipeline|
+ self.head_pipeline = pipeline
+ update_column(:head_pipeline_id, head_pipeline.id) if head_pipeline_id_changed?
+ end
end
def has_test_reports?
- actual_head_pipeline&.has_test_reports?
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.test_reports)
end
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s)
variables.append(key: 'CI_MERGE_REQUEST_IID', value: iid.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_REF_PATH',
- value: ref_path.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID',
- value: project.id.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH',
- value: project.full_path)
-
- variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL',
- value: project.web_url)
-
- variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME',
- value: target_branch.to_s)
-
- if source_project
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID',
- value: source_project.id.to_s)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH',
- value: source_project.full_path)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL',
- value: source_project.web_url)
-
- variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME',
- value: source_branch.to_s)
- end
+ variables.append(key: 'CI_MERGE_REQUEST_REF_PATH', value: ref_path.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_ID', value: project.id.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_PATH', value: project.full_path)
+ variables.append(key: 'CI_MERGE_REQUEST_PROJECT_URL', value: project.web_url)
+ variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_NAME', value: target_branch.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title)
+ variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.any?
+ variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone
+ variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present?
+ variables.concat(source_project_variables)
end
end
@@ -1251,7 +1308,7 @@ class MergeRequest < ActiveRecord::Base
end
def has_commits?
- merge_request_diff && commits_count > 0
+ merge_request_diff && commits_count.to_i > 0
end
def has_no_commits?
@@ -1273,7 +1330,7 @@ class MergeRequest < ActiveRecord::Base
def base_pipeline
@base_pipeline ||= project.ci_pipelines
.order(id: :desc)
- .find_by(sha: diff_base_sha)
+ .find_by(sha: diff_base_sha, ref: target_branch)
end
def discussions_rendered_on_frontend?
@@ -1319,4 +1376,21 @@ class MergeRequest < ActiveRecord::Base
source_project.repository.squash_in_progress?(id)
end
+
+ def find_actual_head_pipeline
+ all_pipelines.for_sha_or_source_sha(diff_head_sha).first
+ end
+
+ private
+
+ def source_project_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless source_project
+
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_ID', value: source_project.id.to_s)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_PATH', value: source_project.full_path)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_PROJECT_URL', value: source_project.web_url)
+ variables.append(key: 'CI_MERGE_REQUEST_SOURCE_BRANCH_NAME', value: source_branch.to_s)
+ end
+ end
end
diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb
index 65e94a97b0a..05f8e18a2c1 100644
--- a/app/models/merge_request/metrics.rb
+++ b/app/models/merge_request/metrics.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequest::Metrics < ActiveRecord::Base
+class MergeRequest::Metrics < ApplicationRecord
belongs_to :merge_request
belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id
belongs_to :latest_closed_by, class_name: 'User'
diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb
new file mode 100644
index 00000000000..f0e6be51b7f
--- /dev/null
+++ b/app/models/merge_request_assignee.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class MergeRequestAssignee < ApplicationRecord
+ belongs_to :merge_request
+ belongs_to :assignee, class_name: "User", foreign_key: :user_id
+end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index a3029a54604..f45bd0e03de 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -1,25 +1,33 @@
# frozen_string_literal: true
-class MergeRequestDiff < ActiveRecord::Base
+class MergeRequestDiff < ApplicationRecord
include Sortable
include Importable
include ManualInverseAssociation
include IgnorableColumn
include EachBatch
include Gitlab::Utils::StrongMemoize
+ include ObjectStorage::BackgroundMove
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
- ignore_column :st_commits,
- :st_diffs
+ # Applies to closed or merged MRs when determining whether to migrate their
+ # diffs to external storage
+ EXTERNAL_DIFF_CUTOFF = 7.days.freeze
belongs_to :merge_request
+
manual_inverse_association :merge_request, :merge_request_diff
- has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
+ has_many :merge_request_diff_files,
+ -> { order(:merge_request_diff_id, :relative_order) },
+ inverse_of: :merge_request_diff
+
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
+ validates :base_commit_sha, :head_commit_sha, :start_commit_sha, sha: true
+
state_machine :state, initial: :empty do
event :clean do
transition any => :without_files
@@ -43,12 +51,95 @@ class MergeRequestDiff < ActiveRecord::Base
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
+ scope :by_project_id, -> (project_id) do
+ joins(:merge_request).where(merge_requests: { target_project_id: project_id })
+ end
+
scope :recent, -> { order(id: :desc).limit(100) }
+ scope :files_in_database, -> { where(stored_externally: [false, nil]) }
+
+ scope :not_latest_diffs, -> do
+ merge_requests = MergeRequest.arel_table
+ mr_diffs = arel_table
+
+ join_condition = merge_requests[:id].eq(mr_diffs[:merge_request_id])
+ .and(mr_diffs[:id].not_eq(merge_requests[:latest_merge_request_diff_id]))
+
+ arel_join = mr_diffs.join(merge_requests).on(join_condition)
+ joins(arel_join.join_sources)
+ end
+
+ scope :old_merged_diffs, -> (before) do
+ merge_requests = MergeRequest.arel_table
+ mr_metrics = MergeRequest::Metrics.arel_table
+ mr_diffs = arel_table
+
+ mr_join = mr_diffs
+ .join(merge_requests)
+ .on(mr_diffs[:merge_request_id].eq(merge_requests[:id]))
+
+ metrics_join_condition = mr_diffs[:merge_request_id]
+ .eq(mr_metrics[:merge_request_id])
+ .and(mr_metrics[:merged_at].not_eq(nil))
+
+ metrics_join = mr_diffs.join(mr_metrics).on(metrics_join_condition)
+
+ condition = MergeRequest.arel_table[:state].eq(:merged)
+ .and(MergeRequest::Metrics.arel_table[:merged_at].lteq(before))
+ .and(MergeRequest::Metrics.arel_table[:merged_at].not_eq(nil))
+
+ joins(metrics_join.join_sources, mr_join.join_sources).where(condition)
+ end
+
+ scope :old_closed_diffs, -> (before) do
+ condition = MergeRequest.arel_table[:state].eq(:closed)
+ .and(MergeRequest::Metrics.arel_table[:latest_closed_at].lteq(before))
+
+ joins(merge_request: :metrics).where(condition)
+ end
+
+ def self.ids_for_external_storage_migration(limit:)
+ # No point doing any work unless the feature is enabled
+ return [] unless Gitlab.config.external_diffs.enabled
+
+ case Gitlab.config.external_diffs.when
+ when 'always'
+ files_in_database.limit(limit).pluck(:id)
+ when 'outdated'
+ # Outdated is too complex to be a single SQL query, so split into three
+ before = EXTERNAL_DIFF_CUTOFF.ago
+
+ ids = files_in_database
+ .old_merged_diffs(before)
+ .limit(limit)
+ .pluck(:id)
+
+ return ids if ids.size >= limit
+
+ ids += files_in_database
+ .old_closed_diffs(before)
+ .limit(limit - ids.size)
+ .pluck(:id)
+
+ return ids if ids.size >= limit
+
+ ids + files_in_database
+ .not_latest_diffs
+ .limit(limit - ids.size)
+ .pluck(:id)
+ else
+ []
+ end
+ end
+
+ mount_uploader :external_diff, ExternalDiffUploader
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
+ after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
+
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
@@ -67,7 +158,14 @@ class MergeRequestDiff < ActiveRecord::Base
ensure_commit_shas
save_commits
save_diffs
+
+ # Another set of `after_save` hooks will be called here when we update the record
save
+ # We need to reset so that dirty tracking is reset when running the original set
+ # of `after_save` hooks that come after this `after_create` hook. Otherwise, the
+ # hooks that run when an attribute was changed are run twice.
+ reset
+
keep_around_commits
end
@@ -241,10 +339,109 @@ class MergeRequestDiff < ActiveRecord::Base
end
end
+ # Carrierwave defines `write_uploader` dynamically on this class, so `super`
+ # does not work. Alias the carrierwave method so we can call it when needed
+ alias_method :carrierwave_write_uploader, :write_uploader
+
+ # The `external_diff`, `external_diff_store`, and `stored_externally`
+ # columns were introduced in GitLab 11.8, but some background migration specs
+ # use factories that rely on current code with an old schema. Without these
+ # `has_attribute?` guards, they fail with a `MissingAttributeError`.
+ #
+ # For more details, see: https://gitlab.com/gitlab-org/gitlab-ce/issues/44990
+
+ def write_uploader(column, identifier)
+ carrierwave_write_uploader(column, identifier) if has_attribute?(column)
+ end
+
+ def update_external_diff_store
+ update_column(:external_diff_store, external_diff.object_store) if
+ has_attribute?(:external_diff_store)
+ end
+
+ def saved_change_to_external_diff?
+ super if has_attribute?(:external_diff)
+ end
+
+ def stored_externally
+ super if has_attribute?(:stored_externally)
+ end
+ alias_method :stored_externally?, :stored_externally
+
+ # If enabled, yields the external file containing the diff. Otherwise, yields
+ # nil. This method is not thread-safe, but it *is* re-entrant, which allows
+ # multiple merge_request_diff_files to load their data efficiently
+ def opening_external_diff
+ return yield(nil) unless stored_externally?
+ return yield(@external_diff_file) if @external_diff_file
+
+ external_diff.open do |file|
+ @external_diff_file = file
+
+ yield(@external_diff_file)
+ ensure
+ @external_diff_file = nil
+ end
+ end
+
+ # Transactionally migrate the current merge_request_diff_files entries to
+ # external storage. If external storage isn't an option for this diff, the
+ # method is a no-op.
+ def migrate_files_to_external_storage!
+ return if stored_externally? || !use_external_diff? || merge_request_diff_files.count == 0
+
+ rows = build_merge_request_diff_files(merge_request_diff_files)
+
+ transaction do
+ MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all
+ create_merge_request_diff_files(rows)
+ save!
+ end
+
+ merge_request_diff_files.reset
+ end
+
private
- def create_merge_request_diff_files(diffs)
- rows = diffs.map.with_index do |diff, index|
+ def encode_in_base64?(diff_text)
+ (diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?) ||
+ diff_text.include?("\0")
+ end
+
+ def build_external_merge_request_diff_files(rows)
+ tempfile = build_external_diff_tempfile(rows)
+
+ self.external_diff = tempfile
+ self.stored_externally = true
+
+ rows
+ ensure
+ tempfile&.unlink
+ end
+
+ def create_merge_request_diff_files(rows)
+ rows = build_external_merge_request_diff_files(rows) if use_external_diff?
+
+ # Faster inserts
+ Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ end
+
+ def build_external_diff_tempfile(rows)
+ Tempfile.open(external_diff.filename) do |file|
+ rows.each do |row|
+ data = row.delete(:diff)
+ row[:external_diff_offset] = file.pos
+ row[:external_diff_size] = data.bytesize
+
+ file.write(data)
+ end
+
+ file
+ end
+ end
+
+ def build_merge_request_diff_files(diffs)
+ diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
binary: false,
merge_request_diff_id: self.id,
@@ -255,24 +452,67 @@ class MergeRequestDiff < ActiveRecord::Base
diff_hash.tap do |hash|
diff_text = hash[:diff]
- if diff_text.encoding == Encoding::BINARY && !diff_text.ascii_only?
+ if encode_in_base64?(diff_text)
hash[:binary] = true
hash[:diff] = [diff_text].pack('m0')
end
end
end
+ end
- Gitlab::Database.bulk_insert('merge_request_diff_files', rows)
+ def use_external_diff?
+ return false unless has_attribute?(:external_diff)
+ return false unless Gitlab.config.external_diffs.enabled
+
+ case Gitlab.config.external_diffs.when
+ when 'always'
+ true
+ when 'outdated'
+ outdated_by_merge? || outdated_by_closure? || old_version?
+ else
+ false # Disable external diffs if misconfigured
+ end
+ end
+
+ def outdated_by_merge?
+ return false unless merge_request&.metrics&.merged_at
+
+ merge_request.merged? && merge_request.metrics.merged_at < EXTERNAL_DIFF_CUTOFF.ago
+ end
+
+ def outdated_by_closure?
+ return false unless merge_request&.metrics&.latest_closed_at
+
+ merge_request.closed? && merge_request.metrics.latest_closed_at < EXTERNAL_DIFF_CUTOFF.ago
+ end
+
+ # We can't rely on `merge_request.latest_merge_request_diff_id` because that
+ # may have been changed in `save_git_content` without being reflected in
+ # the association's instance. This query is always subject to races, but
+ # the worst case is that we *don't* make a diff external when we could. The
+ # background worker will make it external at a later date.
+ def old_version?
+ latest_id = MergeRequest
+ .where(id: merge_request_id)
+ .limit(1)
+ .pluck(:latest_merge_request_diff_id)
+ .first
+
+ self.id != latest_id
end
def load_diffs(options)
- collection = merge_request_diff_files
+ # Ensure all diff files operate on the same external diff file instance if
+ # present. This reduces file open/close overhead.
+ opening_external_diff do
+ collection = merge_request_diff_files
- if paths = options[:paths]
- collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
- end
+ if paths = options[:paths]
+ collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths)
+ end
- Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ Gitlab::Git::DiffCollection.new(collection.map(&:to_hash), options)
+ end
end
def load_commits
@@ -294,7 +534,8 @@ class MergeRequestDiff < ActiveRecord::Base
if diff_collection.any?
new_attributes[:state] = :collected
- create_merge_request_diff_files(diff_collection)
+ rows = build_merge_request_diff_files(diff_collection)
+ create_merge_request_diff_files(rows)
end
# Set our state to 'overflow' to make the #empty? and #collected?
@@ -311,10 +552,10 @@ class MergeRequestDiff < ActiveRecord::Base
def save_commits
MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse)
- # merge_request_diff_commits.reload is preferred way to reload associated
+ # merge_request_diff_commits.reset is preferred way to reload associated
# objects but it returns cached result for some reason in this case
# we can circumvent that by specifying that we need an uncached reload
- commits = self.class.uncached { merge_request_diff_commits.reload }
+ commits = self.class.uncached { merge_request_diff_commits.reset }
self.commits_count = commits.size
end
diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb
index 4ad3690512d..b897bbc8cf5 100644
--- a/app/models/merge_request_diff_commit.rb
+++ b/app/models/merge_request_diff_commit.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestDiffCommit < ActiveRecord::Base
+class MergeRequestDiffCommit < ApplicationRecord
include ShaAttribute
belongs_to :merge_request_diff
diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb
index a9f110bec5c..01ee82ae398 100644
--- a/app/models/merge_request_diff_file.rb
+++ b/app/models/merge_request_diff_file.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-class MergeRequestDiffFile < ActiveRecord::Base
+class MergeRequestDiffFile < ApplicationRecord
include Gitlab::EncodingHelper
include DiffFile
- belongs_to :merge_request_diff
+ belongs_to :merge_request_diff, inverse_of: :merge_request_diff_files
def utf8_diff
return '' if diff.blank?
@@ -13,6 +13,16 @@ class MergeRequestDiffFile < ActiveRecord::Base
end
def diff
- binary? ? super.unpack('m0').first : super
+ content =
+ if merge_request_diff&.stored_externally?
+ merge_request_diff.opening_external_diff do |file|
+ file.seek(external_diff_offset)
+ file.read(external_diff_size)
+ end
+ else
+ super
+ end
+
+ binary? ? content.unpack1('m0') : content
end
end
diff --git a/app/models/merge_requests_closing_issues.rb b/app/models/merge_requests_closing_issues.rb
index 242b65bedc0..61af50841ee 100644
--- a/app/models/merge_requests_closing_issues.rb
+++ b/app/models/merge_requests_closing_issues.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class MergeRequestsClosingIssues < ActiveRecord::Base
+class MergeRequestsClosingIssues < ApplicationRecord
belongs_to :merge_request
belongs_to :issue
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 3cc8e2c44bb..787600569fa 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Milestone < ActiveRecord::Base
+class Milestone < ApplicationRecord
# Represents a "No Milestone" state used for filtering Issues and Merge
# Requests that have no milestone assigned.
MilestoneStruct = Struct.new(:title, :name, :id)
@@ -28,7 +28,7 @@ class Milestone < ActiveRecord::Base
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
has_many :issues
- has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -37,13 +37,16 @@ class Milestone < ActiveRecord::Base
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) }
+ scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
- scope :for_projects_and_groups, -> (project_ids, group_ids) do
- conditions = []
- conditions << arel_table[:project_id].in(project_ids) if project_ids&.compact&.any?
- conditions << arel_table[:group_id].in(group_ids) if group_ids&.compact&.any?
+ scope :for_projects_and_groups, -> (projects, groups) do
+ projects = projects.compact if projects.is_a? Array
+ projects = [] if projects.nil?
- where(conditions.reduce(:or))
+ groups = groups.compact if groups.is_a? Array
+ groups = [] if groups.nil?
+
+ where(project_id: projects).or(where(group_id: groups))
end
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
@@ -75,7 +78,7 @@ class Milestone < ActiveRecord::Base
alias_attribute :name, :title
class << self
- # Searches for milestones matching the given query.
+ # Searches for milestones with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
@@ -86,6 +89,17 @@ class Milestone < ActiveRecord::Base
fuzzy_search(query, [:title, :description])
end
+ # Searches for milestones with a matching title.
+ #
+ # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search_title(query)
+ fuzzy_search(query, [:title])
+ end
+
def filter_by_state(milestones, state)
case state
when 'closed' then milestones.closed
@@ -94,6 +108,10 @@ class Milestone < ActiveRecord::Base
end
end
+ def count_by_state
+ reorder(nil).group(:state).count
+ end
+
def predefined?(milestone)
milestone == Any ||
milestone == None ||
@@ -129,18 +147,29 @@ class Milestone < ActiveRecord::Base
@link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
end
- def self.upcoming_ids_by_projects(projects)
- rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)
+ def self.upcoming_ids(projects, groups)
+ rel = unscoped
+ .for_projects_and_groups(projects, groups)
+ .active.where('milestones.due_date > CURRENT_DATE')
if Gitlab::Database.postgresql?
- rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
+ rel.order(:project_id, :group_id, :due_date).select('DISTINCT ON (project_id, group_id) id')
else
+ # We need to use MySQL's NULL-safe comparison operator `<=>` here
+ # because one of `project_id` or `group_id` is always NULL
+ join_clause = <<~HEREDOC
+ LEFT OUTER JOIN milestones earlier_milestones
+ ON milestones.project_id <=> earlier_milestones.project_id
+ AND milestones.group_id <=> earlier_milestones.group_id
+ AND milestones.due_date > earlier_milestones.due_date
+ AND earlier_milestones.due_date > CURRENT_DATE
+ AND earlier_milestones.state = 'active'
+ HEREDOC
+
rel
- .group(:project_id, :due_date, :id)
- .having('due_date = MIN(due_date)')
- .pluck(:id, :project_id, :due_date)
- .uniq(&:second)
- .map(&:first)
+ .joins(join_clause)
+ .where('earlier_milestones.id IS NULL')
+ .select(:id)
end
end
@@ -174,7 +203,7 @@ class Milestone < ActiveRecord::Base
return STATE_COUNT_HASH unless projects || groups
counts = Milestone
- .for_projects_and_groups(projects&.map(&:id), groups&.map(&:id))
+ .for_projects_and_groups(projects, groups)
.reorder(nil)
.group(:state)
.count
@@ -212,10 +241,10 @@ class Milestone < ActiveRecord::Base
end
def reference_link_text(from = nil)
- self.title
+ self.class.reference_prefix + self.title
end
- def milestoneish_ids
+ def milestoneish_id
id
end
@@ -258,27 +287,26 @@ class Milestone < ActiveRecord::Base
if project
relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
elsif group
- project_ids = group.projects.map(&:id)
- relation = Milestone.for_projects_and_groups(project_ids, [group.id])
+ relation = Milestone.for_projects_and_groups(group.projects.select(:id), [group.id])
end
title_exists = relation.find_by_title(title)
- errors.add(:title, "already being used for another group or project milestone.") if title_exists
+ errors.add(:title, _("already being used for another group or project milestone.")) if title_exists
end
# Milestone should be either a project milestone or a group milestone
def milestone_type_check
if group_id && project_id
field = project_id_changed? ? :project_id : :group_id
- errors.add(field, "milestone should belong either to a project or a group.")
+ errors.add(field, _("milestone should belong either to a project or a group."))
end
end
def milestone_format_reference(format = :iid)
- raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
+ raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_milestone? && format == :iid
- raise ArgumentError, 'Cannot refer to a group milestone by an internal id!'
+ raise ArgumentError, _('Cannot refer to a group milestone by an internal id!')
end
if format == :name && !name.include?('"')
@@ -294,7 +322,7 @@ class Milestone < ActiveRecord::Base
def start_date_should_be_less_than_due_date
if due_date <= start_date
- errors.add(:due_date, "must be greater than start date")
+ errors.add(:due_date, _("must be greater than start date"))
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 3c9b1d32a53..7393ef4b05c 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Namespace < ActiveRecord::Base
+class Namespace < ApplicationRecord
include CacheMarkdownField
include Sortable
include Gitlab::VisibilityLevel
@@ -11,6 +11,7 @@ class Namespace < ActiveRecord::Base
include IgnorableColumn
include FeatureGate
include FromUnion
+ include Gitlab::Utils::StrongMemoize
ignore_column :deleted_at
@@ -49,17 +50,20 @@ class Namespace < ActiveRecord::Base
validate :nesting_level_allowed
+ validates_associated :runners
+
delegate :name, to: :owner, allow_nil: true, prefix: true
+ delegate :avatar_url, to: :owner, allow_nil: true
after_commit :refresh_access_of_projects_invited_groups, on: :update, if: -> { previous_changes.key?('share_with_group_lock') }
before_create :sync_share_with_group_lock_with_parent
before_update :sync_share_with_group_lock_with_parent, if: :parent_changed?
- after_update :force_share_with_group_lock_on_descendants, if: -> { share_with_group_lock_changed? && share_with_group_lock? }
+ after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? }
# Legacy Storage specific hooks
- after_update :move_dir, if: :path_or_parent_changed?
+ after_update :move_dir, if: :saved_change_to_path_or_parent?
before_destroy(prepend: true) { prepare_for_destroy }
after_destroy :rm_dir
@@ -73,7 +77,8 @@ class Namespace < ActiveRecord::Base
'COALESCE(SUM(ps.storage_size), 0) AS storage_size',
'COALESCE(SUM(ps.repository_size), 0) AS repository_size',
'COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size',
- 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size'
+ 'COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size',
+ 'COALESCE(SUM(ps.packages_size), 0) AS packages_size'
)
end
@@ -140,7 +145,7 @@ class Namespace < ActiveRecord::Base
def send_update_instructions
projects.each do |project|
- project.send_move_instructions("#{full_path_was}/#{project.path}")
+ project.send_move_instructions("#{full_path_before_last_save}/#{project.path}")
end
end
@@ -148,8 +153,12 @@ class Namespace < ActiveRecord::Base
type == 'Group' ? 'group' : 'user'
end
+ def user?
+ kind == 'user'
+ end
+
def find_fork_of(project)
- return nil unless project.fork_network
+ return unless project.fork_network
if Gitlab::SafeRequestStore.active?
forks_in_namespace = Gitlab::SafeRequestStore.fetch("namespaces:#{id}:forked_projects") do
@@ -175,16 +184,16 @@ class Namespace < ActiveRecord::Base
# Returns all ancestors, self, and descendants of the current namespace.
def self_and_hierarchy
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(self.class.where(id: id))
- .all_groups
+ .all_objects
end
# Returns all the ancestors of the current namespaces.
def ancestors
return self.class.none unless parent_id
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(self.class.where(id: parent_id))
.base_and_ancestors
end
@@ -192,27 +201,27 @@ class Namespace < ActiveRecord::Base
# returns all ancestors upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil, hierarchy_order: nil)
- Gitlab::GroupHierarchy.new(self.class.where(id: id))
+ Gitlab::ObjectHierarchy.new(self.class.where(id: id))
.ancestors(upto: top, hierarchy_order: hierarchy_order)
end
def self_and_ancestors
return self.class.where(id: id) unless parent_id
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(self.class.where(id: id))
.base_and_ancestors
end
# Returns all the descendants of the current namespace.
def descendants
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(self.class.where(parent_id: id))
.base_and_descendants
end
def self_and_descendants
- Gitlab::GroupHierarchy
+ Gitlab::ObjectHierarchy
.new(self.class.where(id: id))
.base_and_descendants
end
@@ -221,10 +230,6 @@ class Namespace < ActiveRecord::Base
[owner_id]
end
- def parent_changed?
- parent_id_changed?
- end
-
# Includes projects from this namespace and projects from all subgroups
# that belongs to this namespace
def all_projects
@@ -254,12 +259,12 @@ class Namespace < ActiveRecord::Base
false
end
- def full_path_was
- if parent_id_was.nil?
- path_was
+ def full_path_before_last_save
+ if parent_id_before_last_save.nil?
+ path_before_last_save
else
- previous_parent = Group.find_by(id: parent_id_was)
- previous_parent.full_path + '/' + path_was
+ previous_parent = Group.find_by(id: parent_id_before_last_save)
+ previous_parent.full_path + '/' + path_before_last_save
end
end
@@ -267,10 +272,34 @@ class Namespace < ActiveRecord::Base
owner.refresh_authorized_projects
end
+ def auto_devops_enabled?
+ first_auto_devops_config[:status]
+ end
+
+ def first_auto_devops_config
+ return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil?
+
+ strong_memoize(:first_auto_devops_config) do
+ if has_parent?
+ parent.first_auto_devops_config
+ else
+ { scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? }
+ end
+ end
+ end
+
private
- def path_or_parent_changed?
- path_changed? || parent_changed?
+ def parent_changed?
+ parent_id_changed?
+ end
+
+ def saved_change_to_parent?
+ saved_change_to_parent_id?
+ end
+
+ def saved_change_to_path_or_parent?
+ saved_change_to_path? || saved_change_to_parent_id?
end
def refresh_access_of_projects_invited_groups
@@ -293,7 +322,7 @@ class Namespace < ActiveRecord::Base
end
def force_share_with_group_lock_on_descendants
- return unless Group.supports_nested_groups?
+ return unless Group.supports_nested_objects?
# We can't use `descendants.update_all` since Rails will throw away the WITH
# RECURSIVE statement. We also can't use WHERE EXISTS since we can't use
@@ -306,6 +335,7 @@ class Namespace < ActiveRecord::Base
def write_projects_repository_config
all_projects.find_each do |project|
project.write_repository_config
+ project.track_project_repository
end
end
end
diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb
index 6da3bb7bfb7..ecbeb24ee0a 100644
--- a/app/models/network/graph.rb
+++ b/app/models/network/graph.rb
@@ -40,9 +40,12 @@ module Network
# Get commits from repository
#
def collect_commits
- find_commits(count_to_display_commit_in_center).map do |commit|
- # Decorate with app/model/network/commit.rb
- Network::Commit.new(commit)
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/58013
+ Gitlab::GitalyClient.allow_n_plus_1_calls do
+ find_commits(count_to_display_commit_in_center).map do |commit|
+ # Decorate with app/model/network/commit.rb
+ Network::Commit.new(commit)
+ end
end
end
diff --git a/app/models/note.rb b/app/models/note.rb
index becf14e9785..081d6f91230 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -3,7 +3,7 @@
# 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
+class Note < ApplicationRecord
extend ActiveModel::Naming
include Participable
include Mentionable
@@ -313,6 +313,14 @@ class Note < ActiveRecord::Base
!system?
end
+ # Since we're using `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
+ # This makes sure it is only marked as edited when the note body is updated.
+ def edited?
+ return false if updated_by.blank?
+
+ super
+ end
+
def cross_reference_not_visible_for?(user)
cross_reference? && !all_referenced_mentionables_allowed?(user)
end
@@ -456,6 +464,10 @@ class Note < ActiveRecord::Base
Upload.find_by(model: self, path: paths)
end
+ def parent
+ project
+ end
+
private
def keep_around_commit
diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb
index 27aef7adc48..fcc9e2b3fd8 100644
--- a/app/models/note_diff_file.rb
+++ b/app/models/note_diff_file.rb
@@ -1,9 +1,28 @@
# frozen_string_literal: true
-class NoteDiffFile < ActiveRecord::Base
+class NoteDiffFile < ApplicationRecord
include DiffFile
+ scope :for_commit_or_unresolved, -> do
+ joins(:diff_note).where("resolved_at IS NULL OR noteable_type = 'Commit'")
+ end
+
+ scope :referencing_sha, -> (oids, project_id:) do
+ joins(:diff_note).where(notes: { project_id: project_id, commit_id: oids })
+ end
+
+ delegate :original_position, :project, to: :diff_note
+
belongs_to :diff_note, inverse_of: :note_diff_file
validates :diff_note, presence: true
+
+ def raw_diff_file
+ raw_diff = Gitlab::Git::Diff.new(to_hash)
+
+ Gitlab::Diff::File.new(raw_diff,
+ repository: project.repository,
+ diff_refs: original_position.diff_refs,
+ unique_identifier: id)
+ end
end
diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb
index 9f16eefe074..6889e0d776b 100644
--- a/app/models/notification_recipient.rb
+++ b/app/models/notification_recipient.rb
@@ -47,14 +47,14 @@ class NotificationRecipient
def suitable_notification_level?
case notification_level
- when :disabled, nil
- false
- when :custom
- custom_enabled? || %i[participating mention].include?(@type)
- when :watch, :participating
- !action_excluded?
when :mention
@type == :mention
+ when :participating
+ !excluded_participating_action? && %i[participating mention watch].include?(@type)
+ when :custom
+ custom_enabled? || %i[participating mention].include?(@type)
+ when :watch
+ !excluded_watcher_action?
else
false
end
@@ -100,18 +100,14 @@ class NotificationRecipient
end
end
- def action_excluded?
- excluded_watcher_action? || excluded_participating_action?
- end
-
def excluded_watcher_action?
- return false unless @custom_action && notification_level == :watch
+ return false unless @custom_action
NotificationSetting::EXCLUDED_WATCHER_EVENTS.include?(@custom_action)
end
def excluded_participating_action?
- return false unless @custom_action && notification_level == :participating
+ return false unless @custom_action
NotificationSetting::EXCLUDED_PARTICIPATING_EVENTS.include?(@custom_action)
end
@@ -119,24 +115,28 @@ class NotificationRecipient
private
def read_ability
- return nil if @skip_read_ability
+ return if @skip_read_ability
return @read_ability if instance_variable_defined?(:@read_ability)
@read_ability =
- case @target
- when Issuable
- :"read_#{@target.to_ability_name}"
- when Ci::Pipeline
+ if @target.is_a?(Ci::Pipeline)
:read_build # We have build trace in pipeline emails
- when ActiveRecord::Base
- :"read_#{@target.class.model_name.name.underscore}"
- else
- nil
+ elsif default_ability_for_target
+ :"read_#{default_ability_for_target}"
+ end
+ end
+
+ def default_ability_for_target
+ @default_ability_for_target ||=
+ if @target.respond_to?(:to_ability_name)
+ @target.to_ability_name
+ elsif @target.class.respond_to?(:model_name)
+ @target.class.model_name.name.underscore
end
end
def default_project
- return nil if @target.nil?
+ return if @target.nil?
return @target if @target.is_a?(Project)
return @target.project if @target.respond_to?(:project)
end
@@ -153,7 +153,7 @@ class NotificationRecipient
user.global_notification_setting
end
- # Returns the notificaton_setting of the lowest group in hierarchy with non global level
+ # Returns the notification_setting of the lowest group in hierarchy with non global level
def closest_non_global_group_notification_settting
return unless @group
return if indexed_group_notification_settings.empty?
diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb
index e82eaf4e069..61af5c09ae4 100644
--- a/app/models/notification_setting.rb
+++ b/app/models/notification_setting.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class NotificationSetting < ActiveRecord::Base
+class NotificationSetting < ApplicationRecord
include IgnorableColumn
ignore_column :events
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 7a33ade826b..407d85b1520 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class PagesDomain < ActiveRecord::Base
+class PagesDomain < ApplicationRecord
VERIFICATION_KEY = 'gitlab-pages-verification-code'.freeze
VERIFICATION_THRESHOLD = 3.days.freeze
@@ -26,7 +26,7 @@ class PagesDomain < ActiveRecord::Base
after_initialize :set_verification_code
after_create :update_daemon
- after_update :update_daemon, if: :pages_config_changed?
+ after_update :update_daemon, if: :saved_change_to_pages_config?
after_destroy :update_daemon
scope :enabled, -> { where('enabled_until >= ?', Time.now ) }
@@ -38,6 +38,8 @@ class PagesDomain < ActiveRecord::Base
where(verified_at.eq(nil).or(enabled_until.eq(nil).or(enabled_until.lt(threshold))))
end
+ scope :for_removal, -> { where("remove_at < ?", Time.now) }
+
def verified?
!!verified_at
end
@@ -146,21 +148,21 @@ class PagesDomain < ActiveRecord::Base
end
# rubocop: enable CodeReuse/ServiceClass
- def pages_config_changed?
- project_id_changed? ||
- domain_changed? ||
- certificate_changed? ||
- key_changed? ||
+ def saved_change_to_pages_config?
+ saved_change_to_project_id? ||
+ saved_change_to_domain? ||
+ saved_change_to_certificate? ||
+ saved_change_to_key? ||
became_enabled? ||
became_disabled?
end
def became_enabled?
- enabled_until.present? && !enabled_until_was.present?
+ enabled_until.present? && !enabled_until_before_last_save.present?
end
def became_disabled?
- !enabled_until.present? && enabled_until_was.present?
+ !enabled_until.present? && enabled_until_before_last_save.present?
end
def validate_matching_key
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 73a58f2420e..f69f0e2dccb 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -1,9 +1,10 @@
# frozen_string_literal: true
-class PersonalAccessToken < ActiveRecord::Base
+class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
- add_authentication_token_field :token, digest: true, fallback: true
+
+ add_authentication_token_field :token, digest: true
REDIS_EXPIRY_TIME = 3.minutes
@@ -55,7 +56,7 @@ class PersonalAccessToken < ActiveRecord::Base
protected
def validate_scopes
- unless revoked || scopes.all? { |scope| Gitlab::Auth.available_scopes.include?(scope.to_sym) }
+ unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb
index 47da0209c2f..50eed7344bd 100644
--- a/app/models/pool_repository.rb
+++ b/app/models/pool_repository.rb
@@ -3,11 +3,11 @@
# The PoolRepository model is the database equivalent of an ObjectPool for Gitaly
# That is; PoolRepository is the record in the database, ObjectPool is the
# repository on disk
-class PoolRepository < ActiveRecord::Base
+class PoolRepository < ApplicationRecord
include Shardable
include AfterCommitQueue
- has_one :source_project, class_name: 'Project'
+ belongs_to :source_project, class_name: 'Project'
validates :source_project, presence: true
has_many :member_projects, class_name: 'Project'
@@ -18,6 +18,7 @@ class PoolRepository < ActiveRecord::Base
state :scheduled
state :ready
state :failed
+ state :obsolete
event :schedule do
transition none: :scheduled
@@ -31,6 +32,10 @@ class PoolRepository < ActiveRecord::Base
transition all => :failed
end
+ event :mark_obsolete do
+ transition all => :obsolete
+ end
+
state all - [:ready] do
def joinable?
false
@@ -54,6 +59,12 @@ class PoolRepository < ActiveRecord::Base
::ObjectPool::ScheduleJoinWorker.perform_async(pool.id)
end
end
+
+ after_transition any => :obsolete do |pool, _|
+ pool.run_after_commit do
+ ::ObjectPool::DestroyWorker.perform_async(pool.id)
+ end
+ end
end
def create_object_pool
@@ -70,22 +81,26 @@ class PoolRepository < ActiveRecord::Base
object_pool.link(repository.raw)
end
- # This RPC can cause data loss, as not all objects are present the local repository
- # No execution path yet, will be added through:
- # https://gitlab.com/gitlab-org/gitaly/issues/1415
- def delete_repository_alternate(repository)
- object_pool.unlink_repository(repository.raw)
+ def mark_obsolete_if_last(repository)
+ if member_projects.where.not(id: repository.project.id).exists?
+ true
+ else
+ mark_obsolete
+ end
end
def object_pool
@object_pool ||= Gitlab::Git::ObjectPool.new(
shard.name,
disk_path + '.git',
- source_project.repository.raw)
+ source_project.repository.raw,
+ source_project.full_path
+ )
end
def inspect
- "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source_project.full_path}>"
+ source = source_project ? source_project.full_path : 'nil'
+ "#<#{self.class.name} id:#{id} state:#{state} disk_path:#{disk_path} source_project: #{source}>"
end
private
diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb
index e264fe88e47..74ccf23cf69 100644
--- a/app/models/postgresql/replication_slot.rb
+++ b/app/models/postgresql/replication_slot.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
module Postgresql
- class ReplicationSlot < ActiveRecord::Base
+ class ReplicationSlot < ApplicationRecord
self.table_name = 'pg_replication_slots'
# Returns true if there are any replication slots in use.
diff --git a/app/models/programming_language.rb b/app/models/programming_language.rb
index 0e667dac21e..375fbe9b5a9 100644
--- a/app/models/programming_language.rb
+++ b/app/models/programming_language.rb
@@ -1,6 +1,12 @@
# frozen_string_literal: true
-class ProgrammingLanguage < ActiveRecord::Base
+class ProgrammingLanguage < ApplicationRecord
validates :name, presence: true
validates :color, allow_blank: false, color: true
+
+ # Returns all programming languages which match the given name (case
+ # insensitively).
+ scope :with_name_case_insensitive, ->(name) do
+ where(arel_table[:name].matches(sanitize_sql_like(name)))
+ end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index 67262ecce85..ab4da61dcf8 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class Project < ActiveRecord::Base
+class Project < ApplicationRecord
include Gitlab::ConfigHelper
include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
@@ -38,7 +38,6 @@ class Project < ActiveRecord::Base
BoardLimitExceeded = Class.new(StandardError)
STATISTICS_ATTRIBUTE = 'repositories_count'.freeze
- NUMBER_OF_PERMITTED_BOARDS = 1
UNKNOWN_IMPORT_URL = 'http://unknown.git'.freeze
# Hashed Storage versions handle rolling out new storage to project and dependents models:
# nil: legacy
@@ -73,7 +72,7 @@ class Project < ActiveRecord::Base
delegate :no_import?, to: :import_state, allow_nil: true
default_value_for :archived, false
- default_value_for :visibility_level, gitlab_config_features.visibility_level
+ default_value_for(:visibility_level) { Gitlab::CurrentSettings.default_project_visibility }
default_value_for :resolve_outdated_diff_discussions, false
default_value_for :container_registry_enabled, gitlab_config_features.container_registry
default_value_for(:repository_storage) { Gitlab::CurrentSettings.pick_repository_storage }
@@ -85,13 +84,13 @@ class Project < ActiveRecord::Base
default_value_for :snippets_enabled, gitlab_config_features.snippets
default_value_for :only_allow_merge_if_all_discussions_are_resolved, false
- add_authentication_token_field :runners_token, encrypted: true, migrating: true
+ add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required }
before_validation :mark_remote_mirrors_for_removal, if: -> { RemoteMirror.table_exists? }
before_save :ensure_runners_token
- after_save :update_project_statistics, if: :namespace_id_changed?
+ after_save :update_project_statistics, if: :saved_change_to_namespace_id?
after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? }
@@ -117,7 +116,7 @@ class Project < ActiveRecord::Base
after_initialize :use_hashed_storage
after_create :check_repository_absence!
after_create :ensure_storage_path_exists
- after_save :ensure_storage_path_exists, if: :namespace_id_changed?
+ after_save :ensure_storage_path_exists, if: :saved_change_to_namespace_id?
acts_as_ordered_taggable
@@ -137,7 +136,7 @@ class Project < ActiveRecord::Base
alias_attribute :parent_id, :namespace_id
has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event'
- has_many :boards, before_add: :validate_board_limit
+ has_many :boards
# Project services
has_one :campfire_service
@@ -161,6 +160,7 @@ class Project < ActiveRecord::Base
has_one :pushover_service
has_one :jira_service
has_one :redmine_service
+ has_one :youtrack_service
has_one :custom_issue_tracker_service
has_one :bugzilla_service
has_one :gitlab_issue_tracker_service, inverse_of: :project
@@ -187,6 +187,8 @@ class Project < ActiveRecord::Base
has_one :import_state, autosave: true, class_name: 'ProjectImportState', inverse_of: :project
has_one :import_export_upload, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :project_repository, inverse_of: :project
+ has_one :error_tracking_setting, inverse_of: :project, class_name: 'ErrorTracking::ProjectErrorTrackingSetting'
+ has_one :metrics_setting, inverse_of: :project, class_name: 'ProjectMetricsSetting'
# Merge Requests for target project should be removed with it
has_many :merge_requests, foreign_key: 'target_project_id', inverse_of: :target_project
@@ -248,15 +250,15 @@ class Project < ActiveRecord::Base
has_many :container_repositories, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :commit_statuses
- # The relation :all_pipelines is intented to be used when we want to get the
+ # The relation :all_pipelines is intended to be used when we want to get the
# whole list of pipelines associated to the project
has_many :all_pipelines, class_name: 'Ci::Pipeline', inverse_of: :project
- # The relation :ci_pipelines is intented to be used when we want to get only
+ # The relation :ci_pipelines is intended to be used when we want to get only
# those pipeline which are directly related to CI. There are
# other pipelines, like webide ones, that we won't retrieve
# if we use this relation.
has_many :ci_pipelines,
- -> { Feature.enabled?(:pipeline_ci_sources_only, default_enabled: true) ? ci_sources : all },
+ -> { ci_sources },
class_name: 'Ci::Pipeline',
inverse_of: :project
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
@@ -295,6 +297,9 @@ class Project < ActiveRecord::Base
allow_destroy: true,
reject_if: ->(attrs) { attrs[:id].blank? && attrs[:url].blank? }
+ accepts_nested_attributes_for :error_tracking_setting, update_only: true
+ accepts_nested_attributes_for :metrics_setting, update_only: true, allow_destroy: true
+
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
@@ -303,13 +308,14 @@ class Project < ActiveRecord::Base
delegate :group_runners_enabled, :group_runners_enabled=, :group_runners_enabled?, to: :ci_cd_settings
delegate :group_clusters_enabled?, to: :group, allow_nil: true
delegate :root_ancestor, to: :namespace, allow_nil: true
+ delegate :last_pipeline, to: :commit, allow_nil: true
# Validations
validates :creator, presence: true, on: :create
validates :description, length: { maximum: 2000 }, allow_blank: true
validates :ci_config_path,
format: { without: %r{(\.{2}|\A/)},
- message: 'cannot include leading slash or directory traversal.' },
+ message: _('cannot include leading slash or directory traversal.') },
length: { maximum: 255 },
allow_blank: true
validates :name,
@@ -324,15 +330,14 @@ class Project < ActiveRecord::Base
validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id }
- validates :import_url, url: { protocols: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
- ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS },
- allow_localhost: false,
- enforce_user: true }, if: [:external_import?, :import_url_changed?]
+ validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
+ ports: ->(project) { project.persisted? ? VALID_MIRROR_PORTS : VALID_IMPORT_PORTS },
+ enforce_user: true }, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 }
- validate :check_limit, on: :create
+ validate :check_personal_projects_limit, on: :create
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
- validate :visibility_level_allowed_by_group
- validate :visibility_level_allowed_as_fork
+ validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
+ validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
validate :check_wiki_path_conflict
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
@@ -351,7 +356,8 @@ class Project < ActiveRecord::Base
# last_activity_at is throttled every minute, but last_repository_updated_at is updated with every push
scope :sorted_by_activity, -> { reorder("GREATEST(COALESCE(last_activity_at, '1970-01-01'), COALESCE(last_repository_updated_at, '1970-01-01')) DESC") }
- scope :sorted_by_stars, -> { reorder(star_count: :desc) }
+ scope :sorted_by_stars_desc, -> { reorder(star_count: :desc) }
+ scope :sorted_by_stars_asc, -> { reorder(star_count: :asc) }
scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) }
scope :personal, ->(user) { where(namespace_id: user.namespace_id) }
@@ -375,8 +381,10 @@ class Project < ActiveRecord::Base
# "enabled" here means "not disabled". It includes private features!
scope :with_feature_enabled, ->(feature) {
- access_level_attribute = ProjectFeature.access_level_attribute(feature)
- with_project_feature.where(project_features: { access_level_attribute => [nil, ProjectFeature::PRIVATE, ProjectFeature::ENABLED, ProjectFeature::PUBLIC] })
+ access_level_attribute = ProjectFeature.arel_table[ProjectFeature.access_level_attribute(feature)]
+ enabled_feature = access_level_attribute.gt(ProjectFeature::DISABLED).or(access_level_attribute.eq(nil))
+
+ with_project_feature.where(enabled_feature)
}
# Picks a feature where the level is exactly that given.
@@ -385,6 +393,16 @@ class Project < ActiveRecord::Base
with_project_feature.where(project_features: { access_level_attribute => level })
}
+ # Picks projects which use the given programming language
+ scope :with_programming_language, ->(language_name) do
+ lang_id_query = ProgrammingLanguage
+ .with_name_case_insensitive(language_name)
+ .select(:id)
+
+ joins(:repository_languages)
+ .where(repository_languages: { programming_language_id: lang_id_query })
+ end
+
scope :with_builds_enabled, -> { with_feature_enabled(:builds) }
scope :with_issues_enabled, -> { with_feature_enabled(:issues) }
scope :with_issues_available_for_user, ->(current_user) { with_feature_available_for_user(:issues, current_user) }
@@ -405,13 +423,13 @@ class Project < ActiveRecord::Base
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
chronic_duration_attr :build_timeout_human_readable, :build_timeout,
- default: 3600, error_message: 'Maximum job timeout has a value which could not be accepted'
+ default: 3600, error_message: _('Maximum job timeout has a value which could not be accepted')
validates :build_timeout, allow_nil: true,
numericality: { greater_than_or_equal_to: 10.minutes,
less_than: 1.month,
only_integer: true,
- message: 'needs to be beetween 10 minutes and 1 month' }
+ message: _('needs to be beetween 10 minutes and 1 month') }
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
@@ -445,10 +463,12 @@ class Project < ActiveRecord::Base
# Returns a collection of projects that is either public or visible to the
# logged in user.
- def self.public_or_visible_to_user(user = nil)
+ def self.public_or_visible_to_user(user = nil, min_access_level = nil)
+ min_access_level = nil if user&.admin?
+
if user
where('EXISTS (?) OR projects.visibility_level IN (?)',
- user.authorizations_for_projects,
+ user.authorizations_for_projects(min_access_level: min_access_level),
Gitlab::VisibilityLevel.levels_for_user(user))
else
public_to_user
@@ -458,24 +478,32 @@ class Project < ActiveRecord::Base
# project features may be "disabled", "internal", "enabled" or "public". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either public, enabled, or internal with permission for the user.
+ # Note: this scope doesn't enforce that the user has access to the projects, it just checks
+ # that the user has access to the feature. It's important to use this scope with others
+ # that checks project authorizations first.
#
# This method uses an optimised version of `with_feature_access_level` for
# logged in users to more efficiently get private projects with the given
# feature.
def self.with_feature_available_for_user(feature, user)
- visible = [nil, ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
+ visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
if user&.admin?
with_feature_enabled(feature)
elsif user
+ min_access_level = ProjectFeature.required_minimum_access_level(feature)
column = ProjectFeature.quoted_access_level_column(feature)
with_project_feature
- .where("#{column} IN (?) OR (#{column} = ? AND EXISTS (?))",
- visible,
- ProjectFeature::PRIVATE,
- user.authorizations_for_projects)
+ .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
+ {
+ public_visible: visible,
+ private_visible: ProjectFeature::PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
+ })
else
+ # This has to be added to include features whose value is nil in the db
+ visible << nil
with_feature_access_level(feature, visible)
end
end
@@ -520,7 +548,9 @@ class Project < ActiveRecord::Base
when 'latest_activity_asc'
reorder(last_activity_at: :asc)
when 'stars_desc'
- sorted_by_stars
+ sorted_by_stars_desc
+ when 'stars_asc'
+ sorted_by_stars_asc
else
order_by(method)
end
@@ -528,6 +558,7 @@ class Project < ActiveRecord::Base
def reference_pattern
%r{
+ (?<!#{Gitlab::PathRegex::PATH_START_CHAR})
((?<namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})\/)?
(?<project>#{Gitlab::PathRegex::PROJECT_PATH_FORMAT_REGEX})
}x
@@ -567,10 +598,26 @@ class Project < ActiveRecord::Base
end
end
+ def all_pipelines
+ if builds_enabled?
+ super
+ else
+ super.external
+ end
+ end
+
+ def ci_pipelines
+ if builds_enabled?
+ super
+ else
+ super.external
+ end
+ end
+
# returns all ancestor-groups upto but excluding the given namespace
# when no namespace is given, all ancestors upto the top are returned
def ancestors_upto(top = nil, hierarchy_order: nil)
- Gitlab::GroupHierarchy.new(Group.where(id: namespace_id))
+ Gitlab::ObjectHierarchy.new(Group.where(id: namespace_id))
.base_and_ancestors(upto: top, hierarchy_order: hierarchy_order)
end
@@ -593,12 +640,29 @@ class Project < ActiveRecord::Base
end
def has_auto_devops_implicitly_enabled?
- auto_devops&.enabled.nil? &&
- (Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self))
+ auto_devops_config = first_auto_devops_config
+
+ auto_devops_config[:scope] != :project && auto_devops_config[:status]
end
def has_auto_devops_implicitly_disabled?
- auto_devops&.enabled.nil? && !(Gitlab::CurrentSettings.auto_devops_enabled? || Feature.enabled?(:force_autodevops_on_by_default, self))
+ auto_devops_config = first_auto_devops_config
+
+ auto_devops_config[:scope] != :project && !auto_devops_config[:status]
+ end
+
+ def first_auto_devops_config
+ return namespace.first_auto_devops_config if auto_devops&.enabled.nil?
+
+ { scope: :project, status: auto_devops&.enabled || Feature.enabled?(:force_autodevops_on_by_default, self) }
+ end
+
+ def multiple_mr_assignees_enabled?
+ Feature.enabled?(:multiple_merge_request_assignees, self)
+ end
+
+ def daily_statistics_enabled?
+ Feature.enabled?(:project_daily_statistics, self, default_enabled: true)
end
def empty_repo?
@@ -645,19 +709,15 @@ class Project < ActiveRecord::Base
end
# ref can't be HEAD, can only be branch/tag name or SHA
- def latest_successful_builds_for(ref = default_branch)
+ def latest_successful_build_for(job_name, ref = default_branch)
latest_pipeline = ci_pipelines.latest_successful_for(ref)
+ return unless latest_pipeline
- if latest_pipeline
- latest_pipeline.builds.latest.with_artifacts_archive
- else
- builds.none
- end
+ latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name)
end
- def latest_successful_build_for(job_name, ref = default_branch)
- builds = latest_successful_builds_for(ref)
- builds.find_by!(name: job_name)
+ def latest_successful_build_for!(job_name, ref = default_branch)
+ latest_successful_build_for(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}"))
end
def merge_base_commit(first_commit_id, second_commit_id)
@@ -723,11 +783,13 @@ class Project < ActiveRecord::Base
end
def import_url=(value)
- return super(value) unless Gitlab::UrlSanitizer.valid?(value)
-
- import_url = Gitlab::UrlSanitizer.new(value)
- super(import_url.sanitized_url)
- create_or_update_import_data(credentials: import_url.credentials)
+ if Gitlab::UrlSanitizer.valid?(value)
+ import_url = Gitlab::UrlSanitizer.new(value)
+ super(import_url.sanitized_url)
+ create_or_update_import_data(credentials: import_url.credentials)
+ else
+ super(value)
+ end
end
def import_url
@@ -797,7 +859,7 @@ class Project < ActiveRecord::Base
def mark_stuck_remote_mirrors_as_failed!
remote_mirrors.stuck.update_all(
update_status: :failed,
- last_error: 'The remote mirror took to long to complete.',
+ last_error: _('The remote mirror took to long to complete.'),
last_update_at: Time.now
)
end
@@ -811,18 +873,26 @@ class Project < ActiveRecord::Base
::Gitlab::CurrentSettings.mirror_available
end
- def check_limit
- unless creator.can_create_project? || namespace.kind == 'group'
- projects_limit = creator.projects_limit
+ def check_personal_projects_limit
+ # Since this method is called as validation hook, `creator` might not be
+ # present. Since the validation for that will fail, we can just return
+ # early.
+ return if !creator || creator.can_create_project? ||
+ namespace.kind == 'group'
- if projects_limit == 0
- self.errors.add(:limit_reached, "Personal project creation is not allowed. Please contact your administrator with questions")
+ limit = creator.projects_limit
+ error =
+ if limit.zero?
+ _('Personal project creation is not allowed. Please contact your administrator with questions')
else
- self.errors.add(:limit_reached, "Your project limit is #{projects_limit} projects! Please contact your administrator to increase it")
+ _('Your project limit is %{limit} projects! Please contact your administrator to increase it')
end
- end
- rescue
- self.errors.add(:base, "Can't check your ability to create project")
+
+ self.errors.add(:limit_reached, error % { limit: limit })
+ end
+
+ def should_validate_visibility_level?
+ new_record? || changes.has_key?(:visibility_level)
end
def visibility_level_allowed_by_group
@@ -830,14 +900,14 @@ class Project < ActiveRecord::Base
level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase
group_level_name = Gitlab::VisibilityLevel.level_name(self.group.visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed in a #{group_level_name} group.")
+ self.errors.add(:visibility_level, _("%{level_name} is not allowed in a %{group_level_name} group.") % { level_name: level_name, group_level_name: group_level_name })
end
def visibility_level_allowed_as_fork
return if visibility_level_allowed_as_fork?
level_name = Gitlab::VisibilityLevel.level_name(self.visibility_level).downcase
- self.errors.add(:visibility_level, "#{level_name} is not allowed since the fork source project has lower visibility.")
+ self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name })
end
def check_wiki_path_conflict
@@ -846,7 +916,7 @@ class Project < ActiveRecord::Base
path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
if Project.where(namespace_id: namespace_id, path: path_to_check).exists?
- errors.add(:name, 'has already been taken')
+ errors.add(:name, _('has already been taken'))
end
end
@@ -866,7 +936,7 @@ class Project < ActiveRecord::Base
return unless pages_https_only?
unless pages_domains.all?(&:https?)
- errors.add(:pages_https_only, "cannot be enabled unless all domains have TLS certificates")
+ errors.add(:pages_https_only, _("cannot be enabled unless all domains have TLS certificates"))
end
end
@@ -913,11 +983,16 @@ class Project < ActiveRecord::Base
def new_issuable_address(author, address_type)
return unless Gitlab::IncomingEmail.supports_issue_creation? && author
+ # check since this can come from a request parameter
+ return unless %w(issue merge_request).include?(address_type)
+
author.ensure_incoming_email_token!
- suffix = address_type == 'merge_request' ? '+merge-request' : ''
- Gitlab::IncomingEmail.reply_address(
- "#{full_path}#{suffix}+#{author.incoming_email_token}")
+ suffix = address_type.dasherize
+
+ # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-issue@localhost.com
+ # example: incoming+h5bp-html5-boilerplate-8-1234567890abcdef123456789-merge-request@localhost.com
+ Gitlab::IncomingEmail.reply_address("#{full_path_slug}-#{project_id}-#{author.incoming_email_token}-#{suffix}")
end
def build_commit_note(commit)
@@ -1042,7 +1117,7 @@ class Project < ActiveRecord::Base
# rubocop: disable CodeReuse/ServiceClass
def create_labels
Label.templates.each do |label|
- params = label.attributes.except('id', 'template', 'created_at', 'updated_at')
+ params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type')
Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true)
end
end
@@ -1141,7 +1216,7 @@ class Project < ActiveRecord::Base
def valid_repo?
repository.exists?
rescue
- errors.add(:path, 'Invalid repository path')
+ errors.add(:path, _('Invalid repository path'))
false
end
@@ -1151,11 +1226,9 @@ class Project < ActiveRecord::Base
def repo_exists?
strong_memoize(:repo_exists) do
- begin
- repository.exists?
- rescue
- false
- end
+ repository.exists?
+ rescue
+ false
end
end
@@ -1171,7 +1244,7 @@ class Project < ActiveRecord::Base
"#{web_url}.git"
end
- # Is overriden in EE
+ # Is overridden in EE
def lfs_http_url_to_repo(_)
http_url_to_repo
end
@@ -1181,7 +1254,7 @@ class Project < ActiveRecord::Base
end
def fork_source
- return nil unless forked?
+ return unless forked?
forked_from_project || fork_network&.root_project
end
@@ -1234,7 +1307,7 @@ class Project < ActiveRecord::Base
# Check if repository with same path already exists on disk we can
# skip this for the hashed storage because the path does not change
if legacy_storage? && repository_with_same_path_already_exists?
- errors.add(:base, 'There is already a repository with that name on disk')
+ errors.add(:base, _('There is already a repository with that name on disk'))
return false
end
@@ -1244,21 +1317,19 @@ class Project < ActiveRecord::Base
end
def track_project_repository
- return unless hashed_storage?(:repository)
-
- project_repo = project_repository || build_project_repository
- project_repo.update!(shard_name: repository_storage, disk_path: disk_path)
+ repository = project_repository || build_project_repository
+ repository.update!(shard_name: repository_storage, disk_path: disk_path)
end
def create_repository(force: false)
# Forked import is handled asynchronously
return if forked? && !force
- if gitlab_shell.create_repository(repository_storage, disk_path)
+ if gitlab_shell.create_project_repository(self)
repository.after_create
true
else
- errors.add(:base, 'Failed to create repository via gitlab-shell')
+ errors.add(:base, _('Failed to create repository via gitlab-shell'))
false
end
end
@@ -1331,9 +1402,10 @@ class Project < ActiveRecord::Base
repository.raw_repository.write_ref('HEAD', "refs/heads/#{branch}")
repository.copy_gitattributes(branch)
repository.after_change_head
+ ProjectCacheWorker.perform_async(self.id, [], [:commit_count])
reload_default_branch
else
- errors.add(:base, "Could not change HEAD: branch '#{branch}' does not exist")
+ errors.add(:base, _("Could not change HEAD: branch '%{branch}' does not exist") % { branch: branch })
false
end
end
@@ -1371,7 +1443,7 @@ class Project < ActiveRecord::Base
# update visibility_level of forks
def update_forks_visibility_level
- return unless visibility_level < visibility_level_was
+ return unless visibility_level < visibility_level_before_last_save
forks.each do |forked_project|
if forked_project.visibility_level > visibility_level
@@ -1385,7 +1457,7 @@ class Project < ActiveRecord::Base
ProjectWiki.new(self, self.owner).wiki
true
rescue ProjectWiki::CouldNotCreateWikiError
- errors.add(:base, 'Failed create wiki')
+ errors.add(:base, _('Failed create wiki'))
false
end
@@ -1530,7 +1602,7 @@ class Project < ActiveRecord::Base
end
def pages_available?
- Gitlab.config.pages.enabled && !namespace.subgroup?
+ Gitlab.config.pages.enabled
end
def remove_private_deploy_keys
@@ -1580,6 +1652,13 @@ class Project < ActiveRecord::Base
def after_import
repository.after_import
wiki.repository.after_import
+
+ # The import assigns iid values on its own, e.g. by re-using GitHub ids.
+ # Flush existing InternalId records for this project for consistency reasons.
+ # Those records are going to be recreated with the next normal creation
+ # of a model instance (e.g. an Issue).
+ InternalId.flush_records!(project: self)
+
import_state.finish
import_state.remove_jid
update_project_counter_caches
@@ -1601,24 +1680,7 @@ class Project < ActiveRecord::Base
# rubocop: disable CodeReuse/ServiceClass
def after_create_default_branch
- return unless default_branch
-
- # Ensure HEAD points to the default branch in case it is not master
- change_head(default_branch)
-
- if Gitlab::CurrentSettings.default_branch_protection != Gitlab::Access::PROTECTION_NONE && !ProtectedBranch.protected?(self, default_branch)
- params = {
- name: default_branch,
- push_access_levels_attributes: [{
- access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_PUSH ? Gitlab::Access::DEVELOPER : Gitlab::Access::MAINTAINER
- }],
- merge_access_levels_attributes: [{
- access_level: Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE ? Gitlab::Access::DEVELOPER : Gitlab::Access::MAINTAINER
- }]
- }
-
- ProtectedBranches::CreateService.new(self, creator, params).execute(skip_authorization: true)
- end
+ Projects::ProtectDefaultBranchService.new(self).execute
end
# rubocop: enable CodeReuse/ServiceClass
@@ -1642,7 +1704,7 @@ class Project < ActiveRecord::Base
end
def export_path
- return nil unless namespace.present? || hashed_storage?(:repository)
+ return unless namespace.present? || hashed_storage?(:repository)
import_export_shared.archive_path
end
@@ -1701,8 +1763,23 @@ class Project < ActiveRecord::Base
.append(key: 'CI_PROJECT_NAMESPACE', value: namespace.full_path)
.append(key: 'CI_PROJECT_URL', value: web_url)
.append(key: 'CI_PROJECT_VISIBILITY', value: visibility)
+ .concat(pages_variables)
.concat(container_registry_variables)
.concat(auto_devops_variables)
+ .concat(api_variables)
+ end
+
+ def pages_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_PAGES_DOMAIN', value: Gitlab.config.pages.host)
+ variables.append(key: 'CI_PAGES_URL', value: pages_url)
+ end
+ end
+
+ def api_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ variables.append(key: 'CI_API_V4_URL', value: API::Helpers::Version.new('v4').root_url)
+ end
end
def container_registry_variables
@@ -1736,10 +1813,21 @@ class Project < ActiveRecord::Base
end
def protected_for?(ref)
- if repository.branch_exists?(ref)
- ProtectedBranch.protected?(self, ref)
- elsif repository.tag_exists?(ref)
- ProtectedTag.protected?(self, ref)
+ raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref)
+
+ resolved_ref = repository.expand_ref(ref) || ref
+ return false unless Gitlab::Git.tag_ref?(resolved_ref) || Gitlab::Git.branch_ref?(resolved_ref)
+
+ ref_name = if resolved_ref == ref
+ Gitlab::Git.ref_name(resolved_ref)
+ else
+ ref
+ end
+
+ if Gitlab::Git.branch_ref?(resolved_ref)
+ ProtectedBranch.protected?(self, ref_name)
+ elsif Gitlab::Git.tag_ref?(resolved_ref)
+ ProtectedTag.protected?(self, ref_name)
end
end
@@ -1766,6 +1854,24 @@ class Project < ActiveRecord::Base
handle_update_attribute_error(e, value)
end
+ # Tries to set repository as read_only, checking for existing Git transfers in progress beforehand
+ #
+ # @return [Boolean] true when set to read_only or false when an existing git transfer is in progress
+ def set_repository_read_only!
+ with_lock do
+ break false if git_transfer_in_progress?
+
+ update_column(:repository_read_only, true)
+ end
+ end
+
+ # Set repository as writable again
+ def set_repository_writable!
+ with_lock do
+ update_column(:repository_read_only, false)
+ end
+ end
+
def pushes_since_gc
Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
@@ -1817,8 +1923,8 @@ class Project < ActiveRecord::Base
false
end
- def full_path_was
- File.join(namespace.full_path, previous_changes['path'].first)
+ def full_path_before_last_save
+ File.join(namespace.full_path, path_before_last_save)
end
alias_method :name_with_namespace, :full_name
@@ -1840,7 +1946,7 @@ class Project < ActiveRecord::Base
#
# @param [Symbol] feature that needs to be rolled out for the project (:repository, :attachments)
def hashed_storage?(feature)
- raise ArgumentError, "Invalid feature" unless HASHED_STORAGE_FEATURES.include?(feature)
+ raise ArgumentError, _("Invalid feature") unless HASHED_STORAGE_FEATURES.include?(feature)
self.storage_version && self.storage_version >= HASHED_STORAGE_FEATURES[feature]
end
@@ -1849,6 +1955,14 @@ class Project < ActiveRecord::Base
persisted? && path_changed?
end
+ def human_merge_method
+ if merge_method == :ff
+ 'Fast-forward'
+ else
+ merge_method.to_s.humanize
+ end
+ end
+
def merge_method
if self.merge_requests_ff_only_enabled
:ff
@@ -1880,27 +1994,35 @@ class Project < ActiveRecord::Base
def migrate_to_hashed_storage!
return unless storage_upgradable?
- update!(repository_read_only: true)
+ if git_transfer_in_progress?
+ HashedStorage::ProjectMigrateWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
+ else
+ HashedStorage::ProjectMigrateWorker.perform_async(id)
+ end
+ end
- if repo_reference_count > 0 || wiki_reference_count > 0
- ProjectMigrateHashedStorageWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
+ def rollback_to_legacy_storage!
+ return if legacy_storage?
+
+ if git_transfer_in_progress?
+ HashedStorage::ProjectRollbackWorker.perform_in(Gitlab::ReferenceCounter::REFERENCE_EXPIRE_TIME, id)
else
- ProjectMigrateHashedStorageWorker.perform_async(id)
+ HashedStorage::ProjectRollbackWorker.perform_async(id)
end
end
+ def git_transfer_in_progress?
+ repo_reference_count > 0 || wiki_reference_count > 0
+ end
+
def storage_version=(value)
super
@storage = nil if storage_version_changed?
end
- def gl_repository(is_wiki:)
- Gitlab::GlRepository.gl_repository(self, is_wiki)
- end
-
- def reference_counter(wiki: false)
- Gitlab::ReferenceCounter.new(gl_repository(is_wiki: wiki))
+ def reference_counter(type: Gitlab::GlRepository::PROJECT)
+ Gitlab::ReferenceCounter.new(type.identifier_for_subject(self))
end
def badges
@@ -1920,23 +2042,20 @@ class Project < ActiveRecord::Base
.where('project_authorizations.project_id = merge_requests.target_project_id')
.limit(1)
.select(1)
- source_of_merge_requests.opened
- .where(allow_collaboration: true)
- .where('EXISTS (?)', developer_access_exists)
+ merge_requests_allowing_collaboration.where('EXISTS (?)', developer_access_exists)
end
- def branch_allows_collaboration?(user, branch_name)
- return false unless user
-
- cache_key = "user:#{user.id}:#{branch_name}:branch_allows_push"
+ def any_branch_allows_collaboration?(user)
+ fetch_branch_allows_collaboration(user)
+ end
- memoized_results = strong_memoize(:branch_allows_collaboration) do
- Hash.new do |result, cache_key|
- result[cache_key] = fetch_branch_allows_collaboration?(user, branch_name)
- end
- end
+ def branch_allows_collaboration?(user, branch_name)
+ fetch_branch_allows_collaboration(user, branch_name)
+ end
- memoized_results[cache_key]
+ def external_authorization_classification_label
+ super || ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
end
def licensed_features
@@ -2004,16 +2123,32 @@ class Project < ActiveRecord::Base
Feature.enabled?(:object_pools, self)
end
+ def leave_pool_repository
+ pool_repository&.mark_obsolete_if_last(repository) && update_column(:pool_repository_id, nil)
+ end
+
+ def link_pool_repository
+ pool_repository&.link_repository(repository)
+ end
+
+ def has_pool_repository?
+ pool_repository.present?
+ end
+
private
+ def merge_requests_allowing_collaboration(source_branch = nil)
+ relation = source_of_merge_requests.opened.where(allow_collaboration: true)
+ relation = relation.where(source_branch: source_branch) if source_branch
+ relation
+ end
+
def create_new_pool_repository
- pool = begin
- create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
- rescue ActiveRecord::RecordNotUnique
- pool_repository(true)
- end
+ pool = PoolRepository.safe_find_or_create_by!(shard: Shard.by_name(repository_storage), source_project: self)
+ update!(pool_repository: pool)
pool.schedule unless pool.scheduled?
+
pool
end
@@ -2034,14 +2169,14 @@ class Project < ActiveRecord::Base
end
def wiki_reference_count
- reference_counter(wiki: true).value
+ reference_counter(type: Gitlab::GlRepository::WIKI).value
end
def check_repository_absence!
return if skip_disk_validation
if repository_storage.blank? || repository_with_same_path_already_exists?
- errors.add(:base, 'There is already a repository with that name on disk')
+ errors.add(:base, _('There is already a repository with that name on disk'))
throw :abort
end
end
@@ -2074,17 +2209,6 @@ class Project < ActiveRecord::Base
"projects/#{id}/pushes_since_gc"
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
- # callbacks throw an exception, the object will not be added to the
- # collection. Before you add a new board to the boards collection if you
- # already have 1, 2, or n it will fail, but it if you have 0 that is lower
- # than the number of permitted boards per project it won't fail.
- def validate_board_limit(board)
- raise BoardLimitExceeded, 'Number of permitted boards exceeded' if boards.size >= NUMBER_OF_PERMITTED_BOARDS
- end
-
def update_project_statistics
stats = statistics || build_statistics
stats.update(namespace_id: namespace_id)
@@ -2098,7 +2222,7 @@ class Project < ActiveRecord::Base
errors.delete(error)
end
- errors.add(:base, "The project is still being deleted. Please try again later.")
+ errors.add(:base, _("The project is still being deleted. Please try again later."))
end
def pending_delete_twin
@@ -2130,26 +2254,19 @@ class Project < ActiveRecord::Base
raise ex
end
- def fetch_branch_allows_collaboration?(user, branch_name)
- check_access = -> do
- next false if empty_repo?
+ def fetch_branch_allows_collaboration(user, branch_name = nil)
+ return false unless user
- merge_requests = source_of_merge_requests.opened
- .where(allow_collaboration: true)
+ Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do
+ next false if empty_repo?
# Issue for N+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/49322
Gitlab::GitalyClient.allow_n_plus_1_calls do
- if branch_name
- merge_requests.find_by(source_branch: branch_name)&.can_be_merged_by?(user)
- else
- merge_requests.any? { |merge_request| merge_request.can_be_merged_by?(user) }
+ merge_requests_allowing_collaboration(branch_name).any? do |merge_request|
+ merge_request.can_be_merged_by?(user)
end
end
end
-
- Gitlab::SafeRequestStore.fetch("project-#{id}:branch-#{branch_name}:user-#{user.id}:branch_allows_collaboration") do
- check_access.call
- end
end
def services_templates
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index 2c590008db2..f95d3ab54e2 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectAuthorization < ActiveRecord::Base
+class ProjectAuthorization < ApplicationRecord
include FromUnion
belongs_to :user
diff --git a/app/models/project_auto_devops.rb b/app/models/project_auto_devops.rb
index 2253ad7b543..90bcb3067f6 100644
--- a/app/models/project_auto_devops.rb
+++ b/app/models/project_auto_devops.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectAutoDevops < ActiveRecord::Base
+class ProjectAutoDevops < ApplicationRecord
belongs_to :project
enum deploy_strategy: {
@@ -16,21 +16,8 @@ class ProjectAutoDevops < ActiveRecord::Base
after_save :create_gitlab_deploy_token, if: :needs_to_create_deploy_token?
- def instance_domain
- Gitlab::CurrentSettings.auto_devops_domain
- end
-
- def has_domain?
- domain.present? || instance_domain.present?
- end
-
def predefined_variables
Gitlab::Ci::Variables::Collection.new.tap do |variables|
- if has_domain?
- variables.append(key: 'AUTO_DEVOPS_DOMAIN',
- value: domain.presence || instance_domain)
- end
-
variables.concat(deployment_strategy_default_variables)
end
end
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index 1dad235cc2b..1414164b703 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectCiCdSetting < ActiveRecord::Base
+class ProjectCiCdSetting < ApplicationRecord
belongs_to :project, inverse_of: :ci_cd_settings
# The version of the schema that first introduced this model/table.
diff --git a/app/models/project_custom_attribute.rb b/app/models/project_custom_attribute.rb
index 4e767cb3b26..b0da586988a 100644
--- a/app/models/project_custom_attribute.rb
+++ b/app/models/project_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectCustomAttribute < ActiveRecord::Base
+class ProjectCustomAttribute < ApplicationRecord
belongs_to :project
validates :project, :key, :value, presence: true
diff --git a/app/models/project_daily_statistic.rb b/app/models/project_daily_statistic.rb
new file mode 100644
index 00000000000..5ee11ab186e
--- /dev/null
+++ b/app/models/project_daily_statistic.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class ProjectDailyStatistic < ApplicationRecord
+ belongs_to :project
+
+ scope :of_project, -> (project) { where(project: project) }
+ scope :of_last_30_days, -> { where('date >= ?', 29.days.ago.utc.to_date) }
+ scope :sorted_by_date_desc, -> { order(project_id: :desc, date: :desc) }
+ scope :sum_fetch_count, -> { sum(:fetch_count) }
+end
diff --git a/app/models/project_deploy_token.rb b/app/models/project_deploy_token.rb
index 719c492a1ff..a55667496fb 100644
--- a/app/models/project_deploy_token.rb
+++ b/app/models/project_deploy_token.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectDeployToken < ActiveRecord::Base
+class ProjectDeployToken < ApplicationRecord
belongs_to :project
belongs_to :deploy_token, inverse_of: :project_deploy_tokens
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 39f2b8fe0de..0542581c6e0 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectFeature < ActiveRecord::Base
+class ProjectFeature < ApplicationRecord
# == Project features permissions
#
# Grants access level to project tools
@@ -23,11 +23,11 @@ class ProjectFeature < ActiveRecord::Base
PUBLIC = 30
FEATURES = %i(issues merge_requests wiki snippets builds repository pages).freeze
+ PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
class << self
def access_level_attribute(feature)
- feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name)
- raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature)
+ feature = ensure_feature!(feature)
"#{feature}_access_level".to_sym
end
@@ -38,6 +38,21 @@ class ProjectFeature < ActiveRecord::Base
"#{table}.#{attribute}"
end
+
+ def required_minimum_access_level(feature)
+ feature = ensure_feature!(feature)
+
+ PRIVATE_FEATURES_MIN_ACCESS_LEVEL.fetch(feature, Gitlab::Access::GUEST)
+ end
+
+ private
+
+ def ensure_feature!(feature)
+ feature = feature.model_name.plural.to_sym if feature.respond_to?(:model_name)
+ raise ArgumentError, "invalid project feature: #{feature}" unless FEATURES.include?(feature)
+
+ feature
+ end
end
# Default scopes force us to unscope here since a service may need to check
@@ -61,7 +76,7 @@ class ProjectFeature < ActiveRecord::Base
# This feature might not be behind a feature flag at all, so default to true
return false unless ::Feature.enabled?(feature, user, default_enabled: true)
- get_permission(user, access_level(feature))
+ get_permission(user, feature)
end
def access_level(feature)
@@ -119,12 +134,12 @@ class ProjectFeature < ActiveRecord::Base
(FEATURES - %i(pages)).each {|f| validator.call("#{f}_access_level")}
end
- def get_permission(user, level)
- case level
+ def get_permission(user, feature)
+ case access_level(feature)
when DISABLED
false
when PRIVATE
- user && (project.team.member?(user) || user.full_private_access?)
+ team_access?(user, feature)
when ENABLED
true
when PUBLIC
@@ -133,4 +148,11 @@ class ProjectFeature < ActiveRecord::Base
true
end
end
+
+ def team_access?(user, feature)
+ return unless user
+ return true if user.full_private_access?
+
+ project.team.member?(user, ProjectFeature.required_minimum_access_level(feature))
+ end
end
diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb
index bc3759142ae..feaf172d48d 100644
--- a/app/models/project_group_link.rb
+++ b/app/models/project_group_link.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectGroupLink < ActiveRecord::Base
+class ProjectGroupLink < ApplicationRecord
include Expirable
GUEST = 10
@@ -14,7 +14,7 @@ class ProjectGroupLink < ActiveRecord::Base
validates :project_id, presence: true
validates :group, presence: true
- validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" }
+ validates :group_id, uniqueness: { scope: [:project_id], message: _("already shared with this group") }
validates :group_access, presence: true
validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true
validate :different_group
@@ -44,7 +44,7 @@ class ProjectGroupLink < ActiveRecord::Base
group_ids = project_group.ancestors.map(&:id).push(project_group.id)
if group_ids.include?(self.group.id)
- errors.add(:base, "Project cannot be shared with the group it is in or one of its ancestors.")
+ errors.add(:base, _("Project cannot be shared with the group it is in or one of its ancestors."))
end
end
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index 525725034a5..580e8dfd833 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class ProjectImportData < ActiveRecord::Base
+class ProjectImportData < ApplicationRecord
belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials,
key: Settings.attr_encrypted_db_key_base,
@@ -30,4 +30,8 @@ class ProjectImportData < ActiveRecord::Base
def merge_credentials(hash)
self.credentials = credentials.to_h.merge(hash) unless hash.empty?
end
+
+ def clear_credentials
+ self.credentials = {}
+ end
end
diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb
index 488f0cb5971..1605345efd5 100644
--- a/app/models/project_import_state.rb
+++ b/app/models/project_import_state.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectImportState < ActiveRecord::Base
+class ProjectImportState < ApplicationRecord
include AfterCommitQueue
self.table_name = "project_mirror_data"
diff --git a/app/models/project_metrics_setting.rb b/app/models/project_metrics_setting.rb
new file mode 100644
index 00000000000..a2a7dc571a4
--- /dev/null
+++ b/app/models/project_metrics_setting.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ProjectMetricsSetting < ApplicationRecord
+ belongs_to :project
+
+ validates :external_dashboard_url,
+ length: { maximum: 255 },
+ addressable_url: { enforce_sanitization: true, ascii_only: true }
+end
diff --git a/app/models/project_repository.rb b/app/models/project_repository.rb
index 38913f3f2f5..092efabd73f 100644
--- a/app/models/project_repository.rb
+++ b/app/models/project_repository.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProjectRepository < ActiveRecord::Base
+class ProjectRepository < ApplicationRecord
include Shardable
belongs_to :project, inverse_of: :project_repository
diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb
index cc5f1207653..3e28dc23782 100644
--- a/app/models/project_services/asana_service.rb
+++ b/app/models/project_services/asana_service.rb
@@ -11,7 +11,7 @@ class AsanaService < Service
end
def description
- 'Asana - Teamwork without email'
+ s_('AsanaService|Asana - Teamwork without email')
end
def help
@@ -36,13 +36,13 @@ http://app.asana.com/-/account_api'
{
type: 'text',
name: 'api_key',
- placeholder: 'User Personal Access Token. User must have access to task, all comments will be attributed to this user.',
+ placeholder: s_('AsanaService|User Personal Access Token. User must have access to task, all comments will be attributed to this user.'),
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.'
+ placeholder: s_('AsanaService|Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.')
}
]
end
@@ -73,7 +73,7 @@ http://app.asana.com/-/account_api'
project_name = project.full_name
data[:commits].each do |commit|
- push_msg = "#{user} pushed to branch #{branch} of #{project_name} ( #{commit[:url]} ):"
+ push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] }
check_commit(commit[:message], push_msg)
end
end
diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb
index a252052200a..dfeb21680a9 100644
--- a/app/models/project_services/bamboo_service.rb
+++ b/app/models/project_services/bamboo_service.rb
@@ -31,15 +31,15 @@ class BambooService < CiService
end
def title
- 'Atlassian Bamboo CI'
+ s_('BambooService|Atlassian Bamboo CI')
end
def description
- 'A continuous integration and build server'
+ s_('BambooService|A continuous integration and build server')
end
def help
- 'You must set up automatic revision labeling and a repository trigger in Bamboo.'
+ s_('BambooService|You must set up automatic revision labeling and a repository trigger in Bamboo.')
end
def self.to_param
@@ -49,11 +49,11 @@ class BambooService < CiService
def fields
[
{ type: 'text', name: 'bamboo_url',
- placeholder: 'Bamboo root URL like https://bamboo.example.com', required: true },
+ placeholder: s_('BambooService|Bamboo root URL like https://bamboo.example.com'), required: true },
{ type: 'text', name: 'build_key',
- placeholder: 'Bamboo build plan key like KEY', required: true },
+ placeholder: s_('BambooService|Bamboo build plan key like KEY'), required: true },
{ type: 'text', name: 'username',
- placeholder: 'A user with API access, if applicable' },
+ placeholder: s_('BambooService|A user with API access, if applicable') },
{ type: 'password', name: 'password' }
]
end
@@ -80,19 +80,27 @@ class BambooService < CiService
private
- def get_build_result_index
- # When Bamboo returns multiple results for a given changeset, arbitrarily assume the most relevant result to be the last one.
- -1
+ def get_build_result(response)
+ return if response.code != 200
+
+ # May be nil if no result, a single result hash, or an array if multiple results for a given changeset.
+ result = response.dig('results', 'results', 'result')
+
+ # In case of multiple results, arbitrarily assume the last one is the most relevant.
+ return result.last if result.is_a?(Array)
+
+ result
end
def read_build_page(response)
+ result = get_build_result(response)
key =
- if response.code != 200 || response.dig('results', 'results', 'size') == '0'
+ if result.blank?
# If actual build link can't be determined, send user to build summary page.
build_key
else
# If actual build link is available, go to build result page.
- response.dig('results', 'results', 'result', get_build_result_index, 'planResultKey', 'key')
+ result.dig('planResultKey', 'key')
end
build_url("browse/#{key}")
@@ -101,11 +109,15 @@ class BambooService < CiService
def read_commit_status(response)
return :error unless response.code == 200 || response.code == 404
- status = if response.code == 404 || response.dig('results', 'results', 'size') == '0'
- 'Pending'
- else
- response.dig('results', 'results', 'result', get_build_result_index, 'buildState')
- end
+ result = get_build_result(response)
+ status =
+ if result.blank?
+ 'Pending'
+ else
+ result.dig('buildState')
+ end
+
+ return :error unless status.present?
if status.include?('Success')
'success'
diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb
index 1d7877a1fb5..ad26e42a21b 100644
--- a/app/models/project_services/campfire_service.rb
+++ b/app/models/project_services/campfire_service.rb
@@ -57,7 +57,7 @@ class CampfireService < Service
# https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message
def speak(room_name, message, auth)
room = rooms(auth).find { |r| r["name"] == room_name }
- return nil unless room
+ return unless room
path = "/room/#{room["id"]}/speak.json"
body = {
diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb
new file mode 100644
index 00000000000..dae3a56116e
--- /dev/null
+++ b/app/models/project_services/chat_message/deployment_message.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+module ChatMessage
+ class DeploymentMessage < BaseMessage
+ attr_reader :commit_title
+ attr_reader :commit_url
+ attr_reader :deployable_id
+ attr_reader :deployable_url
+ attr_reader :environment
+ attr_reader :short_sha
+ attr_reader :status
+ attr_reader :user_url
+
+ def initialize(data)
+ super
+
+ @commit_title = data[:commit_title]
+ @commit_url = data[:commit_url]
+ @deployable_id = data[:deployable_id]
+ @deployable_url = data[:deployable_url]
+ @environment = data[:environment]
+ @short_sha = data[:short_sha]
+ @status = data[:status]
+ @user_url = data[:user_url]
+ end
+
+ def attachments
+ [{
+ text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}",
+ color: color
+ }]
+ end
+
+ def activity
+ {}
+ end
+
+ private
+
+ def message
+ "Deploy to #{environment} #{humanized_status}"
+ end
+
+ def color
+ case status
+ when 'success'
+ 'good'
+ when 'canceled'
+ 'warning'
+ when 'failed'
+ 'danger'
+ else
+ '#334455'
+ end
+ end
+
+ def project_link
+ link(project_name, project_url)
+ end
+
+ def deployment_link
+ link("##{deployable_id}", deployable_url)
+ end
+
+ def user_link
+ link(user_combined_name, user_url)
+ end
+
+ def commit_link
+ link(short_sha, commit_url)
+ end
+
+ def humanized_status
+ status == 'success' ? 'succeeded' : status
+ end
+ end
+end
diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb
index c10ee07ccf4..7c9ecc6b821 100644
--- a/app/models/project_services/chat_notification_service.rb
+++ b/app/models/project_services/chat_notification_service.rb
@@ -33,7 +33,7 @@ class ChatNotificationService < Service
def self.supported_events
%w[push issue confidential_issue merge_request note confidential_note tag_push
- pipeline wiki_page]
+ pipeline wiki_page deployment]
end
def fields
@@ -122,6 +122,8 @@ class ChatNotificationService < Service
ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data)
when "wiki_page"
ChatMessage::WikiPageMessage.new(data)
+ when "deployment"
+ ChatMessage::DeploymentMessage.new(data)
end
end
diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb
index 6dae4f3a4a6..80aa2101509 100644
--- a/app/models/project_services/deployment_service.rb
+++ b/app/models/project_services/deployment_service.rb
@@ -11,7 +11,7 @@ class DeploymentService < Service
%w()
end
- def predefined_variables
+ def predefined_variables(project:)
[]
end
diff --git a/app/models/project_services/discord_service.rb b/app/models/project_services/discord_service.rb
index 21afd14dbff..4385834ed0a 100644
--- a/app/models/project_services/discord_service.rb
+++ b/app/models/project_services/discord_service.rb
@@ -4,11 +4,11 @@ require "discordrb/webhooks"
class DiscordService < ChatNotificationService
def title
- "Discord Notifications"
+ s_("DiscordService|Discord Notifications")
end
def description
- "Receive event notifications in Discord"
+ s_("DiscordService|Receive event notifications in Discord")
end
def self.to_param
@@ -33,6 +33,11 @@ class DiscordService < ChatNotificationService
# No-op.
end
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
def default_fields
[
{ type: "text", name: "webhook", placeholder: "e.g. https://discordapp.com/api/webhooks/…" },
diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb
index fb73d430fb1..45de64a9990 100644
--- a/app/models/project_services/emails_on_push_service.rb
+++ b/app/models/project_services/emails_on_push_service.rb
@@ -7,11 +7,11 @@ class EmailsOnPushService < Service
validates :recipients, presence: true, if: :valid_recipients?
def title
- 'Emails on push'
+ s_('EmailsOnPushService|Emails on push')
end
def description
- 'Email the commits and diff of each push to a list of recipients.'
+ s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.')
end
def self.to_param
@@ -45,11 +45,11 @@ class EmailsOnPushService < Service
def fields
domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ")
[
- { type: 'checkbox', name: 'send_from_committer_email', title: "Send from committer",
- help: "Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. #{domains})." },
- { type: 'checkbox', name: 'disable_diffs', title: "Disable code diffs",
- help: "Don't include possibly sensitive code diffs in notification body." },
- { type: 'textarea', name: 'recipients', placeholder: 'Emails separated by whitespace' }
+ { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"),
+ help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain is part of the domain GitLab is running on (e.g. %{domains}).") % { domains: domains } },
+ { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"),
+ help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") },
+ { type: 'textarea', name: 'recipients', placeholder: s_('EmailsOnPushService|Emails separated by whitespace') }
]
end
end
diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb
index d2835c6ac82..593ce69b0fd 100644
--- a/app/models/project_services/external_wiki_service.rb
+++ b/app/models/project_services/external_wiki_service.rb
@@ -6,11 +6,11 @@ class ExternalWikiService < Service
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
def title
- 'External Wiki'
+ s_('ExternalWikiService|External Wiki')
end
def description
- 'Replaces the link to the internal wiki with a link to an external wiki.'
+ s_('ExternalWikiService|Replaces the link to the internal wiki with a link to an external wiki.')
end
def self.to_param
@@ -19,7 +19,7 @@ class ExternalWikiService < Service
def fields
[
- { type: 'text', name: 'external_wiki_url', placeholder: 'The URL of the external Wiki', required: true }
+ { type: 'text', name: 'external_wiki_url', placeholder: s_('ExternalWikiService|The URL of the external Wiki'), required: true }
]
end
diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb
index 76624263aab..094488cb431 100644
--- a/app/models/project_services/flowdock_service.rb
+++ b/app/models/project_services/flowdock_service.rb
@@ -9,7 +9,7 @@ class FlowdockService < Service
end
def description
- 'Flowdock is a collaboration web app for technical teams.'
+ s_('FlowdockService|Flowdock is a collaboration web app for technical teams.')
end
def self.to_param
@@ -18,7 +18,7 @@ class FlowdockService < Service
def fields
[
- { type: 'text', name: 'token', placeholder: 'Flowdock Git source token', required: true }
+ { type: 'text', name: 'token', placeholder: s_('FlowdockService|Flowdock Git source token'), required: true }
]
end
diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb
index 272cd0f4e47..699cf1659d1 100644
--- a/app/models/project_services/hangouts_chat_service.rb
+++ b/app/models/project_services/hangouts_chat_service.rb
@@ -35,6 +35,11 @@ class HangoutsChatService < ChatNotificationService
'https://chat.googleapis.com/v1/spaces…'
end
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
def default_fields
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb
index a15780c14f9..fb76bc89c98 100644
--- a/app/models/project_services/irker_service.rb
+++ b/app/models/project_services/irker_service.rb
@@ -59,7 +59,7 @@ class IrkerService < Service
' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \
' want to use a password, you have to omit the "#" on the channel). If you ' \
' specify a default IRC URI to prepend before each recipient, you can just ' \
- ' give a channel name.' },
+ ' give a channel name.' },
{ type: 'checkbox', name: 'colorize_messages' }
]
end
@@ -112,7 +112,7 @@ class IrkerService < Service
end
def consider_uri(uri)
- return nil if uri.scheme.nil?
+ return if uri.scheme.nil?
# Authorize both irc://domain.com/#chan and irc://domain.com/chan
if uri.is_a?(URI) && uri.scheme[/^ircs?\z/] && !uri.path.nil?
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index 9066a0b7f1d..7b4832b84a8 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -11,7 +11,7 @@ class JiraService < IssueTrackerService
validates :password, presence: true, if: :activated?
validates :jira_issue_transition_id,
- format: { with: Gitlab::Regex.jira_transition_id_regex, message: "transition ids can have only numbers which can be split with , or ;" },
+ format: { with: Gitlab::Regex.jira_transition_id_regex, message: s_("JiraService|transition ids can have only numbers which can be split with , or ;") },
allow_blank: true
# JIRA cloud version is deprecating authentication via username and password.
@@ -86,7 +86,7 @@ class JiraService < IssueTrackerService
if self.properties && self.properties['description'].present?
self.properties['description']
else
- 'Jira issue tracker'
+ s_('JiraService|Jira issue tracker')
end
end
@@ -96,11 +96,11 @@ class JiraService < IssueTrackerService
def fields
[
- { type: 'text', name: 'url', title: 'Web URL', placeholder: 'https://jira.example.com', required: true },
- { type: 'text', name: 'api_url', title: 'JIRA API URL', placeholder: 'If different from Web URL' },
- { type: 'text', name: 'username', title: 'Username or Email', placeholder: 'Use a username for server version and an email for cloud version', required: true },
- { type: 'password', name: 'password', title: 'Password or API token', placeholder: 'Use a password for server version and an API token for cloud version', required: true },
- { type: 'text', name: 'jira_issue_transition_id', title: 'Transition ID(s)', placeholder: 'Use , or ; to separate multiple transition IDs' }
+ { type: 'text', name: 'url', title: s_('JiraService|Web URL'), placeholder: 'https://jira.example.com', required: true },
+ { type: 'text', name: 'api_url', title: s_('JiraService|JIRA API URL'), placeholder: s_('JiraService|If different from Web URL') },
+ { type: 'text', name: 'username', title: s_('JiraService|Username or Email'), placeholder: s_('JiraService|Use a username for server version and an email for cloud version'), required: true },
+ { type: 'password', name: 'password', title: s_('JiraService|Password or API token'), placeholder: s_('JiraService|Use a password for server version and an API token for cloud version'), required: true },
+ { type: 'text', name: 'jira_issue_transition_id', title: s_('JiraService|Transition ID(s)'), placeholder: s_('JiraService|Use , or ; to separate multiple transition IDs') }
]
end
@@ -139,7 +139,7 @@ class JiraService < IssueTrackerService
def create_cross_reference_note(mentioned, noteable, author)
unless can_cross_reference?(noteable)
- return "Events for #{noteable.model_name.plural.humanize(capitalize: false)} are disabled."
+ return s_("JiraService|Events for %{noteable_model_name} are disabled.") % { noteable_model_name: noteable.model_name.plural.humanize(capitalize: false) }
end
jira_issue = jira_request { client.Issue.find(mentioned.id) }
@@ -205,17 +205,15 @@ class JiraService < IssueTrackerService
# if any transition fails it will log the error message and stop the transition sequence
def transition_issue(issue)
jira_issue_transition_id.scan(Gitlab::Regex.jira_transition_id_regex).each do |transition_id|
- begin
- issue.transitions.build.save!(transition: { id: transition_id })
- rescue => error
- log_error("Issue transition failed", error: error.message, client_url: client_url)
- return false
- end
+ issue.transitions.build.save!(transition: { id: transition_id })
+ rescue => error
+ log_error("Issue transition failed", error: error.message, client_url: client_url)
+ return false
end
end
def add_issue_solved_comment(issue, commit_id, commit_url)
- link_title = "GitLab: Solved by commit #{commit_id}."
+ link_title = "Solved by commit #{commit_id}."
comment = "Issue solved with [#{commit_id}|#{commit_url}]."
link_props = build_remote_link_props(url: commit_url, title: link_title, resolved: true)
send_message(issue, comment, link_props)
@@ -230,7 +228,7 @@ class JiraService < IssueTrackerService
project_name = data[:project][:name]
message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'"
- link_title = "GitLab: Mentioned on #{entity_name} - #{entity_title}"
+ link_title = "#{entity_name.capitalize} - #{entity_title}"
link_props = build_remote_link_props(url: entity_url, title: link_title)
unless comment_exists?(issue, message)
@@ -267,6 +265,7 @@ class JiraService < IssueTrackerService
def find_remote_link(issue, url)
links = jira_request { issue.remotelink.all }
+ return unless links
links.find { |link| link.object["url"] == url }
end
@@ -278,6 +277,7 @@ class JiraService < IssueTrackerService
{
GlobalID: 'GitLab',
+ relationship: 'mentioned on',
object: {
url: url,
title: title,
@@ -339,9 +339,9 @@ class JiraService < IssueTrackerService
def self.event_description(event)
case event
when "merge_request", "merge_request_events"
- "JIRA comments will be created when an issue gets referenced in a merge request."
+ s_("JiraService|JIRA comments will be created when an issue gets referenced in a merge request.")
when "commit", "commit_events"
- "JIRA comments will be created when an issue gets referenced in a commit."
+ s_("JiraService|JIRA comments will be created when an issue gets referenced in a commit.")
end
end
end
diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb
index b801fd84a07..aa6b4aa1d5e 100644
--- a/app/models/project_services/kubernetes_service.rb
+++ b/app/models/project_services/kubernetes_service.rb
@@ -53,7 +53,7 @@ class KubernetesService < DeploymentService
end
def description
- 'Kubernetes / Openshift integration'
+ 'Kubernetes / OpenShift integration'
end
def help
@@ -86,7 +86,7 @@ class KubernetesService < DeploymentService
]
end
- def actual_namespace
+ def kubernetes_namespace_for(project)
if namespace.present?
namespace
else
@@ -113,8 +113,8 @@ class KubernetesService < DeploymentService
Gitlab::Ci::Variables::Collection.new.tap do |variables|
variables
.append(key: 'KUBE_URL', value: api_url)
- .append(key: 'KUBE_TOKEN', value: token, public: false)
- .append(key: 'KUBE_NAMESPACE', value: actual_namespace)
+ .append(key: 'KUBE_TOKEN', value: token, public: false, masked: true)
+ .append(key: 'KUBE_NAMESPACE', value: kubernetes_namespace_for(project))
.append(key: 'KUBECONFIG', value: kubeconfig, public: false, file: true)
if ca_pem.present?
@@ -131,8 +131,10 @@ class KubernetesService < DeploymentService
# short time later
def terminals(environment)
with_reactive_cache do |data|
- pods = filter_by_label(data[:pods], app: environment.slug)
- terminals = pods.flat_map { |pod| terminals_for_pod(api_url, actual_namespace, pod) }
+ project = environment.project
+
+ pods = filter_by_project_environment(data[:pods], project.full_path_slug, environment.slug)
+ terminals = pods.flat_map { |pod| terminals_for_pod(api_url, kubernetes_namespace_for(project), pod) }.compact
terminals.each { |terminal| add_terminal_auth(terminal, terminal_auth) }
end
end
@@ -169,7 +171,7 @@ class KubernetesService < DeploymentService
def kubeconfig
to_kubeconfig(
url: api_url,
- namespace: actual_namespace,
+ namespace: kubernetes_namespace_for(project),
token: token,
ca_pem: ca_pem)
end
@@ -186,7 +188,7 @@ class KubernetesService < DeploymentService
end
def build_kube_client!
- raise "Incomplete settings" unless api_url && actual_namespace && token
+ raise "Incomplete settings" unless api_url && kubernetes_namespace_for(project) && token
Gitlab::Kubernetes::KubeClient.new(
api_url,
@@ -200,7 +202,7 @@ class KubernetesService < DeploymentService
def read_pods
kubeclient = build_kube_client!
- kubeclient.get_pods(namespace: actual_namespace).as_json
+ kubeclient.get_pods(namespace: kubernetes_namespace_for(project)).as_json
rescue Kubeclient::ResourceNotFoundError
[]
end
diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb
index c34078f13c1..c22a6dc26f6 100644
--- a/app/models/project_services/microsoft_teams_service.rb
+++ b/app/models/project_services/microsoft_teams_service.rb
@@ -33,6 +33,11 @@ class MicrosoftTeamsService < ChatNotificationService
def default_channel_placeholder
end
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
def default_fields
[
{ type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" },
diff --git a/app/models/project_services/mock_ci_service.rb b/app/models/project_services/mock_ci_service.rb
index d8bba58dcbf..c5e5f4f6400 100644
--- a/app/models/project_services/mock_ci_service.rb
+++ b/app/models/project_services/mock_ci_service.rb
@@ -2,7 +2,7 @@
# For an example companion mocking service, see https://gitlab.com/gitlab-org/gitlab-mock-ci-service
class MockCiService < CiService
- ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze
+ ALLOWED_STATES = %w[failed canceled running pending success success-with-warnings skipped not_found].freeze
prop_accessor :mock_service_url
validates :mock_service_url, presence: true, public_url: true, if: :activated?
diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb
index d60a6a7efa3..7ba69370f14 100644
--- a/app/models/project_services/pipelines_email_service.rb
+++ b/app/models/project_services/pipelines_email_service.rb
@@ -10,11 +10,11 @@ class PipelinesEmailService < Service
end
def title
- 'Pipelines emails'
+ _('Pipelines emails')
end
def description
- 'Email the pipelines status to a list of recipients.'
+ _('Email the pipelines status to a list of recipients.')
end
def self.to_param
@@ -51,7 +51,7 @@ class PipelinesEmailService < Service
[
{ type: 'textarea',
name: 'recipients',
- placeholder: 'Emails separated by comma',
+ placeholder: _('Emails separated by comma'),
required: true },
{ type: 'checkbox',
name: 'notify_only_broken_pipelines' }
diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb
index 617e502b639..c15993bdc06 100644
--- a/app/models/project_services/pivotaltracker_service.rb
+++ b/app/models/project_services/pivotaltracker_service.rb
@@ -11,7 +11,7 @@ class PivotaltrackerService < Service
end
def description
- 'Project Management Software (Source Commits Endpoint)'
+ s_('PivotalTrackerService|Project Management Software (Source Commits Endpoint)')
end
def self.to_param
@@ -23,14 +23,14 @@ class PivotaltrackerService < Service
{
type: 'text',
name: 'token',
- placeholder: 'Pivotal Tracker API token.',
+ placeholder: s_('PivotalTrackerService|Pivotal Tracker API token.'),
required: true
},
{
type: 'text',
name: 'restrict_to_branch',
- placeholder: 'Comma-separated list of branches which will be ' \
- 'automatically inspected. Leave blank to include all branches.'
+ placeholder: s_('PivotalTrackerService|Comma-separated list of branches which will be ' \
+ 'automatically inspected. Leave blank to include all branches.')
}
]
end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
index 60cb2d380d5..c68a9d923c8 100644
--- a/app/models/project_services/prometheus_service.rb
+++ b/app/models/project_services/prometheus_service.rb
@@ -71,7 +71,7 @@ class PrometheusService < MonitoringService
end
def prometheus_client
- RestClient::Resource.new(api_url, max_redirects: 0) if api_url && manual_configuration? && active?
+ RestClient::Resource.new(api_url, max_redirects: 0) if should_return_client?
end
def prometheus_available?
@@ -83,6 +83,10 @@ class PrometheusService < MonitoringService
private
+ def should_return_client?
+ api_url && manual_configuration? && active? && valid?
+ end
+
def synchronize_service_state
self.active = prometheus_available? || manual_configuration?
diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb
index 4e48c348b45..0d35bab7f80 100644
--- a/app/models/project_services/pushover_service.rb
+++ b/app/models/project_services/pushover_service.rb
@@ -11,7 +11,7 @@ class PushoverService < Service
end
def description
- 'Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.'
+ s_('PushoverService|Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.')
end
def self.to_param
@@ -20,15 +20,15 @@ class PushoverService < Service
def fields
[
- { type: 'text', name: 'api_key', placeholder: 'Your application key', required: true },
- { type: 'text', name: 'user_key', placeholder: 'Your user key', required: true },
- { type: 'text', name: 'device', placeholder: 'Leave blank for all active devices' },
+ { type: 'text', name: 'api_key', placeholder: s_('PushoverService|Your application key'), required: true },
+ { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true },
+ { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') },
{ type: 'select', name: 'priority', required: true, choices:
[
- ['Lowest Priority', -2],
- ['Low Priority', -1],
- ['Normal Priority', 0],
- ['High Priority', 1]
+ [s_('PushoverService|Lowest Priority'), -2],
+ [s_('PushoverService|Low Priority'), -1],
+ [s_('PushoverService|Normal Priority'), 0],
+ [s_('PushoverService|High Priority'), 1]
],
default_choice: 0 },
{ type: 'select', name: 'sound', choices:
@@ -73,15 +73,15 @@ class PushoverService < Service
message =
if Gitlab::Git.blank_ref?(before)
- "#{data[:user_name]} pushed new branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} pushed new branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
elsif Gitlab::Git.blank_ref?(after)
- "#{data[:user_name]} deleted branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} deleted branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
else
- "#{data[:user_name]} push to branch \"#{ref}\"."
+ s_("PushoverService|%{user_name} push to branch \"%{ref}\".") % { user_name: data[:user_name], ref: ref }
end
if data[:total_commits_count] > 0
- message = [message, "Total commits count: #{data[:total_commits_count]}"].join("\n")
+ message = [message, s_("PushoverService|Total commits count: %{total_commits_count}") % { total_commits_count: data[:total_commits_count] }].join("\n")
end
pushover_data = {
@@ -92,7 +92,7 @@ class PushoverService < Service
title: "#{project.full_name}",
message: message,
url: data[:project][:web_url],
- url_title: "See project #{project.full_name}"
+ url_title: s_("PushoverService|See project %{project_full_name}") % { project_full_name: project.full_name }
}
# Sound parameter MUST NOT be sent to API if not selected
diff --git a/app/models/project_services/slack_slash_commands_service.rb b/app/models/project_services/slack_slash_commands_service.rb
index 6c82e088231..6a454070fe2 100644
--- a/app/models/project_services/slack_slash_commands_service.rb
+++ b/app/models/project_services/slack_slash_commands_service.rb
@@ -22,6 +22,10 @@ class SlackSlashCommandsService < SlashCommandsService
end
end
+ def chat_responder
+ ::Gitlab::Chat::Responder::Slack
+ end
+
private
def format(text)
diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb
index b8e17087db5..3245cd22e73 100644
--- a/app/models/project_services/teamcity_service.rb
+++ b/app/models/project_services/teamcity_service.rb
@@ -39,9 +39,7 @@ class TeamcityService < CiService
end
def help
- 'The build configuration in Teamcity must use the build format '\
- 'number %build.vcs.number% '\
- 'you will also want to configure monitoring of all branches so merge '\
+ 'You will want to configure monitoring of all branches so merge '\
'requests build, that setting is in the vsc root advanced settings.'
end
@@ -70,7 +68,7 @@ class TeamcityService < CiService
end
def calculate_reactive_cache(sha, ref)
- response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}")
+ response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,revision:#{sha}")
{ build_page: read_build_page(response), commit_status: read_commit_status(response) }
end
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
new file mode 100644
index 00000000000..957be685aea
--- /dev/null
+++ b/app/models/project_services/youtrack_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+class YoutrackService < IssueTrackerService
+ validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
+
+ prop_accessor :description, :project_url, :issues_url
+
+ # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1
+ def self.reference_pattern(only_long: false)
+ if only_long
+ /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)/
+ else
+ /(?<issue>\b[A-Z][A-Za-z0-9_]*-\d+)|(#{Issue.reference_prefix}(?<issue>\d+))/
+ end
+ end
+
+ def title
+ 'YouTrack'
+ end
+
+ def description
+ if self.properties && self.properties['description'].present?
+ self.properties['description']
+ else
+ 'YouTrack issue tracker'
+ end
+ end
+
+ def self.to_param
+ 'youtrack'
+ end
+
+ def fields
+ [
+ { type: 'text', name: 'description', placeholder: description },
+ { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
+ { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }
+ ]
+ end
+end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index 781a197d56f..6fe8cb40d25 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
-class ProjectStatistics < ActiveRecord::Base
+class ProjectStatistics < ApplicationRecord
belongs_to :project
belongs_to :namespace
before_save :update_storage_size
COLUMNS_TO_REFRESH = [:repository_size, :lfs_objects_size, :commit_count].freeze
- INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size] }.freeze
+ INCREMENTABLE_COLUMNS = { build_artifacts_size: %i[storage_size], packages_size: %i[storage_size] }.freeze
def total_repository_size
repository_size + lfs_objects_size
@@ -36,8 +36,13 @@ class ProjectStatistics < ActiveRecord::Base
self.lfs_objects_size = project.lfs_objects.sum(:size)
end
+ # older migrations fail due to non-existent attribute without this
+ def packages_size
+ has_attribute?(:packages_size) ? super.to_i : 0
+ end
+
def update_storage_size
- self.storage_size = repository_size + lfs_objects_size + build_artifacts_size
+ self.storage_size = repository_size + lfs_objects_size + build_artifacts_size + packages_size
end
# Since this incremental update method does not call update_storage_size above,
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index 33bc6a561f9..aeba2843e5d 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -74,6 +74,14 @@ class ProjectTeam
end
alias_method :users, :members
+ # `members` method uses project_authorizations table which
+ # is updated asynchronously, on project move it still contains
+ # old members who may not have access to the new location,
+ # so we filter out only members of project or project's group
+ def members_in_project_and_ancestors
+ members.where(id: member_user_ids)
+ end
+
def guests
@guests ||= fetch_members(Gitlab::Access::GUEST)
end
@@ -191,4 +199,8 @@ class ProjectTeam
def group
project.group
end
+
+ def member_user_ids
+ Member.on_project_and_ancestors(project).select(:user_id)
+ end
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 559e4f99294..c91add6439f 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -13,6 +13,11 @@ class ProjectWiki
CouldNotCreateWikiError = Class.new(StandardError)
SIDEBAR = '_sidebar'
+ TITLE_ORDER = 'title'
+ CREATED_AT_ORDER = 'created_at'
+ DIRECTION_DESC = 'desc'
+ DIRECTION_ASC = 'asc'
+
# Returns a string describing what went wrong after
# an operation fails.
attr_reader :error_message
@@ -59,8 +64,8 @@ class ProjectWiki
# Returns the Gitlab::Git::Wiki object.
def wiki
@wiki ||= begin
- gl_repository = Gitlab::GlRepository.gl_repository(project, true)
- raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository)
+ gl_repository = Gitlab::GlRepository::WIKI.identifier_for_subject(project)
+ raw_repository = Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', gl_repository, full_path)
create_repo!(raw_repository) unless raw_repository.exists?
@@ -77,13 +82,28 @@ class ProjectWiki
end
def empty?
- pages(limit: 1).empty?
+ list_pages(limit: 1).empty?
end
+ # Lists wiki pages of the repository.
+ #
+ # limit - max number of pages returned by the method.
+ # sort - criterion by which the pages are sorted.
+ # direction - order of the sorted pages.
+ # load_content - option, which specifies whether the content inside the page
+ # will be loaded.
+ #
# Returns an Array of GitLab WikiPage instances or an
# empty Array if this Wiki has no pages.
- def pages(limit: 0)
- wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) }
+ def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
+ wiki.list_pages(
+ limit: limit,
+ sort: sort,
+ direction_desc: direction == DIRECTION_DESC,
+ load_content: load_content
+ ).map do |page|
+ WikiPage.new(self, page, true)
+ end
end
# Finds a page within the repository based on a tile
@@ -151,7 +171,7 @@ class ProjectWiki
end
def repository
- @repository ||= Repository.new(full_path, @project, disk_path: disk_path, is_wiki: true)
+ @repository ||= Repository.new(full_path, @project, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
end
def default_branch
@@ -175,7 +195,7 @@ class ProjectWiki
private
def create_repo!(raw_repository)
- gitlab_shell.create_repository(project.repository_storage, disk_path)
+ gitlab_shell.create_wiki_repository(project)
raise CouldNotCreateWikiError unless raw_repository.exists?
@@ -183,7 +203,7 @@ class ProjectWiki
end
def commit_details(action, message = nil, title = nil)
- commit_message = message || default_message(action, title)
+ commit_message = message.presence || default_message(action, title)
git_user = Gitlab::Git::User.from_gitlab(@user)
Gitlab::Git::Wiki::CommitDetails.new(@user.id,
diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb
index ce2db9cb44c..62090444f79 100644
--- a/app/models/prometheus_metric.rb
+++ b/app/models/prometheus_metric.rb
@@ -1,15 +1,16 @@
# frozen_string_literal: true
-class PrometheusMetric < ActiveRecord::Base
+class PrometheusMetric < ApplicationRecord
belongs_to :project, validate: true, inverse_of: :prometheus_metrics
enum group: {
# built-in groups
- nginx_ingress: -1,
+ nginx_ingress_vts: -1,
ha_proxy: -2,
aws_elb: -3,
nginx: -4,
kubernetes: -5,
+ nginx_ingress: -6,
# custom/user groups
business: 0,
@@ -17,6 +18,54 @@ class PrometheusMetric < ActiveRecord::Base
system: 2
}
+ GROUP_DETAILS = {
+ # built-in groups
+ nginx_ingress_vts: {
+ group_title: _('Response metrics (NGINX Ingress VTS)'),
+ required_metrics: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg),
+ priority: 10
+ }.freeze,
+ nginx_ingress: {
+ group_title: _('Response metrics (NGINX Ingress)'),
+ required_metrics: %w(nginx_ingress_controller_requests nginx_ingress_controller_ingress_upstream_latency_seconds_sum),
+ priority: 10
+ }.freeze,
+ ha_proxy: {
+ group_title: _('Response metrics (HA Proxy)'),
+ required_metrics: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total),
+ priority: 10
+ }.freeze,
+ aws_elb: {
+ group_title: _('Response metrics (AWS ELB)'),
+ required_metrics: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum),
+ priority: 10
+ }.freeze,
+ nginx: {
+ group_title: _('Response metrics (NGINX)'),
+ required_metrics: %w(nginx_server_requests nginx_server_requestMsec),
+ priority: 10
+ }.freeze,
+ kubernetes: {
+ group_title: _('System metrics (Kubernetes)'),
+ required_metrics: %w(container_memory_usage_bytes container_cpu_usage_seconds_total),
+ priority: 5
+ }.freeze,
+
+ # custom/user groups
+ business: {
+ group_title: _('Business metrics (Custom)'),
+ priority: 0
+ }.freeze,
+ response: {
+ group_title: _('Response metrics (Custom)'),
+ priority: -5
+ }.freeze,
+ system: {
+ group_title: _('System metrics (Custom)'),
+ priority: -10
+ }.freeze
+ }.freeze
+
validates :title, presence: true
validates :query, presence: true
validates :group, presence: true
@@ -28,34 +77,16 @@ class PrometheusMetric < ActiveRecord::Base
scope :common, -> { where(common: true) }
- GROUP_TITLES = {
- # built-in groups
- nginx_ingress: _('Response metrics (NGINX Ingress)'),
- ha_proxy: _('Response metrics (HA Proxy)'),
- aws_elb: _('Response metrics (AWS ELB)'),
- nginx: _('Response metrics (NGINX)'),
- kubernetes: _('System metrics (Kubernetes)'),
-
- # custom/user groups
- business: _('Business metrics (Custom)'),
- response: _('Response metrics (Custom)'),
- system: _('System metrics (Custom)')
- }.freeze
-
- REQUIRED_METRICS = {
- nginx_ingress: %w(nginx_upstream_responses_total nginx_upstream_response_msecs_avg),
- ha_proxy: %w(haproxy_frontend_http_requests_total haproxy_frontend_http_responses_total),
- aws_elb: %w(aws_elb_request_count_sum aws_elb_latency_average aws_elb_httpcode_backend_5_xx_sum),
- nginx: %w(nginx_server_requests nginx_server_requestMsec),
- kubernetes: %w(container_memory_usage_bytes container_cpu_usage_seconds_total)
- }.freeze
+ def priority
+ group_details(group).fetch(:priority)
+ end
def group_title
- GROUP_TITLES[group.to_sym]
+ group_details(group).fetch(:group_title)
end
def required_metrics
- REQUIRED_METRICS[group.to_sym].to_a.map(&:to_s)
+ group_details(group).fetch(:required_metrics, []).map(&:to_s)
end
def to_query_metric
@@ -86,4 +117,10 @@ class PrometheusMetric < ActiveRecord::Base
}]
end
end
+
+ private
+
+ def group_details(group)
+ GROUP_DETAILS.fetch(group.to_sym)
+ end
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index d075440b147..ee0c94c20af 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedBranch < ActiveRecord::Base
+class ProtectedBranch < ApplicationRecord
include ProtectedRef
protected_ref_access_levels :merge, :push
@@ -18,13 +18,23 @@ class ProtectedBranch < ActiveRecord::Base
def self.protected?(project, ref_name)
return true if project.empty_repo? && default_branch_protected?
- refs = project.protected_branches.select(:name)
+ self.matching(ref_name, protected_refs: protected_refs(project)).present?
+ end
- self.matching(ref_name, protected_refs: refs).present?
+ def self.any_protected?(project, ref_names)
+ protected_refs(project).any? do |protected_ref|
+ ref_names.any? do |ref_name|
+ protected_ref.matches?(ref_name)
+ end
+ end
end
def self.default_branch_protected?
Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_FULL ||
Gitlab::CurrentSettings.default_branch_protection == Gitlab::Access::PROTECTION_DEV_CAN_MERGE
end
+
+ def self.protected_refs(project)
+ project.protected_branches.select(:name)
+ end
end
diff --git a/app/models/protected_branch/merge_access_level.rb b/app/models/protected_branch/merge_access_level.rb
index b0d5c64e931..de240e40316 100644
--- a/app/models/protected_branch/merge_access_level.rb
+++ b/app/models/protected_branch/merge_access_level.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-class ProtectedBranch::MergeAccessLevel < ActiveRecord::Base
+class ProtectedBranch::MergeAccessLevel < ApplicationRecord
include ProtectedBranchAccess
end
diff --git a/app/models/protected_branch/push_access_level.rb b/app/models/protected_branch/push_access_level.rb
index b2a88229853..bde1d29ad7f 100644
--- a/app/models/protected_branch/push_access_level.rb
+++ b/app/models/protected_branch/push_access_level.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
-class ProtectedBranch::PushAccessLevel < ActiveRecord::Base
+class ProtectedBranch::PushAccessLevel < ApplicationRecord
include ProtectedBranchAccess
end
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index d28ebabfe49..6b507429e57 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedTag < ActiveRecord::Base
+class ProtectedTag < ApplicationRecord
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
diff --git a/app/models/protected_tag/create_access_level.rb b/app/models/protected_tag/create_access_level.rb
index b06e55fb5dd..9fcfa7646a2 100644
--- a/app/models/protected_tag/create_access_level.rb
+++ b/app/models/protected_tag/create_access_level.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class ProtectedTag::CreateAccessLevel < ActiveRecord::Base
+class ProtectedTag::CreateAccessLevel < ApplicationRecord
include ProtectedTagAccess
def check_access(user)
diff --git a/app/models/push_event.rb b/app/models/push_event.rb
index 9c0267c3140..4698df39730 100644
--- a/app/models/push_event.rb
+++ b/app/models/push_event.rb
@@ -69,7 +69,7 @@ class PushEvent < Event
PUSHED
end
- def push?
+ def push_action?
true
end
diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb
index c7769edf055..537859ec7b7 100644
--- a/app/models/push_event_payload.rb
+++ b/app/models/push_event_payload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class PushEventPayload < ActiveRecord::Base
+class PushEventPayload < ApplicationRecord
include ShaAttribute
belongs_to :event, inverse_of: :push_event_payload
diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb
index c6bd4bb6dfa..2e4769364c6 100644
--- a/app/models/redirect_route.rb
+++ b/app/models/redirect_route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RedirectRoute < ActiveRecord::Base
+class RedirectRoute < ApplicationRecord
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :source, presence: true
diff --git a/app/models/release.rb b/app/models/release.rb
index 7a09ee459a6..7bbeb3c9976 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -1,12 +1,58 @@
# frozen_string_literal: true
-class Release < ActiveRecord::Base
+class Release < ApplicationRecord
include CacheMarkdownField
+ include Gitlab::Utils::StrongMemoize
cache_markdown_field :description
belongs_to :project
+ # releases prior to 11.7 have no author
belongs_to :author, class_name: 'User'
+ has_many :links, class_name: 'Releases::Link'
+
+ accepts_nested_attributes_for :links, allow_destroy: true
+
validates :description, :project, :tag, presence: true
+ validates :name, presence: true, on: :create
+
+ scope :sorted, -> { order(created_at: :desc) }
+
+ delegate :repository, to: :project
+
+ def commit
+ strong_memoize(:commit) do
+ repository.commit(actual_sha)
+ end
+ end
+
+ def tag_missing?
+ actual_tag.nil?
+ end
+
+ def assets_count(except: [])
+ links_count = links.count
+ sources_count = except.include?(:sources) ? 0 : sources.count
+
+ links_count + sources_count
+ end
+
+ def sources
+ strong_memoize(:sources) do
+ Releases::Source.all(project, tag)
+ end
+ end
+
+ private
+
+ def actual_sha
+ sha || actual_tag&.dereferenced_target
+ end
+
+ def actual_tag
+ strong_memoize(:actual_tag) do
+ repository.find_tag(tag)
+ end
+ end
end
diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb
new file mode 100644
index 00000000000..58c2b98e524
--- /dev/null
+++ b/app/models/releases/link.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+module Releases
+ class Link < ApplicationRecord
+ self.table_name = 'release_links'
+
+ belongs_to :release
+
+ validates :url, presence: true, addressable_url: { schemes: %w(http https ftp) }, uniqueness: { scope: :release }
+ validates :name, presence: true, uniqueness: { scope: :release }
+
+ scope :sorted, -> { order(created_at: :desc) }
+
+ def internal?
+ url.start_with?(release.project.web_url)
+ end
+
+ def external?
+ !internal?
+ end
+ end
+end
diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb
new file mode 100644
index 00000000000..4d3d54457af
--- /dev/null
+++ b/app/models/releases/source.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Releases
+ class Source
+ include ActiveModel::Model
+
+ attr_accessor :project, :tag_name, :format
+
+ FORMATS = %w(zip tar.gz tar.bz2 tar).freeze
+
+ class << self
+ def all(project, tag_name)
+ Releases::Source::FORMATS.map do |format|
+ Releases::Source.new(project: project,
+ tag_name: tag_name,
+ format: format)
+ end
+ end
+ end
+
+ def url
+ Gitlab::Routing
+ .url_helpers
+ .project_archive_url(project,
+ id: File.join(tag_name, archive_prefix),
+ format: format)
+ end
+
+ private
+
+ def archive_prefix
+ "#{project.path}-#{tag_name.tr('/', '-')}"
+ end
+ end
+end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 5a6895aefab..af705b29f7a 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RemoteMirror < ActiveRecord::Base
+class RemoteMirror < ApplicationRecord
include AfterCommitQueue
include MirrorAuthentication
@@ -17,13 +17,13 @@ class RemoteMirror < ActiveRecord::Base
belongs_to :project, inverse_of: :remote_mirrors
- validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true, enforce_user: true }
+ validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true }
before_save :set_new_remote_name, if: :mirror_url_changed?
after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available }
- after_save :refresh_remote, if: :mirror_url_changed?
- after_update :reset_fields, if: :mirror_url_changed?
+ after_save :refresh_remote, if: :saved_change_to_mirror_url?
+ after_update :reset_fields, if: :saved_change_to_mirror_url?
after_commit :remove_remote, on: :destroy
@@ -61,7 +61,10 @@ class RemoteMirror < ActiveRecord::Base
timestamp = Time.now
remote_mirror.update!(
- last_update_at: timestamp, last_successful_update_at: timestamp, last_error: nil
+ last_update_at: timestamp,
+ last_successful_update_at: timestamp,
+ last_error: nil,
+ error_notification_sent: false
)
end
@@ -130,6 +133,10 @@ class RemoteMirror < ActiveRecord::Base
end
alias_method :enabled?, :enabled
+ def disabled?
+ !enabled?
+ end
+
def updated_since?(timestamp)
last_update_started_at && last_update_started_at > timestamp && !update_failed?
end
@@ -179,6 +186,10 @@ class RemoteMirror < ActiveRecord::Base
project.repository.add_remote(remote_name, remote_url)
end
+ def after_sent_notification
+ update_column(:error_notification_sent, true)
+ end
+
private
def store_credentials
@@ -221,7 +232,8 @@ class RemoteMirror < ActiveRecord::Base
last_error: nil,
last_update_at: nil,
last_successful_update_at: nil,
- update_status: 'finished'
+ update_status: 'finished',
+ error_notification_sent: false
)
end
@@ -240,7 +252,7 @@ class RemoteMirror < ActiveRecord::Base
# Before adding a new remote we have to delete the data from
# the previous remote name
- prev_remote_name = remote_name_was || fallback_remote_name
+ prev_remote_name = remote_name_before_last_save || fallback_remote_name
run_after_commit do
project.repository.async_remove_remote(prev_remote_name)
end
@@ -257,4 +269,8 @@ class RemoteMirror < ActiveRecord::Base
def mirror_url_changed?
url_changed? || credentials_changed?
end
+
+ def saved_change_to_mirror_url?
+ saved_change_to_url? || saved_change_to_credentials?
+ end
end
diff --git a/app/models/repository.rb b/app/models/repository.rb
index 015a179f374..d43f991bb3e 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -19,12 +19,13 @@ class Repository
include Gitlab::RepositoryCacheAdapter
- attr_accessor :full_path, :disk_path, :project, :is_wiki
+ attr_accessor :full_path, :disk_path, :project, :repo_type
delegate :ref_name_for_sha, to: :raw_repository
delegate :bundle_to_disk, to: :raw_repository
CreateTreeError = Class.new(StandardError)
+ AmbiguousRefError = Class.new(StandardError)
# Methods that cache data from the Git repository.
#
@@ -38,7 +39,8 @@ class Repository
changelog license_blob license_key gitignore
gitlab_ci_yml branch_names tag_names branch_count
tag_count avatar exists? root_ref has_visible_content?
- issue_template_names merge_request_template_names xcode_project?).freeze
+ issue_template_names merge_request_template_names
+ metrics_dashboard_paths xcode_project?).freeze
# Methods that use cache_method but only memoize the value
MEMOIZED_CACHED_METHODS = %i(license).freeze
@@ -56,15 +58,16 @@ class Repository
avatar: :avatar,
issue_template: :issue_template_names,
merge_request_template: :merge_request_template_names,
+ metrics_dashboard: :metrics_dashboard_paths,
xcode_config: :xcode_project?
}.freeze
- def initialize(full_path, project, disk_path: nil, is_wiki: false)
+ def initialize(full_path, project, disk_path: nil, repo_type: Gitlab::GlRepository::PROJECT)
@full_path = full_path
@disk_path = disk_path || full_path
@project = project
@commit_cache = {}
- @is_wiki = is_wiki
+ @repo_type = repo_type
end
def ==(other)
@@ -78,7 +81,7 @@ class Repository
end
def raw_repository
- return nil unless full_path
+ return unless full_path
@raw_repository ||= initialize_raw_repository
end
@@ -102,7 +105,7 @@ class Repository
end
def commit(ref = nil)
- return nil unless exists?
+ return unless exists?
return ref if ref.is_a?(::Commit)
find_commit(ref || root_ref)
@@ -181,6 +184,18 @@ class Repository
tags.find { |tag| tag.name == name }
end
+ def ambiguous_ref?(ref)
+ tag_exists?(ref) && branch_exists?(ref)
+ end
+
+ def expand_ref(ref)
+ if tag_exists?(ref)
+ Gitlab::Git::TAG_REF_PREFIX + ref
+ elsif branch_exists?(ref)
+ Gitlab::Git::BRANCH_REF_PREFIX + ref
+ end
+ end
+
def add_branch(user, branch_name, ref)
branch = raw_repository.add_branch(branch_name, user: user, target: ref)
@@ -252,16 +267,14 @@ class Repository
# to avoid unnecessary syncing.
def keep_around(*shas)
shas.each do |sha|
- begin
- next unless sha.present? && commit_by(oid: sha)
+ next unless sha.present? && commit_by(oid: sha)
- next if kept_around?(sha)
+ next if kept_around?(sha)
- # This will still fail if the file is corrupted (e.g. 0 bytes)
- raw_repository.write_ref(keep_around_ref_name(sha), sha)
- rescue Gitlab::Git::CommandError => ex
- Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
- end
+ # This will still fail if the file is corrupted (e.g. 0 bytes)
+ raw_repository.write_ref(keep_around_ref_name(sha), sha)
+ rescue Gitlab::Git::CommandError => ex
+ Rails.logger.error "Unable to create keep-around reference for repository #{disk_path}: #{ex}"
end
end
@@ -270,28 +283,53 @@ class Repository
end
def diverging_commit_counts(branch)
+ return diverging_commit_counts_without_max(branch) if Feature.enabled?('gitaly_count_diverging_commits_no_max')
+
+ ## TODO: deprecate the below code after 12.0
@root_ref_hash ||= raw_repository.commit(root_ref).id
cache.fetch(:"diverging_commit_counts_#{branch.name}") do
# Rugged seems to throw a `ReferenceError` when given branch_names rather
# than SHA-1 hashes
+ branch_sha = branch.dereferenced_target.sha
+
number_commits_behind, number_commits_ahead =
- raw_repository.count_commits_between(
+ raw_repository.diverging_commit_count(
@root_ref_hash,
- branch.dereferenced_target.sha,
- left_right: true,
+ branch_sha,
max_count: MAX_DIVERGING_COUNT)
+ if number_commits_behind + number_commits_ahead >= MAX_DIVERGING_COUNT
+ { distance: MAX_DIVERGING_COUNT }
+ else
+ { behind: number_commits_behind, ahead: number_commits_ahead }
+ end
+ end
+ end
+
+ def diverging_commit_counts_without_max(branch)
+ @root_ref_hash ||= raw_repository.commit(root_ref).id
+ cache.fetch(:"diverging_commit_counts_without_max_#{branch.name}") do
+ # Rugged seems to throw a `ReferenceError` when given branch_names rather
+ # than SHA-1 hashes
+ branch_sha = branch.dereferenced_target.sha
+
+ number_commits_behind, number_commits_ahead =
+ raw_repository.diverging_commit_count(
+ @root_ref_hash,
+ branch_sha)
+
{ behind: number_commits_behind, ahead: number_commits_ahead }
end
end
- def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:)
+ def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil)
raw_repository.archive_metadata(
ref,
storage_path,
project.path,
format,
- append_sha: append_sha
+ append_sha: append_sha,
+ path: path
)
end
@@ -448,7 +486,7 @@ class Repository
def after_import
expire_content_cache
- DetectRepositoryLanguagesWorker.perform_async(project.id, project.owner.id)
+ DetectRepositoryLanguagesWorker.perform_async(project.id)
end
# Runs code after a new commit has been pushed.
@@ -512,14 +550,15 @@ class Repository
# items is an Array like: [[oid, path], [oid1, path1]]
def blobs_at(items)
+ return [] unless exists?
+
raw_repository.batch_blobs(items).map { |blob| Blob.decorate(blob, project) }
end
def root_ref
- # When the repo does not exist, or there is no root ref, we raise this error so no data is cached.
- raw_repository&.root_ref or raise Gitlab::Git::Repository::NoRepository # rubocop:disable Style/AndOr
+ raw_repository&.root_ref
end
- cache_method :root_ref
+ cache_method_asymmetrically :root_ref
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/314
def exists?
@@ -586,6 +625,11 @@ class Repository
end
cache_method :merge_request_template_names, fallback: []
+ def metrics_dashboard_paths
+ Gitlab::Metrics::Dashboard::Finder.find_all_paths_from_source(project)
+ end
+ cache_method :metrics_dashboard_paths
+
def readme
head_tree&.readme
end
@@ -599,7 +643,6 @@ class Repository
return unless readme
context = { project: project }
- context[:markdown_engine] = :redcarpet unless MarkupHelper.commonmark_for_repositories_enabled?
MarkupHelper.markup_unsafe(readme.name, readme.data, context)
end
@@ -837,6 +880,12 @@ class Repository
end
end
+ def merge_to_ref(user, source_sha, merge_request, target_ref, message)
+ branch = merge_request.target_branch
+
+ raw.merge_to_ref(user, source_sha, branch, target_ref, message)
+ end
+
def ff_merge(user, source, target_branch, merge_request: nil)
their_commit_id = commit(source)&.id
raise 'Invalid merge source' if their_commit_id.nil?
@@ -1009,19 +1058,49 @@ class Repository
raw_repository.fetch_ref(source_repository.raw_repository, source_ref: source_ref, target_ref: target_ref)
end
+ # DEPRECATED: https://gitlab.com/gitlab-org/gitaly/issues/1628
+ def rebase_deprecated(user, merge_request)
+ rebase_sha = raw.rebase_deprecated(
+ user,
+ merge_request.id,
+ branch: merge_request.source_branch,
+ branch_sha: merge_request.source_branch_sha,
+ remote_repository: merge_request.target_project.repository.raw,
+ remote_branch: merge_request.target_branch
+ )
+
+ # To support the full deprecated behaviour, set the
+ # `rebase_commit_sha` for the merge_request here and return the value
+ merge_request.update(rebase_commit_sha: rebase_sha, merge_error: nil)
+
+ rebase_sha
+ end
+
def rebase(user, merge_request)
- raw.rebase(user, merge_request.id, branch: merge_request.source_branch,
- branch_sha: merge_request.source_branch_sha,
- remote_repository: merge_request.target_project.repository.raw,
- remote_branch: merge_request.target_branch)
+ if Feature.disabled?(:two_step_rebase, default_enabled: false)
+ return rebase_deprecated(user, merge_request)
+ end
+
+ MergeRequest.transaction do
+ raw.rebase(
+ user,
+ merge_request.id,
+ branch: merge_request.source_branch,
+ branch_sha: merge_request.source_branch_sha,
+ remote_repository: merge_request.target_project.repository.raw,
+ remote_branch: merge_request.target_branch
+ ) do |commit_id|
+ merge_request.update!(rebase_commit_sha: commit_id, merge_error: nil)
+ end
+ end
end
- def squash(user, merge_request)
+ def squash(user, merge_request, message)
raw.squash(user, merge_request.id, branch: merge_request.target_branch,
start_sha: merge_request.diff_start_sha,
end_sha: merge_request.diff_head_sha,
author: merge_request.author,
- message: merge_request.title)
+ message: message)
end
def update_submodule(user, submodule, commit_sha, message:, branch:)
@@ -1044,6 +1123,19 @@ class Repository
blob.data
end
+ def create_if_not_exists
+ return if exists?
+
+ raw.create_repository
+ after_create
+ end
+
+ def blobs_metadata(paths, ref = 'HEAD')
+ references = Array.wrap(paths).map { |path| [ref, path] }
+
+ Gitlab::Git::Blob.batch_metadata(raw, references).map { |raw_blob| Blob.decorate(raw_blob) }
+ end
+
private
# TODO Generice finder, later split this on finders by Ref or Oid
@@ -1059,19 +1151,11 @@ class Repository
end
def cache
- @cache ||= if is_wiki
- Gitlab::RepositoryCache.new(self, extra_namespace: 'wiki')
- else
- Gitlab::RepositoryCache.new(self)
- end
+ @cache ||= Gitlab::RepositoryCache.new(self)
end
def request_store_cache
- @request_store_cache ||= if is_wiki
- Gitlab::RepositoryCache.new(self, extra_namespace: 'wiki', backend: Gitlab::SafeRequestStore)
- else
- Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore)
- end
+ @request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore)
end
def tags_sorted_by_committed_date
@@ -1098,6 +1182,9 @@ class Repository
end
def initialize_raw_repository
- Gitlab::Git::Repository.new(project.repository_storage, disk_path + '.git', Gitlab::GlRepository.gl_repository(project, is_wiki))
+ Gitlab::Git::Repository.new(project.repository_storage,
+ disk_path + '.git',
+ repo_type.identifier_for_subject(project),
+ project.full_path)
end
end
diff --git a/app/models/repository_language.rb b/app/models/repository_language.rb
index b18142a2ac4..e6867f905e2 100644
--- a/app/models/repository_language.rb
+++ b/app/models/repository_language.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class RepositoryLanguage < ActiveRecord::Base
+class RepositoryLanguage < ApplicationRecord
belongs_to :project
belongs_to :programming_language
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index 3fd96b9dc18..f2c7cb6a65d 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -2,7 +2,7 @@
# This model is not used yet, it will be used for:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/48483
-class ResourceLabelEvent < ActiveRecord::Base
+class ResourceLabelEvent < ApplicationRecord
include Importable
include Gitlab::Utils::StrongMemoize
include CacheMarkdownField
diff --git a/app/models/route.rb b/app/models/route.rb
index 4b23dfa5778..91ea2966013 100644
--- a/app/models/route.rb
+++ b/app/models/route.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Route < ActiveRecord::Base
+class Route < ApplicationRecord
include CaseSensitivity
belongs_to :source, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
@@ -14,26 +14,26 @@ class Route < ActiveRecord::Base
before_validation :delete_conflicting_orphaned_routes
after_create :delete_conflicting_redirects
- after_update :delete_conflicting_redirects, if: :path_changed?
+ after_update :delete_conflicting_redirects, if: :saved_change_to_path?
after_update :create_redirect_for_old_path
after_update :rename_descendants
scope :inside_path, -> (path) { where('routes.path LIKE ?', "#{sanitize_sql_like(path)}/%") }
def rename_descendants
- return unless path_changed? || name_changed?
+ return unless saved_change_to_path? || saved_change_to_name?
- descendant_routes = self.class.inside_path(path_was)
+ descendant_routes = self.class.inside_path(path_before_last_save)
descendant_routes.each do |route|
attributes = {}
- if path_changed? && route.path.present?
- attributes[:path] = route.path.sub(path_was, path)
+ if saved_change_to_path? && route.path.present?
+ attributes[:path] = route.path.sub(path_before_last_save, path)
end
- if name_changed? && name_was.present? && route.name.present?
- attributes[:name] = route.name.sub(name_was, name)
+ if saved_change_to_name? && name_before_last_save.present? && route.name.present?
+ attributes[:name] = route.name.sub(name_before_last_save, name)
end
if attributes.present?
@@ -65,7 +65,7 @@ class Route < ActiveRecord::Base
private
def create_redirect_for_old_path
- create_redirect(path_was) if path_changed?
+ create_redirect(path_before_last_save) if saved_change_to_path?
end
def delete_conflicting_orphaned_routes
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index e65b3df0fb6..0427d5b9ca7 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SentNotification < ActiveRecord::Base
+class SentNotification < ApplicationRecord
serialize :position, Gitlab::Diff::Position # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :project
@@ -48,7 +48,7 @@ class SentNotification < ActiveRecord::Base
end
def record_note(note, recipient_id, reply_key = self.reply_key, attrs = {})
- attrs[:in_reply_to_discussion_id] = note.discussion_id
+ attrs[:in_reply_to_discussion_id] = note.discussion_id if note.part_of_discussion?
record(note.noteable, recipient_id, reply_key, attrs)
end
@@ -99,29 +99,12 @@ class SentNotification < ActiveRecord::Base
private
def reply_params
- attrs = {
+ {
noteable_type: self.noteable_type,
noteable_id: self.noteable_id,
- commit_id: self.commit_id
+ commit_id: self.commit_id,
+ in_reply_to_discussion_id: self.in_reply_to_discussion_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
diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb
new file mode 100644
index 00000000000..5d4f8e0c9e2
--- /dev/null
+++ b/app/models/serverless/function.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Serverless
+ class Function
+ attr_accessor :name, :namespace
+
+ def initialize(project, name, namespace)
+ @project = project
+ @name = name
+ @namespace = namespace
+ end
+
+ def id
+ @project.id.to_s + "/" + @name + "/" + @namespace
+ end
+
+ def self.find_by_id(id)
+ array = id.split("/")
+ project = Project.find_by_id(array[0])
+ name = array[1]
+ namespace = array[2]
+
+ self.new(project, name, namespace)
+ end
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 5b8bf6e7cf0..9896aa12e90 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -2,7 +2,7 @@
# To add new service you should build a class inherited from Service
# and implement a set of methods
-class Service < ActiveRecord::Base
+class Service < ApplicationRecord
include Sortable
include Importable
include ProjectServicesLoggable
@@ -50,6 +50,7 @@ class Service < ActiveRecord::Base
scope :job_hooks, -> { where(job_events: true, active: true) }
scope :pipeline_hooks, -> { where(pipeline_events: true, active: true) }
scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) }
+ scope :deployment_hooks, -> { where(deployment_events: true, active: true) }
scope :external_issue_trackers, -> { issue_trackers.active.without_defaults }
scope :deployment, -> { where(category: 'deployment') }
@@ -210,11 +211,7 @@ class Service < ActiveRecord::Base
class_eval %{
def #{arg}?
# '!!' is used because nil or empty string is converted to nil
- if Gitlab.rails5?
- !!ActiveRecord::Type::Boolean.new.cast(#{arg})
- else
- !!ActiveRecord::Type::Boolean.new.type_cast_from_database(#{arg})
- end
+ !!ActiveRecord::Type::Boolean.new.cast(#{arg})
end
}
end
@@ -271,6 +268,7 @@ class Service < ActiveRecord::Base
prometheus
pushover
redmine
+ youtrack
slack_slash_commands
slack
teamcity
@@ -338,6 +336,8 @@ class Service < ActiveRecord::Base
"Event will be triggered when a wiki page is created/updated"
when "commit", "commit_events"
"Event will be triggered when a commit is created/updated"
+ when "deployment"
+ "Event will be triggered when a deployment finishes"
end
end
diff --git a/app/models/shard.rb b/app/models/shard.rb
index e39d4232486..335a279c6aa 100644
--- a/app/models/shard.rb
+++ b/app/models/shard.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Shard < ActiveRecord::Base
+class Shard < ApplicationRecord
# Store shard names from the configuration file in the database. This is not a
# list of active shards - we just want to assign an immutable, unique ID to
# every shard name for easy indexing / referencing.
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index 11856b55902..f4fdac2558c 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Snippet < ActiveRecord::Base
+class Snippet < ApplicationRecord
include Gitlab::VisibilityLevel
include Redactable
include CacheMarkdownField
@@ -50,11 +50,11 @@ class Snippet < ActiveRecord::Base
validates :visibility_level, inclusion: { in: Gitlab::VisibilityLevel.values }
# Scopes
- scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
+ scope :are_internal, -> { where(visibility_level: Snippet::INTERNAL) }
scope :are_private, -> { where(visibility_level: Snippet::PRIVATE) }
scope :are_public, -> { where(visibility_level: Snippet::PUBLIC) }
scope :public_and_internal, -> { where(visibility_level: [Snippet::PUBLIC, Snippet::INTERNAL]) }
- scope :fresh, -> { order("created_at DESC") }
+ scope :fresh, -> { order("created_at DESC") }
scope :inc_relations_for_view, -> { includes(author: :status) }
participant :author
@@ -175,6 +175,12 @@ class Snippet < ActiveRecord::Base
:visibility_level
end
+ def embeddable?
+ ability = project_id? ? :read_project_snippet : :read_personal_snippet
+
+ Ability.allowed?(nil, ability, self)
+ end
+
def notes_with_associations
notes.includes(:author)
end
diff --git a/app/models/spam_log.rb b/app/models/spam_log.rb
index ef3f974b959..5b9ece8373f 100644
--- a/app/models/spam_log.rb
+++ b/app/models/spam_log.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SpamLog < ActiveRecord::Base
+class SpamLog < ApplicationRecord
belongs_to :user
validates :user, presence: true
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index b6844dbe870..b6fb39ee81f 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -26,7 +26,8 @@ class SshHostKey
self.reactive_cache_lifetime = 10.minutes
def self.find_by(opts = {})
- return nil unless opts.key?(:id)
+ opts = HashWithIndifferentAccess.new(opts)
+ return unless opts.key?(:id)
project_id, url = opts[:id].split(':', 2)
project = Project.find_by(id: project_id)
@@ -52,6 +53,11 @@ class SshHostKey
@compare_host_keys = compare_host_keys
end
+ # Needed for reactive caching
+ def self.primary_key
+ :id
+ end
+
def id
[project.id, url].join(':')
end
diff --git a/app/models/storage/hashed_project.rb b/app/models/storage/hashed_project.rb
index 911fb7e9ce9..f5d0d6fab3b 100644
--- a/app/models/storage/hashed_project.rb
+++ b/app/models/storage/hashed_project.rb
@@ -31,7 +31,7 @@ module Storage
gitlab_shell.add_namespace(repository_storage, base_dir)
end
- def rename_repo
+ def rename_repo(old_full_path: nil, new_full_path: nil)
true
end
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 9f6f19acb41..b483c677be9 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -29,18 +29,19 @@ module Storage
gitlab_shell.add_namespace(repository_storage, base_dir)
end
- def rename_repo
- new_full_path = project.build_full_path
+ def rename_repo(old_full_path: nil, new_full_path: nil)
+ old_full_path ||= project.full_path_before_last_save
+ new_full_path ||= project.build_full_path
- if gitlab_shell.mv_repository(repository_storage, project.full_path_was, new_full_path)
+ if gitlab_shell.mv_repository(repository_storage, old_full_path, new_full_path)
# If repository moved successfully we need to send update instructions to users.
# However we cannot allow rollback since we moved repository
# So we basically we mute exceptions in next actions
begin
- gitlab_shell.mv_repository(repository_storage, "#{project.full_path_was}.wiki", "#{new_full_path}.wiki")
+ gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki")
return true
rescue => e
- Rails.logger.error "Exception renaming #{project.full_path_was} -> #{new_full_path}: #{e}"
+ Rails.logger.error "Exception renaming #{old_full_path} -> #{new_full_path}: #{e}"
# Returning false does not rollback after_* transaction but gives
# us information about failing some of tasks
return false
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index 0f6ee0ddf7e..24a2b8b5167 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Subscription < ActiveRecord::Base
+class Subscription < ApplicationRecord
belongs_to :user
belongs_to :project
belongs_to :subscribable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
index cec5ea30f9d..22e2f11230d 100644
--- a/app/models/suggestion.rb
+++ b/app/models/suggestion.rb
@@ -1,17 +1,18 @@
# frozen_string_literal: true
class Suggestion < ApplicationRecord
- FEATURE_FLAG = :diff_suggestions
+ include Suggestible
belongs_to :note, inverse_of: :suggestions
validates :note, presence: true
validates :commit_id, presence: true, if: :applied?
- delegate :original_position, :position, :diff_file,
- :noteable, to: :note
+ delegate :position, :noteable, to: :note
- def self.feature_enabled?
- Feature.enabled?(FEATURE_FLAG)
+ scope :active, -> { where(outdated: false) }
+
+ def diff_file
+ note.latest_diff_file
end
def project
@@ -22,37 +23,41 @@ class Suggestion < ApplicationRecord
noteable.source_branch
end
- # For now, suggestions only serve as a way to send patches that
- # will change a single line (being able to apply multiple in the same place),
- # which explains `from_line` and `to_line` being the same line.
- # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
- # when allowing multi-line suggestions.
- def from_line
- position.new_line
- end
- alias_method :to_line, :from_line
-
- def from_original_line
- original_position.new_line
+ def file_path
+ position.file_path
end
- alias_method :to_original_line, :from_original_line
# `from_line_index` and `to_line_index` represents diff/blob line numbers in
# index-like way (N-1).
def from_line_index
from_line - 1
end
- alias_method :to_line_index, :from_line_index
- def appliable?
- return false unless note.supports_suggestion?
+ def to_line_index
+ to_line - 1
+ end
+ def appliable?(cached: true)
!applied? &&
noteable.opened? &&
+ !outdated?(cached: cached) &&
+ note.supports_suggestion? &&
different_content? &&
note.active?
end
+ # Overwrites outdated column
+ def outdated?(cached: true)
+ return super() if cached
+ return true unless diff_file
+
+ from_content != fetch_from_content
+ end
+
+ def target_line
+ position.new_line
+ end
+
private
def different_content?
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index d555ebe5322..55da37c9545 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class SystemNoteMetadata < ActiveRecord::Base
+class SystemNoteMetadata < ApplicationRecord
# These notes's action text might contain a reference that is external.
# We should always force a deep validation upon references that are found
# in this note type.
diff --git a/app/models/term_agreement.rb b/app/models/term_agreement.rb
index 9b3c8ac68bd..a4a9dc10282 100644
--- a/app/models/term_agreement.rb
+++ b/app/models/term_agreement.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class TermAgreement < ActiveRecord::Base
+class TermAgreement < ApplicationRecord
belongs_to :term, class_name: 'ApplicationSetting::Term'
belongs_to :user
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index e04c644a53a..048134fbf04 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Timelog < ActiveRecord::Base
+class Timelog < ApplicationRecord
validates :time_spent, :user, presence: true
validate :issuable_id_is_present
diff --git a/app/models/todo.rb b/app/models/todo.rb
index 7b64615f699..5dcc3e9945a 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -1,9 +1,14 @@
# frozen_string_literal: true
-class Todo < ActiveRecord::Base
+class Todo < ApplicationRecord
include Sortable
include FromUnion
+ # Time to wait for todos being removed when not visible for user anymore.
+ # Prevents TODOs being removed by mistake, for example, removing access from a user
+ # and giving it back again.
+ WAIT_FOR_DELETE = 1.hour
+
ASSIGNED = 1
MENTIONED = 2
BUILD_FAILED = 3
@@ -26,7 +31,13 @@ class Todo < ActiveRecord::Base
belongs_to :note
belongs_to :project
belongs_to :group
- belongs_to :target, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
+ belongs_to :target, -> {
+ if self.klass.respond_to?(:with_api_entity_associations)
+ self.with_api_entity_associations
+ else
+ self
+ end
+ }, polymorphic: true, touch: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :user
delegate :name, :email, to: :author, prefix: true, allow_nil: true
@@ -47,6 +58,7 @@ class Todo < ActiveRecord::Base
scope :for_type, -> (type) { where(target_type: type) }
scope :for_target, -> (id) { where(target_id: id) }
scope :for_commit, -> (id) { where(commit_id: id) }
+ scope :with_api_entity_associations, -> { preload(:target, :author, :note, group: :route, project: [:route, { namespace: :route }]) }
state_machine :state, initial: :pending do
event :done do
diff --git a/app/models/trending_project.rb b/app/models/trending_project.rb
index 7b22e8cb760..810dee672b2 100644
--- a/app/models/trending_project.rb
+++ b/app/models/trending_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class TrendingProject < ActiveRecord::Base
+class TrendingProject < ApplicationRecord
belongs_to :project
# The number of months to include in the trending calculation.
diff --git a/app/models/u2f_registration.rb b/app/models/u2f_registration.rb
index 37598173fd1..81415eb383b 100644
--- a/app/models/u2f_registration.rb
+++ b/app/models/u2f_registration.rb
@@ -2,7 +2,7 @@
# Registration information for U2F (universal 2nd factor) devices, like Yubikeys
-class U2fRegistration < ActiveRecord::Base
+class U2fRegistration < ApplicationRecord
belongs_to :user
def self.register(user, app_id, params, challenges)
@@ -19,7 +19,7 @@ class U2fRegistration < ActiveRecord::Base
user: user,
name: params[:name])
rescue JSON::ParserError, NoMethodError, ArgumentError
- registration.errors.add(:base, 'Your U2F device did not send a valid JSON response.')
+ registration.errors.add(:base, _('Your U2F device did not send a valid JSON response.'))
rescue U2F::Error => e
registration.errors.add(:base, e.message)
end
diff --git a/app/models/upload.rb b/app/models/upload.rb
index 20860f14b83..ca74f16b3b8 100644
--- a/app/models/upload.rb
+++ b/app/models/upload.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class Upload < ActiveRecord::Base
+class Upload < ApplicationRecord
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
@@ -45,7 +45,7 @@ class Upload < ActiveRecord::Base
end
def absolute_path
- raise ObjectStorage::RemoteStoreError, "Remote object has no absolute path." unless local?
+ raise ObjectStorage::RemoteStoreError, _("Remote object has no absolute path.") unless local?
return path unless relative_path?
uploader_class.absolute_path(self)
@@ -71,10 +71,10 @@ class Upload < ActiveRecord::Base
# Help sysadmins find missing upload files
if persisted? && !exist
if Gitlab::Sentry.enabled?
- Raven.capture_message("Upload file does not exist", extra: self.attributes)
+ Raven.capture_message(_("Upload file does not exist"), extra: self.attributes)
end
- Gitlab::Metrics.counter(:upload_file_does_not_exist_total, 'The number of times an upload record could not find its file').increment
+ Gitlab::Metrics.counter(:upload_file_does_not_exist_total, _('The number of times an upload record could not find its file')).increment
end
exist
diff --git a/app/models/user.rb b/app/models/user.rb
index f20756d1cc3..2eb5c63a4cc 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -2,7 +2,7 @@
require 'carrierwave/orm/activerecord'
-class User < ActiveRecord::Base
+class User < ApplicationRecord
extend Gitlab::ConfigHelper
include Gitlab::ConfigHelper
@@ -105,6 +105,7 @@ class User < ActiveRecord::Base
has_many :groups, through: :group_members
has_many :owned_groups, -> { where(members: { access_level: Gitlab::Access::OWNER }) }, through: :group_members, source: :group
has_many :maintainers_groups, -> { where(members: { access_level: Gitlab::Access::MAINTAINER }) }, through: :group_members, source: :group
+ has_many :developer_groups, -> { where(members: { access_level: ::Gitlab::Access::DEVELOPER }) }, through: :group_members, source: :group
has_many :owned_or_maintainers_groups,
-> { where(members: { access_level: [Gitlab::Access::MAINTAINER, Gitlab::Access::OWNER] }) },
through: :group_members,
@@ -145,7 +146,7 @@ class User < ActiveRecord::Base
has_many :issue_assignees
has_many :assigned_issues, class_name: "Issue", through: :issue_assignees, source: :issue
- has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
+ has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" # rubocop:disable Cop/ActiveRecordDependent
has_many :custom_attributes, class_name: 'UserCustomAttribute'
has_many :callouts, class_name: 'UserCallout'
@@ -159,12 +160,12 @@ class User < ActiveRecord::Base
# Validations
#
# Note: devise :validatable above adds validations for :email and :password
- validates :name, presence: true
+ validates :name, presence: true, length: { maximum: 128 }
validates :email, confirmation: true
validates :notification_email, presence: true
- validates :notification_email, email: true, if: ->(user) { user.notification_email != user.email }
- validates :public_email, presence: true, uniqueness: true, email: true, allow_blank: true
- validates :commit_email, email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
+ validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email }
+ validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true
+ validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email }
validates :bio, length: { maximum: 255 }, allow_blank: true
validates :projects_limit,
presence: true,
@@ -193,7 +194,7 @@ class User < ActiveRecord::Base
before_validation :ensure_namespace_correct
before_save :ensure_namespace_correct # in case validation is skipped
after_validation :set_username_errors
- after_update :username_changed_hook, if: :username_changed?
+ after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
after_commit(on: :update) do
@@ -228,6 +229,12 @@ class User < ActiveRecord::Base
delegate :path, to: :namespace, allow_nil: true, prefix: true
delegate :notes_filter_for, to: :user_preference
delegate :set_notes_filter, to: :user_preference
+ delegate :first_day_of_week, :first_day_of_week=, to: :user_preference
+ delegate :timezone, :timezone=, to: :user_preference
+ delegate :time_display_relative, :time_display_relative=, to: :user_preference
+ delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference
+
+ accepts_nested_attributes_for :user_preference, update_only: true
state_machine :state, initial: :active do
event :block do
@@ -267,9 +274,13 @@ class User < ActiveRecord::Base
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
+ scope :with_emails, -> { preload(:emails) }
+ scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
# Limits the users to those that have TODOs, optionally in the given state.
#
@@ -337,6 +348,8 @@ class User < ActiveRecord::Base
case order_method.to_s
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
+ when 'last_activity_on_desc' then order_recent_last_activity
+ when 'last_activity_on_asc' then order_oldest_last_activity
else
order_by(order_method)
end
@@ -380,7 +393,7 @@ class User < ActiveRecord::Base
find_by(id: user_id)
end
- def filter(filter_name)
+ def filter_items(filter_name)
case filter_name
when 'admins'
admins
@@ -424,7 +437,7 @@ class User < ActiveRecord::Base
fuzzy_arel_match(:name, query, lower_exact_match: true)
.or(fuzzy_arel_match(:username, query, lower_exact_match: true))
.or(arel_table[:email].eq(query))
- ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
+ ).reorder(order % { query: ApplicationRecord.connection.quote(query) }, :name)
end
# Limits the result set to users _not_ in the given query/list of IDs.
@@ -462,7 +475,7 @@ class User < ActiveRecord::Base
end
def by_login(login)
- return nil unless login
+ return unless login
if login.include?('@'.freeze)
unscoped.iwhere(email: login).take
@@ -507,7 +520,7 @@ class User < ActiveRecord::Base
def ghost
email = 'ghost%s@example.com'
unique_internal(where(ghost: true), 'ghost', email) do |u|
- u.bio = 'This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.'
+ u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.')
u.name = 'Ghost User'
end
end
@@ -527,20 +540,16 @@ class User < ActiveRecord::Base
username
end
- def self.internal_attributes
- [:ghost]
- end
-
def internal?
- self.class.internal_attributes.any? { |a| self[a] }
+ ghost?
end
def self.internal
- where(Hash[internal_attributes.zip([true] * internal_attributes.size)])
+ where(ghost: true)
end
def self.non_internal
- where(internal_attributes.map { |attr| "#{attr} IS NOT TRUE" }.join(" AND "))
+ where('ghost IS NOT TRUE')
end
#
@@ -616,32 +625,32 @@ class User < ActiveRecord::Base
def namespace_move_dir_allowed
if namespace&.any_project_has_container_registry_tags?
- errors.add(:username, 'cannot be changed if a personal project has container registry tags.')
+ errors.add(:username, _('cannot be changed if a personal project has container registry tags.'))
end
end
def unique_email
if !emails.exists?(email: email) && Email.exists?(email: email)
- errors.add(:email, 'has already been taken')
+ errors.add(:email, _('has already been taken'))
end
end
def owns_notification_email
return if temp_oauth_email?
- errors.add(:notification_email, "is not an email you own") unless all_emails.include?(notification_email)
+ errors.add(:notification_email, _("is not an email you own")) unless all_emails.include?(notification_email)
end
def owns_public_email
return if public_email.blank?
- errors.add(:public_email, "is not an email you own") unless all_emails.include?(public_email)
+ errors.add(:public_email, _("is not an email you own")) unless all_emails.include?(public_email)
end
def owns_commit_email
return if read_attribute(:commit_email).blank?
- errors.add(:commit_email, "is not an email you own") unless verified_emails.include?(commit_email)
+ errors.add(:commit_email, _("is not an email you own")) unless verified_emails.include?(commit_email)
end
# Define commit_email-related attribute methods explicitly instead of relying
@@ -709,13 +718,13 @@ class User < ActiveRecord::Base
# Returns the groups a user is a member of, either directly or through a parent group
def membership_groups
- Gitlab::GroupHierarchy.new(groups).base_and_descendants
+ Gitlab::ObjectHierarchy.new(groups).base_and_descendants
end
# Returns a relation of groups the user has access to, including their parent
# and child groups (recursively).
def all_expanded_groups
- Gitlab::GroupHierarchy.new(groups).all_groups
+ Gitlab::ObjectHierarchy.new(groups).all_objects
end
def expanded_groups_requiring_two_factor_authentication
@@ -751,11 +760,19 @@ class User < ActiveRecord::Base
# Typically used in conjunction with projects table to get projects
# a user has been given access to.
+ # The param `related_project_column` is the column to compare to the
+ # project_authorizations. By default is projects.id
#
# Example use:
# `Project.where('EXISTS(?)', user.authorizations_for_projects)`
- def authorizations_for_projects
- project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id')
+ authorizations = project_authorizations
+ .select(1)
+ .where("project_authorizations.project_id = #{related_project_column}")
+
+ return authorizations unless min_access_level.present?
+
+ authorizations.where('project_authorizations.access_level >= ?', min_access_level)
end
# Returns the projects this user has reporter (or greater) access to, limited
@@ -870,7 +887,12 @@ class User < ActiveRecord::Base
# rubocop: enable CodeReuse/ServiceClass
def several_namespaces?
- owned_groups.any? || maintainers_groups.any?
+ union_sql = ::Gitlab::SQL::Union.new(
+ [owned_groups,
+ maintainers_groups,
+ groups_with_developer_maintainer_project_access]).to_sql
+
+ ::Group.from("(#{union_sql}) #{::Group.table_name}").any?
end
def namespace_id
@@ -905,6 +927,10 @@ class User < ActiveRecord::Base
DeployKey.unscoped.in_projects(authorized_projects.pluck(:id)).distinct(:id)
end
+ def highest_role
+ members.maximum(:access_level) || Gitlab::Access::NO_ACCESS
+ end
+
def accessible_deploy_keys
@accessible_deploy_keys ||= begin
key_ids = project_deploy_keys.pluck(:id)
@@ -1152,8 +1178,24 @@ class User < ActiveRecord::Base
@manageable_namespaces ||= [namespace] + manageable_groups
end
- def manageable_groups
- Gitlab::GroupHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+ def manageable_groups(include_groups_with_developer_maintainer_access: false)
+ owned_and_maintainer_group_hierarchy = Gitlab::ObjectHierarchy.new(owned_or_maintainers_groups).base_and_descendants
+
+ if include_groups_with_developer_maintainer_access
+ union_sql = ::Gitlab::SQL::Union.new(
+ [owned_and_maintainer_group_hierarchy,
+ groups_with_developer_maintainer_project_access]).to_sql
+
+ ::Group.from("(#{union_sql}) #{::Group.table_name}")
+ else
+ owned_and_maintainer_group_hierarchy
+ end
+ end
+
+ def manageable_groups_with_routes(include_groups_with_developer_maintainer_access: false)
+ manageable_groups(include_groups_with_developer_maintainer_access: include_groups_with_developer_maintainer_access)
+ .eager_load(:route)
+ .order('routes.path')
end
def namespaces
@@ -1422,6 +1464,10 @@ class User < ActiveRecord::Base
todos.where(id: ids)
end
+ def pending_todo_for(target)
+ todos.find_by(target: target, state: :pending)
+ end
+
# @deprecated
alias_method :owned_or_masters_groups, :owned_or_maintainers_groups
@@ -1451,15 +1497,6 @@ class User < ActiveRecord::Base
devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
end
- # This works around a bug in Devise 4.2.0 that erroneously causes a user to
- # be considered active in MySQL specs due to a sub-second comparison
- # issue. For more details, see: https://gitlab.com/gitlab-org/gitlab-ee/issues/2362#note_29004709
- def confirmation_period_valid?
- return false if self.class.allow_unconfirmed_access_for == 0.days
-
- super
- end
-
def ensure_user_rights_and_limits
if external?
self.can_create_group = false
@@ -1548,4 +1585,16 @@ class User < ActiveRecord::Base
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end
+
+ def groups_with_developer_maintainer_project_access
+ project_creation_levels = [::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS]
+
+ if ::Gitlab::CurrentSettings.default_project_creation == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ project_creation_levels << nil
+ end
+
+ developer_groups_hierarchy = ::Gitlab::ObjectHierarchy.new(developer_groups).base_and_descendants
+ ::Group.where(id: developer_groups_hierarchy.select(:id),
+ project_creation_level: project_creation_levels)
+ end
end
diff --git a/app/models/user_agent_detail.rb b/app/models/user_agent_detail.rb
index e2b2e7f1df9..fea1fce3c8d 100644
--- a/app/models/user_agent_detail.rb
+++ b/app/models/user_agent_detail.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserAgentDetail < ActiveRecord::Base
+class UserAgentDetail < ApplicationRecord
belongs_to :subject, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
validates :user_agent, :ip_address, :subject_id, :subject_type, presence: true
diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb
index 76e7bc06b4e..027ee44c6a9 100644
--- a/app/models/user_callout.rb
+++ b/app/models/user_callout.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserCallout < ActiveRecord::Base
+class UserCallout < ApplicationRecord
belongs_to :user
# We use `UserCalloutEnums.feature_names` here so that EE can more easily
diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb
index e0ffe8ebbfd..727975c3f6e 100644
--- a/app/models/user_custom_attribute.rb
+++ b/app/models/user_custom_attribute.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserCustomAttribute < ActiveRecord::Base
+class UserCustomAttribute < ApplicationRecord
belongs_to :user
validates :user_id, :key, :value, presence: true
diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb
index ae6778e49be..f6f72f4b77a 100644
--- a/app/models/user_interacted_project.rb
+++ b/app/models/user_interacted_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserInteractedProject < ActiveRecord::Base
+class UserInteractedProject < ApplicationRecord
belongs_to :user
belongs_to :project
@@ -26,16 +26,14 @@ class UserInteractedProject < ActiveRecord::Base
cached_exists?(attributes) do
transaction(requires_new: true) do
- begin
- where(attributes).select(1).first || create!(attributes)
- true # not caching the whole record here for now
- rescue ActiveRecord::RecordNotUnique
- # Note, above queries are not atomic and prone
- # to race conditions (similar like #find_or_create!).
- # In the case where we hit this, the record we want
- # already exists - shortcut and return.
- true
- end
+ where(attributes).select(1).first || create!(attributes)
+ true # not caching the whole record here for now
+ rescue ActiveRecord::RecordNotUnique
+ # Note, above queries are not atomic and prone
+ # to race conditions (similar like #find_or_create!).
+ # In the case where we hit this, the record we want
+ # already exists - shortcut and return.
+ true
end
end
end
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 32d0407800f..f1326f4c8cb 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserPreference < ActiveRecord::Base
+class UserPreference < ApplicationRecord
# We could use enums, but Rails 4 doesn't support multiple
# enum options with same name for multiple fields, also it creates
# extra methods that aren't really needed here.
@@ -10,6 +10,10 @@ class UserPreference < ActiveRecord::Base
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
+ default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
+ default_value_for :time_display_relative, value: true, allows_nil: false
+ default_value_for :time_format_in_24h, value: false, allows_nil: false
+
class << self
def notes_filters
{
diff --git a/app/models/user_status.rb b/app/models/user_status.rb
index 2bbb0c59ac1..6ced4f56823 100644
--- a/app/models/user_status.rb
+++ b/app/models/user_status.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserStatus < ActiveRecord::Base
+class UserStatus < ApplicationRecord
include CacheMarkdownField
self.primary_key = :user_id
diff --git a/app/models/user_synced_attributes_metadata.rb b/app/models/user_synced_attributes_metadata.rb
index 7115262942d..5aacf11b1cb 100644
--- a/app/models/user_synced_attributes_metadata.rb
+++ b/app/models/user_synced_attributes_metadata.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UserSyncedAttributesMetadata < ActiveRecord::Base
+class UserSyncedAttributesMetadata < ApplicationRecord
belongs_to :user
validates :user, presence: true
diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb
index bdaf58ae1c1..9be6bd2e6f3 100644
--- a/app/models/users_star_project.rb
+++ b/app/models/users_star_project.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-class UsersStarProject < ActiveRecord::Base
+class UsersStarProject < ApplicationRecord
belongs_to :project, counter_cache: :star_count, touch: true
belongs_to :user
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index b1d6d461928..cd4c7895587 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -28,16 +28,17 @@ class WikiPage
def self.group_by_directory(pages)
return [] if pages.blank?
- pages.sort_by { |page| [page.directory, page.slug] }
- .group_by(&:directory)
- .map do |dir, pages|
- if dir.present?
- WikiDirectory.new(dir, pages)
- else
- pages
- end
+ pages.each_with_object([]) do |page, grouped_pages|
+ next grouped_pages << page unless page.directory.present?
+
+ directory = grouped_pages.find do |obj|
+ obj.is_a?(WikiDirectory) && obj.slug == page.directory
end
- .flatten
+
+ next directory.pages << page if directory
+
+ grouped_pages << WikiDirectory.new(page.directory, [page])
+ end
end
def self.unhyphenize(name)
@@ -132,7 +133,7 @@ class WikiPage
# The GitLab Commit instance for this page.
def version
- return nil unless persisted?
+ return unless persisted?
@version ||= @page.version
end
diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb
index 0d0f1c28bad..5dd2279ef99 100644
--- a/app/policies/base_policy.rb
+++ b/app/policies/base_policy.rb
@@ -7,6 +7,10 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:admin) { @user&.admin? }
+ desc "User has access to all private groups & projects"
+ with_options scope: :user, score: 0
+ condition(:full_private_access) { @user&.full_private_access? }
+
with_options scope: :user, score: 0
condition(:external_user) { @user.nil? || @user.external? }
@@ -18,6 +22,13 @@ class BasePolicy < DeclarativePolicy::Base
Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC)
end
- # This is prevented in some cases in `gitlab-ee`
+ condition(:external_authorization_enabled, scope: :global, score: 0) do
+ ::Gitlab::ExternalAuthorization.perform_check?
+ end
+
+ rule { external_authorization_enabled & ~full_private_access }.policy do
+ prevent :read_cross_project
+ end
+
rule { default }.enable :read_cross_project
end
diff --git a/app/policies/board_policy.rb b/app/policies/board_policy.rb
new file mode 100644
index 00000000000..4bf1e7bd3e1
--- /dev/null
+++ b/app/policies/board_policy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+class BoardPolicy < BasePolicy
+ delegate { @subject.parent }
+
+ condition(:is_group_board) { @subject.group_board? }
+ condition(:is_project_board) { @subject.project_board? }
+
+ rule { is_project_board & can?(:read_project) }.enable :read_parent
+
+ rule { is_group_board & can?(:read_group) }.policy do
+ enable :read_parent
+ enable :read_milestone
+ enable :read_issue
+ end
+end
diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb
index e42d78f47c5..662c29a0973 100644
--- a/app/policies/ci/pipeline_policy.rb
+++ b/app/policies/ci/pipeline_policy.rb
@@ -10,6 +10,19 @@ module Ci
@subject.project.branch_allows_collaboration?(@user, @subject.ref)
end
+ condition(:external_pipeline, scope: :subject, score: 0) do
+ @subject.external?
+ end
+
+ condition(:triggerer_of_pipeline) do
+ @subject.triggered_by?(@user)
+ end
+
+ # Disallow users without permissions from accessing internal pipelines
+ rule { ~can?(:read_build) & ~external_pipeline }.policy do
+ prevent :read_pipeline
+ end
+
rule { protected_ref }.prevent :update_pipeline
rule { can?(:public_access) & branch_allows_collaboration }.policy do
@@ -20,6 +33,14 @@ module Ci
enable :destroy_pipeline
end
+ rule { can?(:admin_pipeline) }.policy do
+ enable :read_pipeline_variable
+ end
+
+ rule { can?(:update_pipeline) & triggerer_of_pipeline }.policy do
+ enable :read_pipeline_variable
+ end
+
def ref_protected?(user, project, tag, ref)
access = ::Gitlab::UserAccess.new(user, project: project)
diff --git a/app/policies/clusters/cluster_policy.rb b/app/policies/clusters/cluster_policy.rb
index d6d590687e2..316bd39f7a3 100644
--- a/app/policies/clusters/cluster_policy.rb
+++ b/app/policies/clusters/cluster_policy.rb
@@ -6,5 +6,6 @@ module Clusters
delegate { cluster.group }
delegate { cluster.project }
+ delegate { cluster.instance }
end
end
diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb
new file mode 100644
index 00000000000..e1045c85e6d
--- /dev/null
+++ b/app/policies/clusters/instance_policy.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Clusters
+ class InstancePolicy < BasePolicy
+ include ClusterableActions
+
+ condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
+ condition(:can_have_multiple_clusters) { multiple_clusters_available? }
+ condition(:instance_clusters_enabled) { Instance.enabled? }
+
+ rule { admin & instance_clusters_enabled }.policy do
+ enable :read_cluster
+ enable :add_cluster
+ enable :create_cluster
+ enable :update_cluster
+ enable :admin_cluster
+ end
+
+ rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+ end
+end
diff --git a/app/policies/concerns/clusterable_actions.rb b/app/policies/concerns/clusterable_actions.rb
new file mode 100644
index 00000000000..08ddd742ea9
--- /dev/null
+++ b/app/policies/concerns/clusterable_actions.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module ClusterableActions
+ private
+
+ # Overridden on EE module
+ def multiple_clusters_available?
+ false
+ end
+
+ def clusterable_has_clusters?
+ !subject.clusters.empty?
+ end
+end
diff --git a/app/policies/container_repository_policy.rb b/app/policies/container_repository_policy.rb
new file mode 100644
index 00000000000..6781c845142
--- /dev/null
+++ b/app/policies/container_repository_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ContainerRepositoryPolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb
index 16c58730878..e85397422e6 100644
--- a/app/policies/global_policy.rb
+++ b/app/policies/global_policy.rb
@@ -44,7 +44,6 @@ class GlobalPolicy < BasePolicy
prevent :access_api
prevent :access_git
prevent :receive_notifications
- prevent :use_quick_actions
end
rule { required_terms_not_accepted }.policy do
@@ -68,6 +67,10 @@ class GlobalPolicy < BasePolicy
enable :read_users_list
end
+ rule { ~anonymous }.policy do
+ enable :read_instance_metadata
+ end
+
rule { admin }.policy do
enable :read_custom_attribute
enable :update_custom_attribute
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 6b4e56ef5e4..ea86858181d 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class GroupPolicy < BasePolicy
+ include ClusterableActions
+
desc "Group is public"
with_options scope: :subject, score: 0
condition(:public_group) { @subject.public? }
@@ -16,7 +18,7 @@ class GroupPolicy < BasePolicy
condition(:maintainer) { access_level >= GroupMember::MAINTAINER }
condition(:reporter) { access_level >= GroupMember::REPORTER }
- condition(:nested_groups_supported, scope: :global) { Group.supports_nested_groups? }
+ condition(:nested_groups_supported, scope: :global) { Group.supports_nested_objects? }
condition(:has_parent, scope: :subject) { @subject.has_parent? }
condition(:share_with_group_locked, scope: :subject) { @subject.share_with_group_lock? }
@@ -24,12 +26,23 @@ class GroupPolicy < BasePolicy
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
condition(:has_projects) do
- GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any?
+ GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true, only_owned: true }).execute.any?
end
+ condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
+ condition(:can_have_multiple_clusters) { multiple_clusters_available? }
+
with_options scope: :subject, score: 0
condition(:request_access_enabled) { @subject.request_access_enabled }
+ condition(:create_projects_disabled) do
+ @subject.project_creation_level == ::Gitlab::Access::NO_ONE_PROJECT_ACCESS
+ end
+
+ condition(:developer_maintainer_access) do
+ @subject.project_creation_level == ::Gitlab::Access::DEVELOPER_MAINTAINER_PROJECT_ACCESS
+ end
+
rule { public_group }.policy do
enable :read_group
enable :read_list
@@ -40,15 +53,17 @@ class GroupPolicy < BasePolicy
rule { guest }.policy do
enable :read_group
+ enable :read_list
enable :upload_file
enable :read_label
end
- rule { admin } .enable :read_group
+ rule { admin }.enable :read_group
rule { has_projects }.policy do
- enable :read_group
+ enable :read_list
enable :read_label
+ enable :read_group
end
rule { has_access }.enable :read_namespace
@@ -66,6 +81,7 @@ class GroupPolicy < BasePolicy
enable :admin_pipeline
enable :admin_build
enable :read_cluster
+ enable :add_cluster
enable :create_cluster
enable :update_cluster
enable :admin_cluster
@@ -105,9 +121,18 @@ class GroupPolicy < BasePolicy
rule { owner & (~share_with_group_locked | ~has_parent | ~parent_share_with_group_locked | can_change_parent_share_with_group_lock) }.enable :change_share_with_group_lock
+ rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+
+ rule { developer & developer_maintainer_access }.enable :create_projects
+ rule { create_projects_disabled }.prevent :create_projects
+
def access_level
return GroupMember::NO_ACCESS if @user.nil?
- @access_level ||= @subject.max_member_access_for_user(@user)
+ @access_level ||= lookup_access_level!
+ end
+
+ def lookup_access_level!
+ @subject.max_member_access_for_user(@user)
end
end
diff --git a/app/policies/identity_provider_policy.rb b/app/policies/identity_provider_policy.rb
new file mode 100644
index 00000000000..d34cdd5bdd4
--- /dev/null
+++ b/app/policies/identity_provider_policy.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class IdentityProviderPolicy < BasePolicy
+ desc "Provider is SAML or CAS3"
+ condition(:protected_provider, scope: :subject, score: 0) { %w(saml cas3).include?(@subject.to_s) }
+
+ rule { anonymous }.prevent_all
+
+ rule { default }.policy do
+ enable :unlink
+ enable :link
+ end
+
+ rule { protected_provider }.prevent(:unlink)
+end
diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb
index 6d8b575102e..537319addc2 100644
--- a/app/policies/issuable_policy.rb
+++ b/app/policies/issuable_policy.rb
@@ -11,12 +11,13 @@ class IssuablePolicy < BasePolicy
@user && @subject.assignee_or_author?(@user)
end
- rule { assignee_or_author }.policy do
+ rule { can?(:guest_access) & assignee_or_author }.policy do
enable :read_issue
enable :update_issue
enable :reopen_issue
enable :read_merge_request
enable :update_merge_request
+ enable :reopen_merge_request
end
rule { locked & ~is_project_member }.policy do
diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb
index a0706eaa46c..dd8c5d49cf4 100644
--- a/app/policies/issue_policy.rb
+++ b/app/policies/issue_policy.rb
@@ -18,6 +18,7 @@ class IssuePolicy < IssuablePolicy
prevent :read_issue_iid
prevent :update_issue
prevent :admin_issue
+ prevent :create_note
end
rule { locked }.policy do
diff --git a/app/policies/merge_request_policy.rb b/app/policies/merge_request_policy.rb
index a2950951d03..a3692857ff4 100644
--- a/app/policies/merge_request_policy.rb
+++ b/app/policies/merge_request_policy.rb
@@ -1,4 +1,7 @@
# frozen_string_literal: true
class MergeRequestPolicy < IssuablePolicy
+ rule { locked }.policy do
+ prevent :reopen_merge_request
+ end
end
diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb
index f22843b6463..8d23e3abed3 100644
--- a/app/policies/note_policy.rb
+++ b/app/policies/note_policy.rb
@@ -18,6 +18,7 @@ class NotePolicy < BasePolicy
prevent :read_note
prevent :admin_note
prevent :resolve_note
+ prevent :award_emoji
end
rule { is_author }.policy do
diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb
index 777f933cdcd..40dd49b4afd 100644
--- a/app/policies/personal_snippet_policy.rb
+++ b/app/policies/personal_snippet_policy.rb
@@ -7,7 +7,7 @@ class PersonalSnippetPolicy < BasePolicy
rule { public_snippet }.policy do
enable :read_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
rule { is_author }.policy do
@@ -15,7 +15,7 @@ class PersonalSnippetPolicy < BasePolicy
enable :update_personal_snippet
enable :destroy_personal_snippet
enable :admin_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
rule { ~anonymous }.enable :create_personal_snippet
@@ -23,10 +23,12 @@ class PersonalSnippetPolicy < BasePolicy
rule { internal_snippet & ~external_user }.policy do
enable :read_personal_snippet
- enable :comment_personal_snippet
+ enable :create_note
end
- rule { anonymous }.prevent :comment_personal_snippet
+ rule { anonymous }.prevent :create_note
- rule { can?(:comment_personal_snippet) }.enable :award_emoji
+ rule { can?(:create_note) }.enable :award_emoji
+
+ rule { full_private_access }.enable :read_personal_snippet
end
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 1c082945299..3218c04b219 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -2,6 +2,7 @@
class ProjectPolicy < BasePolicy
extend ClassMethods
+ include ClusterableActions
READONLY_FEATURES_WHEN_ARCHIVED = %i[
issue
@@ -22,6 +23,7 @@ class ProjectPolicy < BasePolicy
container_image
pages
cluster
+ release
].freeze
desc "User is a project owner"
@@ -87,6 +89,15 @@ class ProjectPolicy < BasePolicy
::Gitlab::CurrentSettings.current_application_settings.mirror_available
end
+ with_scope :subject
+ condition(:classification_label_authorized, score: 32) do
+ ::Gitlab::ExternalAuthorization.access_allowed?(
+ @user,
+ @subject.external_authorization_classification_label,
+ @subject.full_path
+ )
+ end
+
# We aren't checking `:read_issue` or `:read_merge_request` in this case
# because it could be possible for a user to see an issuable-iid
# (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be
@@ -103,6 +114,13 @@ class ProjectPolicy < BasePolicy
@subject.feature_available?(:merge_requests, @user)
end
+ condition(:has_clusters, scope: :subject) { clusterable_has_clusters? }
+ condition(:can_have_multiple_clusters) { multiple_clusters_available? }
+
+ condition(:internal_builds_disabled) do
+ !@subject.builds_enabled?
+ end
+
features = %w[
merge_requests
issues
@@ -143,7 +161,6 @@ class ProjectPolicy < BasePolicy
enable :remove_fork_project
enable :destroy_merge_request
enable :destroy_issue
- enable :remove_pages
enable :set_issue_iid
enable :set_issue_created_at
@@ -169,6 +186,7 @@ class ProjectPolicy < BasePolicy
enable :read_cycle_analytics
enable :award_emoji
enable :read_pages_content
+ enable :read_release
end
# These abilities are not allowed to admins that are not members of the project,
@@ -178,6 +196,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) }.policy do
enable :download_code
+ enable :read_statistics
enable :download_wiki_code
enable :fork_project
enable :create_project_snippet
@@ -190,10 +209,11 @@ class ProjectPolicy < BasePolicy
enable :read_build
enable :read_container_image
enable :read_pipeline
- enable :read_pipeline_schedule
enable :read_environment
enable :read_deployment
enable :read_merge_request
+ enable :read_sentry_issue
+ enable :read_prometheus
end
# We define `:public_user_access` separately because there are cases in gitlab-ee
@@ -216,16 +236,20 @@ class ProjectPolicy < BasePolicy
rule { owner | admin | guest | group_member }.prevent :request_access
rule { ~request_access_enabled }.prevent :request_access
+ rule { can?(:developer_access) & can?(:create_issue) }.enable :import_issues
+
rule { can?(:developer_access) }.policy do
enable :admin_merge_request
enable :admin_milestone
enable :update_merge_request
+ enable :reopen_merge_request
enable :create_commit_status
enable :update_commit_status
enable :create_build
enable :update_build
enable :create_pipeline
enable :update_pipeline
+ enable :read_pipeline_schedule
enable :create_pipeline_schedule
enable :create_merge_request_from
enable :create_wiki
@@ -235,6 +259,8 @@ class ProjectPolicy < BasePolicy
enable :update_container_image
enable :create_environment
enable :create_deployment
+ enable :create_release
+ enable :update_release
end
rule { can?(:maintainer_access) }.policy do
@@ -256,11 +282,16 @@ class ProjectPolicy < BasePolicy
enable :admin_pages
enable :read_pages
enable :update_pages
+ enable :remove_pages
enable :read_cluster
+ enable :add_cluster
enable :create_cluster
enable :update_cluster
enable :admin_cluster
enable :create_environment_terminal
+ enable :destroy_release
+ enable :destroy_artifacts
+ enable :daily_statistics
end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
@@ -282,6 +313,8 @@ class ProjectPolicy < BasePolicy
rule { issues_disabled }.policy do
prevent(*create_read_update_admin_destroy(:issue))
+ prevent(*create_read_update_admin_destroy(:board))
+ prevent(*create_read_update_admin_destroy(:list))
end
rule { merge_requests_disabled | repository_disabled }.policy do
@@ -301,13 +334,12 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:project_snippet))
end
- rule { wiki_disabled & ~has_external_wiki }.policy do
+ rule { wiki_disabled }.policy do
prevent(*create_read_update_admin_destroy(:wiki))
prevent(:download_wiki_code)
end
rule { builds_disabled | repository_disabled }.policy do
- prevent(*create_update_admin_destroy(:pipeline))
prevent(*create_read_update_admin_destroy(:build))
prevent(*create_read_update_admin_destroy(:pipeline_schedule))
prevent(*create_read_update_admin_destroy(:environment))
@@ -315,11 +347,23 @@ class ProjectPolicy < BasePolicy
prevent(*create_read_update_admin_destroy(:deployment))
end
+ # There's two separate cases when builds_disabled is true:
+ # 1. When internal CI is disabled - builds_disabled && internal_builds_disabled
+ # - We do not prevent the user from accessing Pipelines to allow him to access external CI
+ # 2. When the user is not allowed to access CI - builds_disabled && ~internal_builds_disabled
+ # - We prevent the user from accessing Pipelines
+ rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do
+ prevent(*create_read_update_admin_destroy(:pipeline))
+ prevent(*create_read_update_admin_destroy(:commit_status))
+ end
+
rule { repository_disabled }.policy do
prevent :push_code
prevent :download_code
prevent :fork_project
prevent :read_commit_status
+ prevent :read_pipeline
+ prevent(*create_read_update_admin_destroy(:release))
end
rule { container_registry_disabled }.policy do
@@ -345,10 +389,10 @@ class ProjectPolicy < BasePolicy
enable :read_merge_request
enable :read_note
enable :read_pipeline
- enable :read_pipeline_schedule
enable :read_commit_status
enable :read_container_image
enable :download_code
+ enable :read_release
enable :download_wiki_code
enable :read_cycle_analytics
enable :read_pages_content
@@ -363,7 +407,6 @@ class ProjectPolicy < BasePolicy
rule { public_builds & can?(:guest_access) }.policy do
enable :read_pipeline
- enable :read_pipeline_schedule
end
# These rules are included to allow maintainers of projects to push to certain
@@ -378,9 +421,30 @@ class ProjectPolicy < BasePolicy
end.enable :read_issue_iid
rule do
- (can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
+ (~guest & can?(:read_project_for_iids) & merge_requests_visible_to_user) | can?(:read_merge_request)
end.enable :read_merge_request_iid
+ rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster
+
+ rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do
+ # Preventing access here still allows the projects to be listed. Listing
+ # projects doesn't check the `:read_project` ability. But instead counts
+ # on the `project_authorizations` table.
+ #
+ # All other actions should explicitly check read project, which would
+ # trigger the `classification_label_authorized` condition.
+ #
+ # `:read_project_for_iids` is not prevented by this condition, as it is
+ # used for cross-project reference checks.
+ prevent :guest_access
+ prevent :public_access
+ prevent :public_user_access
+ prevent :reporter_access
+ prevent :developer_access
+ prevent :maintainer_access
+ prevent :owner_access
+ end
+
private
def team_member?
@@ -424,6 +488,10 @@ class ProjectPolicy < BasePolicy
def team_access_level
return -1 if @user.nil?
+ lookup_access_level!
+ end
+
+ def lookup_access_level!
# NOTE: max_member_access has its own cache
project.team.max_member_access(@user.id)
end
@@ -433,7 +501,7 @@ class ProjectPolicy < BasePolicy
when ProjectFeature::DISABLED
false
when ProjectFeature::PRIVATE
- guest? || admin?
+ admin? || team_access_level >= ProjectFeature.required_minimum_access_level(feature)
else
true
end
diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb
index 288bf070cfc..e5e005cee6d 100644
--- a/app/policies/project_snippet_policy.rb
+++ b/app/policies/project_snippet_policy.rb
@@ -5,13 +5,12 @@ class ProjectSnippetPolicy < BasePolicy
desc "Snippet is public"
condition(:public_snippet, scope: :subject) { @subject.public? }
+ condition(:internal_snippet, scope: :subject) { @subject.internal? }
condition(:private_snippet, scope: :subject) { @subject.private? }
condition(:public_project, scope: :subject) { @subject.project.public? }
condition(:is_author) { @user && @subject.author == @user }
- condition(:internal, scope: :subject) { @subject.internal? }
-
# We have to check both project feature visibility and a snippet visibility and take the stricter one
# This will be simplified - check https://gitlab.com/gitlab-org/gitlab-ce/issues/27573
rule { ~can?(:read_project) }.policy do
@@ -26,13 +25,13 @@ class ProjectSnippetPolicy < BasePolicy
# is used to hide/show various snippet-related controls, so we can't just move
# all of the handling here.
rule do
- all?(private_snippet | (internal & external_user),
+ all?(private_snippet | (internal_snippet & external_user),
~project.guest,
- ~admin,
- ~is_author)
+ ~is_author,
+ ~full_private_access)
end.prevent :read_project_snippet
- rule { internal & ~is_author & ~admin }.policy do
+ rule { internal_snippet & ~is_author & ~admin }.policy do
prevent :update_project_snippet
prevent :admin_project_snippet
end
@@ -44,4 +43,6 @@ class ProjectSnippetPolicy < BasePolicy
enable :update_project_snippet
enable :admin_project_snippet
end
+
+ rule { ~can?(:read_project_snippet) }.prevent :create_note
end
diff --git a/app/policies/release_policy.rb b/app/policies/release_policy.rb
new file mode 100644
index 00000000000..d7f9e5d7445
--- /dev/null
+++ b/app/policies/release_policy.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ReleasePolicy < BasePolicy
+ delegate { @subject.project }
+end
diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb
new file mode 100644
index 00000000000..7b13db3bb74
--- /dev/null
+++ b/app/presenters/blobs/unfold_presenter.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'gt_one_coercion'
+
+module Blobs
+ class UnfoldPresenter < BlobPresenter
+ include Virtus.model
+ include Gitlab::Utils::StrongMemoize
+
+ attribute :full, Boolean, default: false
+ attribute :since, GtOneCoercion
+ attribute :to, GtOneCoercion
+ attribute :bottom, Boolean
+ attribute :unfold, Boolean, default: true
+ attribute :offset, Integer
+ attribute :indent, Integer, default: 0
+
+ def initialize(blob, params)
+ @subject = blob
+ @all_lines = highlight.lines
+ super(params)
+
+ if full?
+ self.attributes = { since: 1, to: @all_lines.size, bottom: false, unfold: false, offset: 0, indent: 0 }
+ end
+ end
+
+ # Converts a String array to Gitlab::Diff::Line array, with match line added
+ def diff_lines
+ diff_lines = lines.map do |line|
+ Gitlab::Diff::Line.new(line, nil, nil, nil, nil, rich_text: line)
+ end
+
+ add_match_line(diff_lines)
+
+ diff_lines
+ end
+
+ def lines
+ strong_memoize(:lines) do
+ lines = @all_lines
+ lines = lines[since - 1..to - 1] unless full?
+ lines.map(&:html_safe)
+ end
+ end
+
+ def match_line_text
+ return '' if bottom?
+
+ lines_length = lines.length - 1
+ line = [since, lines_length].join(',')
+ "@@ -#{line}+#{line} @@"
+ end
+
+ private
+
+ def add_match_line(diff_lines)
+ return unless unfold?
+
+ if bottom? && to < @all_lines.size
+ old_pos = to - offset
+ new_pos = to
+ elsif since != 1
+ old_pos = new_pos = since
+ end
+
+ # Match line is not needed when it reaches the top limit or bottom limit of the file.
+ return unless new_pos
+
+ match_line = Gitlab::Diff::Line.new(match_line_text, 'match', nil, old_pos, new_pos)
+
+ bottom? ? diff_lines.push(match_line) : diff_lines.unshift(match_line)
+ end
+ end
+end
diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb
new file mode 100644
index 00000000000..ee11cffe355
--- /dev/null
+++ b/app/presenters/ci/bridge_presenter.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module Ci
+ class BridgePresenter < CommitStatusPresenter
+ def detailed_status
+ @detailed_status ||= subject.detailed_status(user)
+ end
+ end
+end
diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb
index 300f85e1e9d..ed3daf6585b 100644
--- a/app/presenters/ci/build_runner_presenter.rb
+++ b/app/presenters/ci/build_runner_presenter.rb
@@ -2,6 +2,12 @@
module Ci
class BuildRunnerPresenter < SimpleDelegator
+ include Gitlab::Utils::StrongMemoize
+
+ DEFAULT_GIT_DEPTH_MERGE_REQUEST = 10
+ RUNNER_REMOTE_TAG_PREFIX = 'refs/tags/'.freeze
+ RUNNER_REMOTE_BRANCH_PREFIX = 'refs/remotes/origin/'.freeze
+
def artifacts
return unless options[:artifacts]
@@ -11,6 +17,37 @@ module Ci
list.flatten.compact
end
+ def ref_type
+ if tag
+ 'tag'
+ else
+ 'branch'
+ end
+ end
+
+ def git_depth
+ strong_memoize(:git_depth) do
+ git_depth = variables&.find { |variable| variable[:key] == 'GIT_DEPTH' }&.dig(:value)
+ git_depth ||= DEFAULT_GIT_DEPTH_MERGE_REQUEST if merge_request_ref?
+ git_depth.to_i
+ end
+ end
+
+ def refspecs
+ specs = []
+
+ if git_depth > 0
+ specs << refspec_for_branch(ref) if branch? || legacy_detached_merge_request_pipeline?
+ specs << refspec_for_tag(ref) if tag?
+ specs << refspec_for_merge_request_ref if merge_request_ref?
+ else
+ specs << refspec_for_branch
+ specs << refspec_for_tag
+ end
+
+ specs
+ end
+
private
def create_archive(artifacts)
@@ -41,5 +78,17 @@ module Ci
}
end
end
+
+ def refspec_for_branch(ref = '*')
+ "+#{Gitlab::Git::BRANCH_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_BRANCH_PREFIX}#{ref}"
+ end
+
+ def refspec_for_tag(ref = '*')
+ "+#{Gitlab::Git::TAG_REF_PREFIX}#{ref}:#{RUNNER_REMOTE_TAG_PREFIX}#{ref}"
+ end
+
+ def refspec_for_merge_request_ref
+ "+#{ref}:#{ref}"
+ end
end
end
diff --git a/app/presenters/ci/pipeline_presenter.rb b/app/presenters/ci/pipeline_presenter.rb
index 57daf04efc6..944895904fe 100644
--- a/app/presenters/ci/pipeline_presenter.rb
+++ b/app/presenters/ci/pipeline_presenter.rb
@@ -3,6 +3,7 @@
module Ci
class PipelinePresenter < Gitlab::View::Presenter::Delegated
include Gitlab::Utils::StrongMemoize
+ include ActionView::Helpers::UrlHelper
# We use a class method here instead of a constant, allowing EE to redefine
# the returned `Hash` more easily.
@@ -32,5 +33,49 @@ module Ci
"Pipeline is redundant and is auto-canceled by Pipeline ##{auto_canceled_by_id}"
end
end
+
+ def ref_text
+ if pipeline.detached_merge_request_pipeline?
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch }
+ elsif pipeline.merge_request_pipeline?
+ _("for %{link_to_merge_request} with %{link_to_merge_request_source_branch} into %{link_to_merge_request_target_branch}").html_safe % { link_to_merge_request: link_to_merge_request, link_to_merge_request_source_branch: link_to_merge_request_source_branch, link_to_merge_request_target_branch: link_to_merge_request_target_branch }
+ elsif pipeline.ref
+ if pipeline.ref_exists?
+ _("for %{link_to_pipeline_ref}").html_safe % { link_to_pipeline_ref: link_to_pipeline_ref }
+ else
+ _("for %{ref}") % { ref: content_tag(:span, pipeline.ref, class: 'ref-name') }
+ end
+ end
+ end
+
+ def link_to_pipeline_ref
+ link_to(pipeline.ref,
+ project_commits_path(pipeline.project, pipeline.ref),
+ class: "ref-name")
+ end
+
+ def link_to_merge_request
+ return unless merge_request_presenter
+
+ link_to(merge_request_presenter.to_reference,
+ project_merge_request_path(merge_request_presenter.project, merge_request_presenter),
+ class: 'mr-iid')
+ end
+
+ def link_to_merge_request_source_branch
+ merge_request_presenter&.source_branch_link
+ end
+
+ def link_to_merge_request_target_branch
+ merge_request_presenter&.target_branch_link
+ end
+
+ private
+
+ def merge_request_presenter
+ return unless pipeline.triggered_by_merge_request?
+
+ @merge_request_presenter ||= pipeline.merge_request.present(current_user: current_user)
+ end
end
end
diff --git a/app/presenters/ci/trigger_presenter.rb b/app/presenters/ci/trigger_presenter.rb
new file mode 100644
index 00000000000..605c8f328a4
--- /dev/null
+++ b/app/presenters/ci/trigger_presenter.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Ci
+ class TriggerPresenter < Gitlab::View::Presenter::Delegated
+ presents :trigger
+
+ def has_token_exposed?
+ can?(current_user, :admin_trigger, trigger)
+ end
+
+ def token
+ if has_token_exposed?
+ trigger.token
+ else
+ trigger.short_token
+ end
+ end
+ end
+end
diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb
index 9cc137aa3bd..34bdf156623 100644
--- a/app/presenters/clusterable_presenter.rb
+++ b/app/presenters/clusterable_presenter.rb
@@ -12,6 +12,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
.fabricate!
end
+ def can_add_cluster?
+ can?(current_user, :add_cluster, clusterable)
+ end
+
def can_create_cluster?
can?(current_user, :create_cluster, clusterable)
end
@@ -40,6 +44,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated
raise NotImplementedError
end
+ def update_applications_cluster_path(cluster, application)
+ raise NotImplementedError
+ end
+
def cluster_path(cluster, params = {})
raise NotImplementedError
end
diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb
index 0473bb8c72a..1634d2479a0 100644
--- a/app/presenters/clusters/cluster_presenter.rb
+++ b/app/presenters/clusters/cluster_presenter.rb
@@ -2,14 +2,28 @@
module Clusters
class ClusterPresenter < Gitlab::View::Presenter::Delegated
+ include ActionView::Helpers::SanitizeHelper
+ include ActionView::Helpers::UrlHelper
+ include IconsHelper
+
presents :cluster
+ # We do not want to show the group path for clusters belonging to the
+ # clusterable, only for the ancestor clusters.
+ def item_link(clusterable_presenter)
+ if cluster.group_type? && clusterable != clusterable_presenter.subject
+ contracted_group_name(cluster.group) + ' / ' + link_to_cluster
+ else
+ link_to_cluster
+ end
+ end
+
def gke_cluster_url
"https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
end
- def can_toggle_cluster?
- can?(current_user, :update_cluster, cluster) && created?
+ def can_read_cluster?
+ can?(current_user, :read_cluster, cluster)
end
def cluster_type_description
@@ -17,6 +31,8 @@ module Clusters
s_("ClusterIntegration|Project cluster")
elsif cluster.group_type?
s_("ClusterIntegration|Group cluster")
+ elsif cluster.instance_type?
+ s_("ClusterIntegration|Instance cluster")
end
end
@@ -25,9 +41,39 @@ module Clusters
project_cluster_path(project, cluster)
elsif cluster.group_type?
group_cluster_path(group, cluster)
+ elsif cluster.instance_type?
+ admin_cluster_path(cluster)
else
raise NotImplementedError
end
end
+
+ def read_only_kubernetes_platform_fields?
+ !cluster.provided_by_user?
+ end
+
+ private
+
+ def clusterable
+ if cluster.group_type?
+ cluster.group
+ elsif cluster.project_type?
+ cluster.project
+ end
+ end
+
+ def contracted_group_name(group)
+ sanitize(group.full_name)
+ .sub(%r{\/.*\/}, "/ #{contracted_icon} /")
+ .html_safe
+ end
+
+ def contracted_icon
+ sprite_icon('ellipsis_h', size: 12, css_class: 'vertical-align-middle')
+ end
+
+ def link_to_cluster
+ link_to_if(can_read_cluster?, cluster.name, show_path)
+ end
end
end
diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb
new file mode 100644
index 00000000000..05adbe1d4f5
--- /dev/null
+++ b/app/presenters/commit_presenter.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class CommitPresenter < Gitlab::View::Presenter::Simple
+ presents :commit
+
+ def status_for(ref)
+ can?(current_user, :read_commit_status, commit.project) && commit.status(ref)
+ end
+
+ def any_pipelines?
+ can?(current_user, :read_pipeline, commit.project) && commit.pipelines.any?
+ end
+end
diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb
index 0cd77da6303..28a25c8b7a3 100644
--- a/app/presenters/commit_status_presenter.rb
+++ b/app/presenters/commit_status_presenter.rb
@@ -11,7 +11,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated
runner_unsupported: 'Your runner is outdated, please upgrade your runner',
stale_schedule: 'Delayed job could not be executed by some reason, please try again',
job_execution_timeout: 'The script exceeded the maximum execution time set for the job',
- archived_failure: 'The job is archived and cannot be run'
+ archived_failure: 'The job is archived and cannot be run',
+ unmet_prerequisites: 'The job failed to complete prerequisite tasks'
}.freeze
private_constant :CALLOUT_FAILURE_MESSAGES
diff --git a/app/presenters/group_clusterable_presenter.rb b/app/presenters/group_clusterable_presenter.rb
index ef6bbc0d109..f5b0bb64487 100644
--- a/app/presenters/group_clusterable_presenter.rb
+++ b/app/presenters/group_clusterable_presenter.rb
@@ -14,6 +14,11 @@ class GroupClusterablePresenter < ClusterablePresenter
install_applications_group_cluster_path(clusterable, cluster, application)
end
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_group_cluster_path(clusterable, cluster, application)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
group_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb
new file mode 100644
index 00000000000..f8bbe5216f1
--- /dev/null
+++ b/app/presenters/instance_clusterable_presenter.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+class InstanceClusterablePresenter < ClusterablePresenter
+ extend ::Gitlab::Utils::Override
+ include ActionView::Helpers::UrlHelper
+
+ def self.fabricate(clusterable, **attributes)
+ attributes_with_presenter_class = attributes.merge(presenter_class: InstanceClusterablePresenter)
+
+ Gitlab::View::Presenter::Factory
+ .new(clusterable, attributes_with_presenter_class)
+ .fabricate!
+ end
+
+ override :index_path
+ def index_path
+ admin_clusters_path
+ end
+
+ override :new_path
+ def new_path
+ new_admin_cluster_path
+ end
+
+ override :cluster_status_cluster_path
+ def cluster_status_cluster_path(cluster, params = {})
+ cluster_status_admin_cluster_path(cluster, params)
+ end
+
+ override :install_applications_cluster_path
+ def install_applications_cluster_path(cluster, application)
+ install_applications_admin_cluster_path(cluster, application)
+ end
+
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_admin_cluster_path(cluster, application)
+ end
+
+ override :cluster_path
+ def cluster_path(cluster, params = {})
+ admin_cluster_path(cluster, params)
+ end
+
+ override :create_user_clusters_path
+ def create_user_clusters_path
+ create_user_admin_clusters_path
+ end
+
+ override :create_gcp_clusters_path
+ def create_gcp_clusters_path
+ create_gcp_admin_clusters_path
+ end
+
+ override :empty_state_help_text
+ def empty_state_help_text
+ s_('ClusterIntegration|Adding an integration will share the cluster across all projects.')
+ end
+
+ override :sidebar_text
+ def sidebar_text
+ s_('ClusterIntegration|Adding a Kubernetes cluster will automatically share the cluster across all projects. Use review apps, deploy your applications, and easily run your pipelines for all projects using the same cluster.')
+ end
+
+ override :learn_more_link
+ def learn_more_link
+ link_to(s_('ClusterIntegration|Learn more about instance Kubernetes clusters'), help_page_path('user/instance/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ end
+end
diff --git a/app/presenters/label_presenter.rb b/app/presenters/label_presenter.rb
new file mode 100644
index 00000000000..1077bf543d9
--- /dev/null
+++ b/app/presenters/label_presenter.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+class LabelPresenter < Gitlab::View::Presenter::Delegated
+ presents :label
+
+ def edit_path
+ case label
+ when GroupLabel then edit_group_label_path(label.group, label)
+ when ProjectLabel then edit_project_label_path(label.project, label)
+ end
+ end
+
+ def destroy_path
+ case label
+ when GroupLabel then group_label_path(label.group, label)
+ when ProjectLabel then project_label_path(label.project, label)
+ end
+ end
+
+ def filter_path(type: :issue)
+ case context_subject
+ when Group
+ send("#{type.to_s.pluralize}_group_path", # rubocop:disable GitlabSecurity/PublicSend
+ context_subject,
+ label_name: [label.name])
+ when Project
+ send("namespace_project_#{type.to_s.pluralize}_path", # rubocop:disable GitlabSecurity/PublicSend
+ context_subject.namespace,
+ context_subject,
+ label_name: [label.name])
+ end
+ end
+
+ def can_subscribe_to_label_in_different_levels?
+ issuable_subject.is_a?(Project) && label.is_a?(GroupLabel)
+ end
+
+ def project_label?
+ label.is_a?(ProjectLabel)
+ end
+
+ def subject_name
+ label.subject.name
+ end
+
+ private
+
+ def context_subject
+ issuable_subject || label.try(:subject)
+ end
+end
diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb
index 1db6c9eff36..ba0711ca867 100644
--- a/app/presenters/merge_request_presenter.rb
+++ b/app/presenters/merge_request_presenter.rb
@@ -13,7 +13,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
def ci_status
if pipeline
status = pipeline.status
- status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
+ status = "success-with-warnings" if pipeline.success? && pipeline.has_warnings?
status || "preparing"
else
@@ -50,7 +50,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
if user_can_fork_project? && cached_can_be_reverted?
continue_params = {
to: merge_request_path(merge_request),
- notice: "#{edit_in_new_fork_notice} Try to cherry-pick this commit again.",
+ notice: _('%{edit_in_new_fork_notice} Try to cherry-pick this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice },
notice_now: edit_in_new_fork_notice_now
}
@@ -64,7 +64,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
if user_can_fork_project? && can_be_cherry_picked?
continue_params = {
to: merge_request_path(merge_request),
- notice: "#{edit_in_new_fork_notice} Try to revert this commit again.",
+ notice: _('%{edit_in_new_fork_notice} Try to revert this commit again.') % { edit_in_new_fork_notice: edit_in_new_fork_notice },
notice_now: edit_in_new_fork_notice_now
}
@@ -98,6 +98,18 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
end
end
+ def target_branch_path
+ if target_branch_exists?
+ project_branch_path(project, target_branch)
+ end
+ end
+
+ def source_branch_commits_path
+ if source_branch_exists?
+ project_commits_path(source_project, source_branch)
+ end
+ end
+
def source_branch_path
if source_branch_exists?
project_branch_path(source_project, source_branch)
@@ -144,8 +156,11 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
).assignable_issues
path = assign_related_issues_project_merge_request_path(project, merge_request)
if issues.present?
- pluralize_this_issue = issues.count > 1 ? "these issues" : "this issue"
- link_to "Assign yourself to #{pluralize_this_issue}", path, method: :post
+ if issues.count > 1
+ link_to _('Assign yourself to these issues'), path, method: :post
+ else
+ link_to _('Assign yourself to this issue'), path, method: :post
+ end
end
# rubocop: enable CodeReuse/ServiceClass
end
@@ -170,6 +185,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
source_branch_exists? && merge_request.can_remove_source_branch?(current_user)
end
+ def can_read_pipeline?
+ pipeline && can?(current_user, :read_pipeline, pipeline)
+ end
+
def mergeable_discussions_state
# This avoids calling MergeRequest#mergeable_discussions_state without
# considering the state of the MR first. If a MR isn't mergeable, we can
@@ -189,6 +208,30 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated
merge_request.subscribed?(current_user, merge_request.target_project)
end
+ def conflicts_docs_path
+ help_page_path('user/project/merge_requests/resolve_conflicts.md')
+ end
+
+ def merge_request_pipelines_docs_path
+ help_page_path('ci/merge_request_pipelines/index.md')
+ end
+
+ def source_branch_link
+ if source_branch_exists?
+ link_to(source_branch, source_branch_commits_path, class: 'ref-name')
+ else
+ content_tag(:span, source_branch, class: 'ref-name')
+ end
+ end
+
+ def target_branch_link
+ if target_branch_exists?
+ link_to(target_branch, target_branch_commits_path, class: 'ref-name')
+ else
+ content_tag(:span, target_branch, class: 'ref-name')
+ end
+ end
+
private
def cached_can_be_reverted?
diff --git a/app/presenters/project_clusterable_presenter.rb b/app/presenters/project_clusterable_presenter.rb
index 63e69b91b11..8661ee02b68 100644
--- a/app/presenters/project_clusterable_presenter.rb
+++ b/app/presenters/project_clusterable_presenter.rb
@@ -14,6 +14,11 @@ class ProjectClusterablePresenter < ClusterablePresenter
install_applications_project_cluster_path(clusterable, cluster, application)
end
+ override :update_applications_cluster_path
+ def update_applications_cluster_path(cluster, application)
+ update_applications_project_cluster_path(clusterable, cluster, application)
+ end
+
override :cluster_path
def cluster_path(cluster, params = {})
project_cluster_path(clusterable, cluster, params)
diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb
index 9bd64ea217e..9afbaf035c7 100644
--- a/app/presenters/project_presenter.rb
+++ b/app/presenters/project_presenter.rb
@@ -13,7 +13,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
presents :project
AnchorData = Struct.new(:is_link, :label, :link, :class_modifier, :icon)
- MAX_TAGS_TO_SHOW = 3
+ MAX_TOPICS_TO_SHOW = 3
def statistic_icon(icon_name = 'plus-square-o')
sprite_icon(icon_name, size: 16, css_class: 'icon append-right-4')
@@ -37,16 +37,12 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout),
kubernetes_cluster_anchor_data,
gitlab_ci_anchor_data
- ].compact.reject(&:is_link)
+ ].compact.reject(&:is_link).sort_by.with_index { |item, idx| [item.class_modifier ? 0 : 1, idx] }
end
def empty_repo_statistics_anchors
[
- license_anchor_data,
- commits_anchor_data,
- branches_anchor_data,
- tags_anchor_data,
- files_anchor_data
+ license_anchor_data
].compact.select { |item| item.is_link }
end
@@ -55,9 +51,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
new_file_anchor_data,
readme_anchor_data,
changelog_anchor_data,
- contribution_guide_anchor_data,
- autodevops_anchor_data,
- kubernetes_cluster_anchor_data
+ contribution_guide_anchor_data
].compact.reject { |item| item.is_link }
end
@@ -256,7 +250,8 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
elsif repository.contribution_guide.present?
AnchorData.new(false,
statistic_icon('doc-text') + _('CONTRIBUTING'),
- contribution_guide_path)
+ contribution_guide_path,
+ 'default')
end
end
@@ -264,7 +259,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
if current_user && can?(current_user, :admin_pipeline, project) && repository.gitlab_ci_yml.blank? && !show_auto_devops_callout
if auto_devops_enabled?
AnchorData.new(false,
- statistic_icon('doc-text') + _('Auto DevOps enabled'),
+ statistic_icon('settings') + _('Auto DevOps enabled'),
project_settings_ci_cd_path(project, anchor: 'autodevops-settings'),
'default')
else
@@ -310,20 +305,24 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated
end
end
- def tags_to_show
- project.tag_list.take(MAX_TAGS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
+ def topics_to_show
+ project.tag_list.take(MAX_TOPICS_TO_SHOW) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def topics_not_shown
+ project.tag_list - topics_to_show
end
- def count_of_extra_tags_not_shown
- if project.tag_list.count > MAX_TAGS_TO_SHOW
- project.tag_list.count - MAX_TAGS_TO_SHOW
+ def count_of_extra_topics_not_shown
+ if project.tag_list.count > MAX_TOPICS_TO_SHOW
+ project.tag_list.count - MAX_TOPICS_TO_SHOW
else
0
end
end
- def has_extra_tags?
- count_of_extra_tags_not_shown > 0
+ def has_extra_topics?
+ count_of_extra_topics_not_shown > 0
end
private
diff --git a/app/serializers/acts_as_taggable_on/tag_entity.rb b/app/serializers/acts_as_taggable_on/tag_entity.rb
new file mode 100644
index 00000000000..d4e4b69f8fa
--- /dev/null
+++ b/app/serializers/acts_as_taggable_on/tag_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class ActsAsTaggableOn::TagEntity < Grape::Entity
+ expose :id
+ expose :name
+end
diff --git a/app/serializers/acts_as_taggable_on/tag_serializer.rb b/app/serializers/acts_as_taggable_on/tag_serializer.rb
new file mode 100644
index 00000000000..87f53606aa1
--- /dev/null
+++ b/app/serializers/acts_as_taggable_on/tag_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ActsAsTaggableOn::TagSerializer < BaseSerializer
+ entity ActsAsTaggableOn::TagEntity
+end
diff --git a/app/serializers/analytics_stage_entity.rb b/app/serializers/analytics_stage_entity.rb
index ae7c20c3bba..8bc6da5aeeb 100644
--- a/app/serializers/analytics_stage_entity.rb
+++ b/app/serializers/analytics_stage_entity.rb
@@ -9,7 +9,8 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
- # median returns a BatchLoader instance which we first have to unwrap by using to_i
- !stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
+ # median returns a BatchLoader instance which we first have to unwrap by using to_f
+ # we use to_f to make sure results below 1 are presented to the end-user
+ stage.median.to_f.nonzero? ? distance_of_time_in_words(stage.median) : nil
end
end
diff --git a/app/serializers/base_serializer.rb b/app/serializers/base_serializer.rb
index 7b65bd22f54..4744a7c1cc8 100644
--- a/app/serializers/base_serializer.rb
+++ b/app/serializers/base_serializer.rb
@@ -16,11 +16,11 @@ class BaseSerializer
.as_json
end
- def self.entity(entity_class)
- @entity_class ||= entity_class
- end
+ class << self
+ attr_reader :entity_class
- def self.entity_class
- @entity_class
+ def entity(entity_class)
+ @entity_class ||= entity_class
+ end
end
end
diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb
index 9ddce0d2c80..6928968edc0 100644
--- a/app/serializers/build_details_entity.rb
+++ b/app/serializers/build_details_entity.rb
@@ -8,16 +8,18 @@ class BuildDetailsEntity < JobEntity
expose :stuck?, as: :stuck
expose :user, using: UserEntity
expose :runner, using: RunnerEntity
+ expose :metadata, using: BuildMetadataEntity
expose :pipeline, using: PipelineEntity
expose :deployment_status, if: -> (*) { build.starts_environment? } do
expose :deployment_status, as: :status
-
- expose :persisted_environment, as: :environment, with: EnvironmentEntity
+ expose :persisted_environment, as: :environment do |build, options|
+ options.merge(deployment_details: false).yield_self do |opts|
+ EnvironmentEntity.represent(build.persisted_environment, opts)
+ end
+ end
end
- expose :metadata, using: BuildMetadataEntity
-
expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do
expose :download_path, if: -> (*) { build.artifacts? } do |build|
download_project_job_artifacts_path(project, build)
@@ -45,6 +47,8 @@ class BuildDetailsEntity < JobEntity
erase_project_job_path(project, build)
end
+ expose :failure_reason, if: -> (*) { build.failed? }
+
expose :terminal_path, if: -> (*) { can_create_build_terminal? } do |build|
terminal_project_job_path(project, build)
end
diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb
index 7b1a0be75ca..2a916b13f52 100644
--- a/app/serializers/cluster_application_entity.rb
+++ b/app/serializers/cluster_application_entity.rb
@@ -4,7 +4,11 @@ class ClusterApplicationEntity < Grape::Entity
expose :name
expose :status_name, as: :status
expose :status_reason
+ expose :version
expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) }
+ expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) }
expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) }
expose :email, if: -> (e, _) { e.respond_to?(:email) }
+ expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) }
+ expose :can_uninstall?, as: :can_uninstall
end
diff --git a/app/serializers/concerns/user_status_tooltip.rb b/app/serializers/concerns/user_status_tooltip.rb
index aa6e67e3351..a81e377691e 100644
--- a/app/serializers/concerns/user_status_tooltip.rb
+++ b/app/serializers/concerns/user_status_tooltip.rb
@@ -3,7 +3,7 @@
module UserStatusTooltip
extend ActiveSupport::Concern
include ActionView::Helpers::TagHelper
- include ActionView::Context
+ include ::Gitlab::ActionViewOutput::Context
include EmojiHelper
include UsersHelper
@@ -11,7 +11,7 @@ module UserStatusTooltip
expose :user_status_if_loaded, as: :status_tooltip_html
def user_status_if_loaded
- return nil unless object.association(:status).loaded?
+ return unless object.association(:status).loaded?
user_status(object)
end
diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb
index 59bf35f5aff..cc746698a05 100644
--- a/app/serializers/container_repository_entity.rb
+++ b/app/serializers/container_repository_entity.rb
@@ -3,7 +3,7 @@
class ContainerRepositoryEntity < Grape::Entity
include RequestAwareEntity
- expose :id, :path, :location
+ expose :id, :name, :path, :location, :created_at
expose :tags_path do |repository|
project_registry_repository_tags_path(project, repository, format: :json)
diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb
index 637294877f8..361c073e22e 100644
--- a/app/serializers/container_tag_entity.rb
+++ b/app/serializers/container_tag_entity.rb
@@ -3,7 +3,7 @@
class ContainerTagEntity < Grape::Entity
include RequestAwareEntity
- expose :name, :location, :revision, :short_revision, :total_size, :created_at
+ expose :name, :path, :location, :digest, :revision, :short_revision, :total_size, :created_at
expose :destroy_path, if: -> (*) { can_destroy? } do |tag|
project_registry_repository_tag_path(project, tag.repository, tag.name)
diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb
index aa1d9e6292c..943c707218d 100644
--- a/app/serializers/deployment_entity.rb
+++ b/app/serializers/deployment_entity.rb
@@ -20,10 +20,39 @@ class DeploymentEntity < Grape::Entity
expose :created_at
expose :tag
expose :last?
-
expose :user, using: UserEntity
- expose :commit, using: CommitEntity
- expose :deployable, using: JobEntity
- expose :manual_actions, using: JobEntity
- expose :scheduled_actions, using: JobEntity
+
+ expose :deployable do |deployment, opts|
+ deployment.deployable.yield_self do |deployable|
+ if include_details?
+ JobEntity.represent(deployable, opts)
+ elsif can_read_deployables?
+ { name: deployable.name,
+ build_path: project_job_path(deployable.project, deployable) }
+ end
+ end
+ end
+
+ expose :commit, using: CommitEntity, if: -> (*) { include_details? }
+ expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
+ expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
+
+ private
+
+ def include_details?
+ options.fetch(:deployment_details, true)
+ end
+
+ def can_create_deployment?
+ can?(request.current_user, :create_deployment, request.project)
+ end
+
+ def can_read_deployables?
+ ##
+ # We intentionally do not check `:read_build, deployment.deployable`
+ # because it triggers a policy evaluation that involves multiple
+ # Gitaly calls that might not be cached.
+ #
+ can?(request.current_user, :read_build, request.project)
+ end
end
diff --git a/app/serializers/detailed_status_entity.rb b/app/serializers/detailed_status_entity.rb
index da994d78286..4f23ef0ed82 100644
--- a/app/serializers/detailed_status_entity.rb
+++ b/app/serializers/detailed_status_entity.rb
@@ -9,16 +9,14 @@ class DetailedStatusEntity < Grape::Entity
expose :details_path
expose :illustration do |status|
- begin
- illustration = {
- image: ActionController::Base.helpers.image_path(status.illustration[:image])
- }
- illustration = status.illustration.merge(illustration)
+ illustration = {
+ image: ActionController::Base.helpers.image_path(status.illustration[:image])
+ }
+ illustration = status.illustration.merge(illustration)
- illustration
- rescue NotImplementedError
- # ignored
- end
+ illustration
+ rescue NotImplementedError
+ # ignored
end
expose :favicon do |status|
diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb
index 06a8db78476..d8630165e49 100644
--- a/app/serializers/diff_file_base_entity.rb
+++ b/app/serializers/diff_file_base_entity.rb
@@ -27,9 +27,13 @@ class DiffFileBaseEntity < Grape::Entity
next unless merge_request.source_project
- project_edit_blob_path(merge_request.source_project,
- tree_join(merge_request.source_branch, diff_file.new_path),
- options)
+ if Feature.enabled?(:web_ide_default)
+ ide_edit_path(merge_request.source_project, merge_request.source_branch, diff_file.new_path)
+ else
+ project_edit_blob_path(merge_request.source_project,
+ tree_join(merge_request.source_branch, diff_file.new_path),
+ options)
+ end
end
expose :old_path_html do |diff_file|
@@ -72,17 +76,20 @@ class DiffFileBaseEntity < Grape::Entity
expose :old_path
expose :new_path
expose :new_file?, as: :new_file
- expose :collapsed?, as: :collapsed
- expose :text?, as: :text
+ expose :renamed_file?, as: :renamed_file
+ expose :deleted_file?, as: :deleted_file
+
expose :diff_refs
+
expose :stored_externally?, as: :stored_externally
expose :external_storage
- expose :renamed_file?, as: :renamed_file
- expose :deleted_file?, as: :deleted_file
+
expose :mode_changed?, as: :mode_changed
expose :a_mode
expose :b_mode
+ expose :viewer, using: DiffViewerEntity
+
private
def memoized_submodule_links(diff_file)
diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb
index b0aaec3326d..2a5121a2266 100644
--- a/app/serializers/diff_file_entity.rb
+++ b/app/serializers/diff_file_entity.rb
@@ -4,12 +4,10 @@ class DiffFileEntity < DiffFileBaseEntity
include CommitsHelper
include IconsHelper
- expose :too_large?, as: :too_large
- expose :empty?, as: :empty
expose :added_lines
expose :removed_lines
- expose :load_collapsed_diff_url, if: -> (diff_file, options) { diff_file.text? && options[:merge_request] } do |diff_file|
+ expose :load_collapsed_diff_url, if: -> (diff_file, options) { options[:merge_request] } do |diff_file|
merge_request = options[:merge_request]
project = merge_request.target_project
@@ -36,10 +34,6 @@ class DiffFileEntity < DiffFileBaseEntity
project_blob_path(project, tree_join(diff_file.content_sha, diff_file.new_path))
end
- expose :viewer, using: DiffViewerEntity do |diff_file|
- diff_file.rich_viewer || diff_file.simple_viewer
- end
-
expose :replaced_view_path, if: -> (_, options) { options[:merge_request] } do |diff_file|
image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image'
image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha
@@ -63,6 +57,10 @@ class DiffFileEntity < DiffFileBaseEntity
diff_file.diff_lines_for_serializer
end
+ expose :is_fully_expanded do |diff_file|
+ diff_file.fully_expanded?
+ end
+
# Used for parallel diffs
expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? }
end
diff --git a/app/serializers/diff_viewer_entity.rb b/app/serializers/diff_viewer_entity.rb
index 27fba03cb3f..45faca6cb2f 100644
--- a/app/serializers/diff_viewer_entity.rb
+++ b/app/serializers/diff_viewer_entity.rb
@@ -1,7 +1,8 @@
# frozen_string_literal: true
class DiffViewerEntity < Grape::Entity
- # Partial name refers directly to a Rails feature, let's avoid
- # using this on the frontend.
expose :partial_name, as: :name
+ expose :render_error, as: :error
+ expose :render_error_message, as: :error_message
+ expose :collapsed?, as: :collapsed
end
diff --git a/app/serializers/entity_date_helper.rb b/app/serializers/entity_date_helper.rb
index cc0c2abf863..f515abe5917 100644
--- a/app/serializers/entity_date_helper.rb
+++ b/app/serializers/entity_date_helper.rb
@@ -44,14 +44,14 @@ module EntityDateHelper
# It returns "Upcoming" for upcoming entities
# If due date is provided, it returns "# days|weeks|months remaining|ago"
# If start date is provided and elapsed, with no due date, it returns "# days elapsed"
- def remaining_days_in_words(entity)
- if entity.try(:expired?)
+ def remaining_days_in_words(due_date, start_date = nil)
+ if due_date&.past?
content_tag(:strong, 'Past due')
- elsif entity.try(:upcoming?)
+ elsif start_date&.future?
content_tag(:strong, 'Upcoming')
- elsif entity.due_date
- is_upcoming = (entity.due_date - Date.today).to_i > 0
- time_ago = time_ago_in_words(entity.due_date)
+ elsif due_date
+ is_upcoming = (due_date - Date.today).to_i > 0
+ time_ago = time_ago_in_words(due_date)
# https://gitlab.com/gitlab-org/gitlab-ce/issues/49440
#
@@ -63,8 +63,8 @@ module EntityDateHelper
remaining_or_ago = is_upcoming ? _("remaining") : _("ago")
"#{content} #{remaining_or_ago}".html_safe
- elsif entity.start_date && entity.start_date.past?
- days = entity.elapsed_days
+ elsif start_date&.past?
+ days = (Date.today - start_date).to_i
"#{content_tag(:strong, days)} #{'day'.pluralize(days)} elapsed".html_safe
end
end
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 07a13c33b89..8258135da4e 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -8,10 +8,11 @@ class EnvironmentEntity < Grape::Entity
expose :state
expose :external_url
expose :environment_type
+ expose :name_without_type
expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action
- expose :metrics_path, if: -> (environment, _) { environment.has_metrics? } do |environment|
+ expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment|
metrics_project_environment_path(environment.project, environment)
end
@@ -23,6 +24,10 @@ class EnvironmentEntity < Grape::Entity
stop_project_environment_path(environment.project, environment)
end
+ expose :cluster_type, if: ->(environment, _) { cluster_platform_kubernetes? } do |environment|
+ cluster.cluster_type
+ end
+
expose :terminal_path, if: ->(*) { environment.has_terminals? && can_access_terminal? } do |environment|
terminal_project_environment_path(environment.project, environment)
end
@@ -48,4 +53,16 @@ class EnvironmentEntity < Grape::Entity
def can_access_terminal?
can?(request.current_user, :create_environment_terminal, environment)
end
+
+ def cluster_platform_kubernetes?
+ deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes)
+ end
+
+ def deployment_platform
+ environment.deployment_platform
+ end
+
+ def cluster
+ deployment_platform.cluster
+ end
end
diff --git a/app/serializers/error_tracking/error_entity.rb b/app/serializers/error_tracking/error_entity.rb
new file mode 100644
index 00000000000..91388e7c3ad
--- /dev/null
+++ b/app/serializers/error_tracking/error_entity.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ErrorEntity < Grape::Entity
+ expose :id, :title, :type, :user_count, :count,
+ :first_seen, :last_seen, :message, :culprit,
+ :external_url, :project_id, :project_name, :project_slug,
+ :short_id, :status, :frequency
+ end
+end
diff --git a/app/serializers/error_tracking/error_serializer.rb b/app/serializers/error_tracking/error_serializer.rb
new file mode 100644
index 00000000000..ff9a645eb16
--- /dev/null
+++ b/app/serializers/error_tracking/error_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ErrorSerializer < BaseSerializer
+ entity ErrorEntity
+ end
+end
diff --git a/app/serializers/error_tracking/project_entity.rb b/app/serializers/error_tracking/project_entity.rb
new file mode 100644
index 00000000000..405d87ca0d0
--- /dev/null
+++ b/app/serializers/error_tracking/project_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ProjectEntity < Grape::Entity
+ expose(*Gitlab::ErrorTracking::Project::ACCESSORS)
+ end
+end
diff --git a/app/serializers/error_tracking/project_serializer.rb b/app/serializers/error_tracking/project_serializer.rb
new file mode 100644
index 00000000000..68724088fff
--- /dev/null
+++ b/app/serializers/error_tracking/project_serializer.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ProjectSerializer < BaseSerializer
+ entity ErrorTracking::ProjectEntity
+ end
+end
diff --git a/app/serializers/group_variable_entity.rb b/app/serializers/group_variable_entity.rb
index 0edab4a3092..19c5fa26f34 100644
--- a/app/serializers/group_variable_entity.rb
+++ b/app/serializers/group_variable_entity.rb
@@ -6,4 +6,5 @@ class GroupVariableEntity < Grape::Entity
expose :value
expose :protected?, as: :protected
+ expose :masked?, as: :masked
end
diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb
new file mode 100644
index 00000000000..61de3c93337
--- /dev/null
+++ b/app/serializers/issuable_sidebar_basic_entity.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class IssuableSidebarBasicEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :id
+ expose :type do |issuable|
+ issuable.to_ability_name
+ end
+ expose :author_id
+ expose :project_id do |issuable|
+ issuable.project.id
+ end
+ expose :discussion_locked
+ expose :reference do |issuable|
+ issuable.to_reference(issuable.project, full: true)
+ end
+
+ expose :milestone, using: ::API::Entities::Milestone
+ expose :labels, using: LabelEntity
+
+ expose :current_user, if: lambda { |_issuable| current_user } do
+ expose :current_user, merge: true, using: API::Entities::UserBasic
+
+ expose :todo, using: IssuableSidebarTodoEntity do |issuable|
+ current_user.pending_todo_for(issuable)
+ end
+
+ expose :can_edit do |issuable|
+ can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
+ end
+
+ expose :can_move do |issuable|
+ issuable.can_move?(current_user)
+ end
+
+ expose :can_admin_label do |issuable|
+ can?(current_user, :admin_label, issuable.project)
+ end
+ end
+
+ expose :issuable_json_path do |issuable|
+ if issuable.is_a?(MergeRequest)
+ project_merge_request_path(issuable.project, issuable.iid, :json)
+ else
+ project_issue_path(issuable.project, issuable.iid, :json)
+ end
+ end
+
+ expose :namespace_path do |issuable|
+ issuable.project.namespace.full_path
+ end
+
+ expose :project_path do |issuable|
+ issuable.project.path
+ end
+
+ expose :project_full_path do |issuable|
+ issuable.project.full_path
+ end
+
+ expose :project_issuables_path do |issuable|
+ project = issuable.project
+ namespace = project.namespace
+
+ if issuable.is_a?(MergeRequest)
+ namespace_project_merge_requests_path(namespace, project)
+ else
+ namespace_project_issues_path(namespace, project)
+ end
+ end
+
+ expose :create_todo_path do |issuable|
+ project_todos_path(issuable.project)
+ end
+
+ expose :project_milestones_path do |issuable|
+ project_milestones_path(issuable.project, :json)
+ end
+
+ expose :project_labels_path do |issuable|
+ project_labels_path(issuable.project, :json, include_ancestor_groups: true)
+ end
+
+ expose :toggle_subscription_path do |issuable|
+ toggle_subscription_path(issuable)
+ end
+
+ expose :move_issue_path do |issuable|
+ move_namespace_project_issue_path(
+ namespace_id: issuable.project.namespace.to_param,
+ project_id: issuable.project,
+ id: issuable
+ )
+ end
+
+ expose :projects_autocomplete_path do |issuable|
+ autocomplete_projects_path(project_id: issuable.project.id)
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/serializers/issuable_sidebar_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb
index 773d78d324c..fb35b7522c5 100644
--- a/app/serializers/issuable_sidebar_entity.rb
+++ b/app/serializers/issuable_sidebar_extras_entity.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
-class IssuableSidebarEntity < Grape::Entity
- include TimeTrackableEntity
+class IssuableSidebarExtrasEntity < Grape::Entity
include RequestAwareEntity
+ include TimeTrackableEntity
expose :participants, using: ::API::Entities::UserBasic do |issuable|
issuable.participants(request.current_user)
@@ -11,4 +11,6 @@ class IssuableSidebarEntity < Grape::Entity
expose :subscribed do |issuable|
issuable.subscribed?(request.current_user, issuable.project)
end
+
+ expose :assignees, using: API::Entities::UserBasic
end
diff --git a/app/serializers/issuable_sidebar_todo_entity.rb b/app/serializers/issuable_sidebar_todo_entity.rb
new file mode 100644
index 00000000000..b2c98433f05
--- /dev/null
+++ b/app/serializers/issuable_sidebar_todo_entity.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class IssuableSidebarTodoEntity < Grape::Entity
+ include Gitlab::Routing
+
+ expose :id
+
+ expose :delete_path do |todo|
+ dashboard_todo_path(todo) if todo
+ end
+end
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
index e3dc43240c6..2e1d7fb3f87 100644
--- a/app/serializers/issue_board_entity.rb
+++ b/app/serializers/issue_board_entity.rb
@@ -2,6 +2,7 @@
class IssueBoardEntity < Grape::Entity
include RequestAwareEntity
+ include TimeTrackableEntity
expose :id
expose :iid
@@ -11,6 +12,7 @@ class IssueBoardEntity < Grape::Entity
expose :due_date
expose :project_id
expose :relative_position
+ expose :time_estimate
expose :project do |issue|
API::Entities::Project.represent issue.project, only: [:id, :path]
@@ -37,7 +39,7 @@ class IssueBoardEntity < Grape::Entity
end
expose :issue_sidebar_endpoint, if: -> (issue) { issue.project } do |issue|
- project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar')
+ project_issue_path(issue.project, issue, format: :json, serializer: 'sidebar_extras')
end
expose :toggle_subscription_endpoint, if: -> (issue) { issue.project } do |issue|
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c3f7d4651fb..914ad628a99 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -42,6 +42,6 @@ class IssueEntity < IssuableEntity
end
expose :preview_note_path do |issue|
- preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid)
+ preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid)
end
end
diff --git a/app/serializers/issue_serializer.rb b/app/serializers/issue_serializer.rb
index d66f0a5acb7..0fa76f098cd 100644
--- a/app/serializers/issue_serializer.rb
+++ b/app/serializers/issue_serializer.rb
@@ -2,13 +2,15 @@
class IssueSerializer < BaseSerializer
# This overrided method takes care of which entity should be used
- # to serialize the `issue` based on `basic` key in `opts` param.
+ # to serialize the `issue` based on `serializer` key in `opts` param.
# Hence, `entity` doesn't need to be declared on the class scope.
def represent(issue, opts = {})
entity =
case opts[:serializer]
when 'sidebar'
- IssueSidebarEntity
+ IssueSidebarBasicEntity
+ when 'sidebar_extras'
+ IssueSidebarExtrasEntity
when 'board'
IssueBoardEntity
else
diff --git a/app/serializers/issue_sidebar_basic_entity.rb b/app/serializers/issue_sidebar_basic_entity.rb
new file mode 100644
index 00000000000..723875809ec
--- /dev/null
+++ b/app/serializers/issue_sidebar_basic_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class IssueSidebarBasicEntity < IssuableSidebarBasicEntity
+ expose :due_date
+ expose :confidential
+end
diff --git a/app/serializers/issue_sidebar_entity.rb b/app/serializers/issue_sidebar_entity.rb
deleted file mode 100644
index 349ad9d1fef..00000000000
--- a/app/serializers/issue_sidebar_entity.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class IssueSidebarEntity < IssuableSidebarEntity
- expose :assignees, using: API::Entities::UserBasic
-end
diff --git a/app/serializers/issue_sidebar_extras_entity.rb b/app/serializers/issue_sidebar_extras_entity.rb
new file mode 100644
index 00000000000..dee891a50b7
--- /dev/null
+++ b/app/serializers/issue_sidebar_extras_entity.rb
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+class IssueSidebarExtrasEntity < IssuableSidebarExtrasEntity
+end
diff --git a/app/serializers/merge_request_assignee_entity.rb b/app/serializers/merge_request_assignee_entity.rb
new file mode 100644
index 00000000000..6849c62e759
--- /dev/null
+++ b/app/serializers/merge_request_assignee_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestAssigneeEntity < ::API::Entities::UserBasic
+ expose :can_merge do |assignee, options|
+ options[:merge_request]&.can_be_merged_by?(assignee)
+ end
+end
diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb
index f7eb74cf392..973e971b4c0 100644
--- a/app/serializers/merge_request_basic_entity.rb
+++ b/app/serializers/merge_request_basic_entity.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
-class MergeRequestBasicEntity < IssuableSidebarEntity
- expose :assignee_id
+class MergeRequestBasicEntity < Grape::Entity
expose :merge_status
expose :merge_error
expose :state
@@ -9,6 +8,7 @@ class MergeRequestBasicEntity < IssuableSidebarEntity
expose :rebase_in_progress?, as: :rebase_in_progress
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
- expose :assignee, using: API::Entities::UserBasic
+ expose :assignees, using: API::Entities::UserBasic
expose :task_status, :task_status_short
+ expose :lock_version, :lock_version
end
diff --git a/app/serializers/merge_request_basic_serializer.rb b/app/serializers/merge_request_basic_serializer.rb
deleted file mode 100644
index a68b48b00db..00000000000
--- a/app/serializers/merge_request_basic_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class MergeRequestBasicSerializer < BaseSerializer
- entity MergeRequestBasicEntity
-end
diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb
index 433bfe60474..7e3053e5881 100644
--- a/app/serializers/merge_request_diff_entity.rb
+++ b/app/serializers/merge_request_diff_entity.rb
@@ -24,6 +24,14 @@ class MergeRequestDiffEntity < Grape::Entity
short_sha(merge_request_diff.head_commit_sha)
end
+ expose :base_version_path do |merge_request_diff|
+ project = merge_request.target_project
+
+ next unless project
+
+ merge_request_version_path(project, merge_request, merge_request_diff)
+ end
+
expose :version_path do |merge_request_diff|
start_sha = options[:start_sha]
project = merge_request.target_project
diff --git a/app/serializers/merge_request_for_pipeline_entity.rb b/app/serializers/merge_request_for_pipeline_entity.rb
new file mode 100644
index 00000000000..17a5c4ebbf9
--- /dev/null
+++ b/app/serializers/merge_request_for_pipeline_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class MergeRequestForPipelineEntity < Grape::Entity
+ include RequestAwareEntity
+
+ expose :iid
+
+ expose :path do |merge_request|
+ project_merge_request_path(merge_request.project, merge_request)
+ end
+
+ expose :title
+ expose :source_branch
+ expose :source_branch_commits_path, as: :source_branch_path
+ expose :target_branch
+ expose :target_branch_commits_path, as: :target_branch_path
+end
diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb
index 1f8c830e1aa..6f589351670 100644
--- a/app/serializers/merge_request_serializer.rb
+++ b/app/serializers/merge_request_serializer.rb
@@ -7,9 +7,14 @@ class MergeRequestSerializer < BaseSerializer
def represent(merge_request, opts = {})
entity =
case opts[:serializer]
- when 'basic', 'sidebar'
+ when 'sidebar'
+ IssuableSidebarBasicEntity
+ when 'sidebar_extras'
+ MergeRequestSidebarExtrasEntity
+ when 'basic'
MergeRequestBasicEntity
- else # It's 'widget'
+ else
+ # fallback to widget for old poll requests without `serializer` set
MergeRequestWidgetEntity
end
diff --git a/app/serializers/merge_request_sidebar_extras_entity.rb b/app/serializers/merge_request_sidebar_extras_entity.rb
new file mode 100644
index 00000000000..7276509c363
--- /dev/null
+++ b/app/serializers/merge_request_sidebar_extras_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestSidebarExtrasEntity < IssuableSidebarExtrasEntity
+ expose :assignees do |merge_request|
+ MergeRequestAssigneeEntity.represent(merge_request.assignees, merge_request: merge_request)
+ end
+end
diff --git a/app/serializers/merge_request_widget_commit_entity.rb b/app/serializers/merge_request_widget_commit_entity.rb
new file mode 100644
index 00000000000..50a5c44a6ad
--- /dev/null
+++ b/app/serializers/merge_request_widget_commit_entity.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class MergeRequestWidgetCommitEntity < Grape::Entity
+ expose :safe_message, as: :message
+ expose :short_id
+ expose :title
+end
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index 9731b52f1ad..b130f447cce 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -11,12 +11,16 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :merge_user_id
expose :merge_when_pipeline_succeeds
expose :source_branch
+ expose :source_branch_protected do |merge_request|
+ merge_request.source_project.present? && ProtectedBranch.protected?(merge_request.source_project, merge_request.source_branch)
+ end
expose :source_project_id
expose :source_project_full_path do |merge_request|
merge_request.source_project&.full_path
end
expose :squash
expose :target_branch
+ expose :target_branch_sha
expose :target_project_id
expose :target_project_full_path do |merge_request|
merge_request.project&.full_path
@@ -53,10 +57,23 @@ class MergeRequestWidgetEntity < IssuableEntity
merge_request.diff_head_sha.presence
end
- expose :merge_commit_message
- expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline
+ expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? }
+
expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)}
+ expose :default_squash_commit_message
+ expose :default_merge_commit_message
+
+ expose :default_merge_commit_message_with_description do |merge_request|
+ merge_request.default_merge_commit_message(include_description: true)
+ end
+
+ expose :commits_without_merge_commits, using: MergeRequestWidgetCommitEntity do |merge_request|
+ merge_request.commits.without_merge_commits
+ end
+
+ expose :commits_count
+
# Booleans
expose :merge_ongoing?, as: :merge_ongoing
expose :work_in_progress?, as: :work_in_progress
@@ -74,7 +91,6 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :branch_missing?, as: :branch_missing
- expose :commits_count
expose :cannot_be_merged?, as: :has_conflicts
expose :can_be_merged?, as: :can_be_merged
expose :mergeable?, as: :mergeable
@@ -202,10 +218,6 @@ class MergeRequestWidgetEntity < IssuableEntity
ci_environments_status_project_merge_request_path(merge_request.project, merge_request)
end
- expose :merge_commit_message_with_description do |merge_request|
- merge_request.merge_commit_message(include_description: true)
- end
-
expose :diverged_commits_count do |merge_request|
if merge_request.open? && merge_request.diverged_from_target_branch?
merge_request.diverged_commits_count
@@ -223,7 +235,7 @@ class MergeRequestWidgetEntity < IssuableEntity
end
expose :preview_note_path do |merge_request|
- preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid)
+ preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid)
end
expose :merge_commit_path do |merge_request|
@@ -240,6 +252,14 @@ class MergeRequestWidgetEntity < IssuableEntity
expose :supports_suggestion?, as: :can_receive_suggestion
+ expose :conflicts_docs_path do |merge_request|
+ presenter(merge_request).conflicts_docs_path
+ end
+
+ expose :merge_request_pipelines_docs_path do |merge_request|
+ presenter(merge_request).merge_request_pipelines_docs_path
+ end
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/namespace_basic_entity.rb b/app/serializers/namespace_basic_entity.rb
new file mode 100644
index 00000000000..8bcbb2bca60
--- /dev/null
+++ b/app/serializers/namespace_basic_entity.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class NamespaceBasicEntity < Grape::Entity
+ expose :id
+ expose :full_path
+end
diff --git a/app/serializers/namespace_serializer.rb b/app/serializers/namespace_serializer.rb
new file mode 100644
index 00000000000..bf3f154b558
--- /dev/null
+++ b/app/serializers/namespace_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class NamespaceSerializer < BaseSerializer
+ entity NamespaceBasicEntity
+end
diff --git a/app/serializers/pipeline_details_entity.rb b/app/serializers/pipeline_details_entity.rb
index d78ad4af4dc..dfef4364965 100644
--- a/app/serializers/pipeline_details_entity.rb
+++ b/app/serializers/pipeline_details_entity.rb
@@ -1,8 +1,11 @@
# frozen_string_literal: true
class PipelineDetailsEntity < PipelineEntity
+ expose :flags do
+ expose :latest?, as: :latest
+ end
+
expose :details do
- expose :ordered_stages, as: :stages, using: StageEntity
expose :artifacts, using: BuildArtifactEntity
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb
index c9669e59199..9ef93b2387f 100644
--- a/app/serializers/pipeline_entity.rb
+++ b/app/serializers/pipeline_entity.rb
@@ -20,22 +20,28 @@ class PipelineEntity < Grape::Entity
end
expose :flags do
- expose :latest?, as: :latest
expose :stuck?, as: :stuck
expose :auto_devops_source?, as: :auto_devops
- expose :merge_request?, as: :merge_request
+ expose :merge_request_event?, as: :merge_request
expose :has_yaml_errors?, as: :yaml_errors
expose :can_retry?, as: :retryable
expose :can_cancel?, as: :cancelable
expose :failure_reason?, as: :failure_reason
+ expose :detached_merge_request_pipeline?, as: :detached_merge_request_pipeline
+ expose :merge_request_pipeline?, as: :merge_request_pipeline
end
expose :details do
expose :detailed_status, as: :status, with: DetailedStatusEntity
+ expose :ordered_stages, as: :stages, using: StageEntity
expose :duration
expose :finished_at
end
+ expose :merge_request, if: -> (*) { has_presentable_merge_request? }, with: MergeRequestForPipelineEntity do |pipeline|
+ pipeline.merge_request.present(current_user: request.current_user)
+ end
+
expose :ref do
expose :name do |pipeline|
pipeline.ref
@@ -49,17 +55,19 @@ class PipelineEntity < Grape::Entity
expose :tag?, as: :tag
expose :branch?, as: :branch
- expose :merge_request?, as: :merge_request
+ expose :merge_request_event?, as: :merge_request
end
expose :commit, using: CommitEntity
+ expose :source_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
+ expose :target_sha, if: -> (pipeline, _) { pipeline.merge_request_pipeline? }
expose :yaml_errors, if: -> (pipeline, _) { pipeline.has_yaml_errors? }
expose :failure_reason, if: -> (pipeline, _) { pipeline.failure_reason? } do |pipeline|
pipeline.present.failure_reason
end
- expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
+ expose :retry_path, if: -> (*) { can_retry? } do |pipeline|
retry_project_pipeline_path(pipeline.project, pipeline)
end
@@ -81,6 +89,11 @@ class PipelineEntity < Grape::Entity
pipeline.cancelable?
end
+ def has_presentable_merge_request?
+ pipeline.triggered_by_merge_request? &&
+ can?(request.current_user, :read_merge_request, pipeline.merge_request)
+ end
+
def detailed_status
pipeline.detailed_status(request.current_user)
end
diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb
index 7451433a841..95d73c6422d 100644
--- a/app/serializers/pipeline_serializer.rb
+++ b/app/serializers/pipeline_serializer.rb
@@ -7,22 +7,7 @@ class PipelineSerializer < BaseSerializer
# rubocop: disable CodeReuse/ActiveRecord
def represent(resource, opts = {})
if resource.is_a?(ActiveRecord::Relation)
- resource = resource.preload([
- :stages,
- :retryable_builds,
- :cancelable_statuses,
- :trigger_requests,
- :manual_actions,
- :scheduled_actions,
- :artifacts,
- {
- pending_builds: :project,
- project: [:route, { namespace: :route }],
- artifacts: {
- project: [:route, { namespace: :route }]
- }
- }
- ])
+ resource = resource.preload(preloaded_relations)
end
if paginated?
@@ -50,4 +35,26 @@ class PipelineSerializer < BaseSerializer
data = represent(resource, { only: [{ details: [:stages] }], preload: true })
data.dig(:details, :stages) || []
end
+
+ private
+
+ def preloaded_relations
+ [
+ :stages,
+ :retryable_builds,
+ :cancelable_statuses,
+ :trigger_requests,
+ :manual_actions,
+ :scheduled_actions,
+ :artifacts,
+ :merge_request,
+ {
+ pending_builds: :project,
+ project: [:route, { namespace: :route }],
+ artifacts: {
+ project: [:route, { namespace: :route }]
+ }
+ }
+ ]
+ end
end
diff --git a/app/serializers/project_import_entity.rb b/app/serializers/project_import_entity.rb
new file mode 100644
index 00000000000..9b51af685e7
--- /dev/null
+++ b/app/serializers/project_import_entity.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectImportEntity < ProjectEntity
+ include ImportHelper
+
+ expose :import_source
+ expose :import_status
+ expose :human_import_status_name
+
+ expose :provider_link do |project, options|
+ provider_project_link_url(options[:provider_url], project[:import_source])
+ end
+end
diff --git a/app/serializers/project_serializer.rb b/app/serializers/project_serializer.rb
index 23b96c2fc9e..52ac2fa0e09 100644
--- a/app/serializers/project_serializer.rb
+++ b/app/serializers/project_serializer.rb
@@ -1,5 +1,15 @@
# frozen_string_literal: true
class ProjectSerializer < BaseSerializer
- entity ProjectEntity
+ def represent(project, opts = {})
+ entity =
+ case opts[:serializer]
+ when :import
+ ProjectImportEntity
+ else
+ ProjectEntity
+ end
+
+ super(project, opts, entity)
+ end
end
diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb
index 4f1f62d145b..a46f8af1466 100644
--- a/app/serializers/projects/serverless/service_entity.rb
+++ b/app/serializers/projects/serverless/service_entity.rb
@@ -13,6 +13,32 @@ module Projects
service.dig('metadata', 'namespace')
end
+ expose :environment_scope do |service|
+ service.dig('environment_scope')
+ end
+
+ expose :cluster_id do |service|
+ service.dig('cluster_id')
+ end
+
+ expose :detail_url do |service|
+ project_serverless_path(
+ request.project,
+ service.dig('environment_scope'),
+ service.dig('metadata', 'name'))
+ end
+
+ expose :podcount do |service|
+ service.dig('podcount')
+ end
+
+ expose :metrics_url do |service|
+ project_serverless_metrics_path(
+ request.project,
+ service.dig('environment_scope'),
+ service.dig('metadata', 'name')) + ".json"
+ end
+
expose :created_at do |service|
service.dig('metadata', 'creationTimestamp')
end
@@ -22,11 +48,24 @@ module Projects
end
expose :description do |service|
- service.dig('spec', 'runLatest', 'configuration', 'revisionTemplate', 'metadata', 'annotations', 'Description')
+ service.dig(
+ 'spec',
+ 'runLatest',
+ 'configuration',
+ 'revisionTemplate',
+ 'metadata',
+ 'annotations',
+ 'Description')
end
expose :image do |service|
- service.dig('spec', 'runLatest', 'configuration', 'build', 'template', 'name')
+ service.dig(
+ 'spec',
+ 'runLatest',
+ 'configuration',
+ 'build',
+ 'template',
+ 'name')
end
end
end
diff --git a/app/serializers/provider_repo_entity.rb b/app/serializers/provider_repo_entity.rb
new file mode 100644
index 00000000000..d70aaa91324
--- /dev/null
+++ b/app/serializers/provider_repo_entity.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ProviderRepoEntity < Grape::Entity
+ include ImportHelper
+
+ expose :id
+ expose :full_name
+ expose :owner_name do |provider_repo, options|
+ owner_name(provider_repo, options[:provider])
+ end
+
+ expose :sanitized_name do |provider_repo|
+ sanitize_project_name(provider_repo[:name])
+ end
+
+ expose :provider_link do |provider_repo, options|
+ provider_project_link_url(options[:provider_url], provider_repo[:full_name])
+ end
+
+ private
+
+ def owner_name(provider_repo, provider)
+ provider_repo.dig(:owner, :login) if provider == :github
+ end
+end
diff --git a/app/serializers/provider_repo_serializer.rb b/app/serializers/provider_repo_serializer.rb
new file mode 100644
index 00000000000..8a73f6fe6df
--- /dev/null
+++ b/app/serializers/provider_repo_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class ProviderRepoSerializer < BaseSerializer
+ entity ProviderRepoEntity
+end
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
index 4d0d4da10be..2dd62e19e29 100644
--- a/app/serializers/suggestion_entity.rb
+++ b/app/serializers/suggestion_entity.rb
@@ -3,6 +3,8 @@
class SuggestionEntity < API::Entities::Suggestion
include RequestAwareEntity
+ unexpose :from_line, :to_line, :from_content, :to_content
+ expose :diff_lines, using: DiffLineEntity
expose :current_user do
expose :can_apply do |suggestion|
Ability.allowed?(current_user, :apply_suggestion, suggestion)
diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb
new file mode 100644
index 00000000000..010344f9fcd
--- /dev/null
+++ b/app/serializers/suggestion_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class SuggestionSerializer < BaseSerializer
+ entity SuggestionEntity
+
+ def represent_diff(resource)
+ represent(resource, { only: [:diff_lines] })
+ end
+end
diff --git a/app/serializers/test_case_entity.rb b/app/serializers/test_case_entity.rb
index ec60055ba5b..5c915c1302c 100644
--- a/app/serializers/test_case_entity.rb
+++ b/app/serializers/test_case_entity.rb
@@ -3,6 +3,7 @@
class TestCaseEntity < Grape::Entity
expose :status
expose :name
+ expose :classname
expose :execution_time
expose :system_output
expose :stack_trace
diff --git a/app/serializers/tree_entity.rb b/app/serializers/tree_entity.rb
deleted file mode 100644
index 9b7dc80e1d9..00000000000
--- a/app/serializers/tree_entity.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-# frozen_string_literal: true
-
-class TreeEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :id, :path, :name, :mode
-
- expose :icon do |tree|
- IconsHelper.file_type_icon_class('folder', tree.mode, tree.name)
- end
-
- expose :url do |tree|
- project_tree_path(request.project, File.join(request.ref, tree.path))
- end
-end
diff --git a/app/serializers/tree_root_entity.rb b/app/serializers/tree_root_entity.rb
deleted file mode 100644
index f1cfcd943d8..00000000000
--- a/app/serializers/tree_root_entity.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-# TODO: Inherit from TreeEntity, when `Tree` implements `id` and `name` like `Gitlab::Git::Tree`.
-class TreeRootEntity < Grape::Entity
- include RequestAwareEntity
-
- expose :path
-
- expose :trees, using: TreeEntity
- expose :blobs, using: BlobEntity
- expose :submodules, using: SubmoduleEntity
-
- expose :parent_tree_url do |tree|
- path = tree.path.sub(%r{\A/}, '')
- next unless path.present?
-
- path_segments = path.split('/')
- path_segments.pop
- parent_tree_path = path_segments.join('/')
-
- project_tree_path(request.project, File.join(request.ref, parent_tree_path))
- end
-
- expose :last_commit_path do |tree|
- logs_file_project_ref_path(request.project, request.ref, tree.path)
- end
-end
diff --git a/app/serializers/tree_serializer.rb b/app/serializers/tree_serializer.rb
deleted file mode 100644
index 536b8ab1ae2..00000000000
--- a/app/serializers/tree_serializer.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-class TreeSerializer < BaseSerializer
- entity TreeRootEntity
-end
diff --git a/app/serializers/variable_entity.rb b/app/serializers/variable_entity.rb
index 85cf367fe51..4d48e13cfca 100644
--- a/app/serializers/variable_entity.rb
+++ b/app/serializers/variable_entity.rb
@@ -6,4 +6,5 @@ class VariableEntity < Grape::Entity
expose :value
expose :protected?, as: :protected
+ expose :masked?, as: :masked
end
diff --git a/app/services/after_branch_delete_service.rb b/app/services/after_branch_delete_service.rb
deleted file mode 100644
index e7eb74d3e7d..00000000000
--- a/app/services/after_branch_delete_service.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-##
-# Branch can be deleted either by DeleteBranchService
-# or by GitPushService.
-#
-class AfterBranchDeleteService < BaseService
- attr_reader :branch_name
-
- def execute(branch_name)
- @branch_name = branch_name
-
- stop_environments
- end
-
- private
-
- def stop_environments
- Ci::StopEnvironmentsService
- .new(project, current_user)
- .execute(branch_name)
- end
-end
diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb
index 2e4643ed668..7eeaf8aade1 100644
--- a/app/services/application_settings/update_service.rb
+++ b/app/services/application_settings/update_service.rb
@@ -2,9 +2,17 @@
module ApplicationSettings
class UpdateService < ApplicationSettings::BaseService
+ include ValidatesClassificationLabel
+
attr_reader :params, :application_setting
def execute
+ validate_classification_label(application_setting, :external_authorization_service_default_label)
+
+ if application_setting.errors.any?
+ return false
+ end
+
update_terms(@params.delete(:terms))
if params.key?(:performance_bar_allowed_group_path)
@@ -38,7 +46,7 @@ module ApplicationSettings
def performance_bar_allowed_group_id
performance_bar_enabled = !params.key?(:performance_bar_enabled) || params.delete(:performance_bar_enabled)
group_full_path = params.delete(:performance_bar_allowed_group_path)
- return nil unless Gitlab::Utils.to_boolean(performance_bar_enabled)
+ return unless Gitlab::Utils.to_boolean(performance_bar_enabled)
Group.find_by_full_path(group_full_path)&.id if group_full_path.present?
end
diff --git a/app/services/applications/create_service.rb b/app/services/applications/create_service.rb
index b6c30da4d3a..dff0d9696f8 100644
--- a/app/services/applications/create_service.rb
+++ b/app/services/applications/create_service.rb
@@ -2,16 +2,16 @@
module Applications
class CreateService
- # rubocop: disable CodeReuse/ActiveRecord
+ attr_reader :current_user, :params
+
def initialize(current_user, params)
@current_user = current_user
- @params = params.except(:ip_address)
+ @params = params.except(:ip_address) # rubocop: disable CodeReuse/ActiveRecord
end
- # rubocop: enable CodeReuse/ActiveRecord
# EE would override and use `request` arg
def execute(request)
- Doorkeeper::Application.create(@params)
+ Doorkeeper::Application.create(params)
end
end
end
diff --git a/app/services/auth/container_registry_authentication_service.rb b/app/services/auth/container_registry_authentication_service.rb
index f764536e762..707caee482c 100644
--- a/app/services/auth/container_registry_authentication_service.rb
+++ b/app/services/auth/container_registry_authentication_service.rb
@@ -116,7 +116,7 @@ module Auth
build_can_pull?(requested_project) || user_can_pull?(requested_project) || deploy_token_can_pull?(requested_project)
when 'push'
build_can_push?(requested_project) || user_can_push?(requested_project)
- when '*'
+ when '*', 'delete'
user_can_admin?(requested_project)
else
false
@@ -160,7 +160,8 @@ module Auth
##
# 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.
+ # an actor. So this should be improved once
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/37452 is resolved.
#
def build_can_push?(requested_project)
# Build can push only to the project from which it originates
diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb
index 43a26f4264e..834baeb9643 100644
--- a/app/services/boards/issues/move_service.rb
+++ b/app/services/boards/issues/move_service.rb
@@ -50,7 +50,7 @@ module Boards
if move_between_ids
attrs[:move_between_ids] = move_between_ids
- attrs[:board_group_id] = board.group&.id
+ attrs[:board_group_id] = board.group&.id
end
attrs
diff --git a/app/services/boards/lists/destroy_service.rb b/app/services/boards/lists/destroy_service.rb
index 609c430caed..e20805d0405 100644
--- a/app/services/boards/lists/destroy_service.rb
+++ b/app/services/boards/lists/destroy_service.rb
@@ -20,7 +20,7 @@ module Boards
# rubocop: disable CodeReuse/ActiveRecord
def decrement_higher_lists(list)
- board.lists.movable.where('position > ?', list.position)
+ board.lists.movable.where('position > ?', list.position)
.update_all('position = position - 1')
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/boards/visits/latest_service.rb b/app/services/boards/visits/latest_service.rb
index 9e4c77a6317..d13e25b4f12 100644
--- a/app/services/boards/visits/latest_service.rb
+++ b/app/services/boards/visits/latest_service.rb
@@ -4,13 +4,15 @@ module Boards
module Visits
class LatestService < Boards::BaseService
def execute
- return nil unless current_user
+ return unless current_user
- if parent.is_a?(Group)
- BoardGroupRecentVisit.latest(current_user, parent)
- else
- BoardProjectRecentVisit.latest(current_user, parent)
- end
+ recent_visit_model.latest(current_user, parent, count: params[:count])
+ end
+
+ private
+
+ def recent_visit_model
+ parent.is_a?(Group) ? BoardGroupRecentVisit : BoardProjectRecentVisit
end
end
end
diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb
index 19b5552887f..c17712355af 100644
--- a/app/services/ci/create_pipeline_service.rb
+++ b/app/services/ci/create_pipeline_service.rb
@@ -7,14 +7,17 @@ module Ci
CreateError = Class.new(StandardError)
SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build,
+ Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs,
Gitlab::Ci::Pipeline::Chain::Validate::Abilities,
Gitlab::Ci::Pipeline::Chain::Validate::Repository,
Gitlab::Ci::Pipeline::Chain::Validate::Config,
Gitlab::Ci::Pipeline::Chain::Skip,
+ Gitlab::Ci::Pipeline::Chain::Limit::Size,
Gitlab::Ci::Pipeline::Chain::Populate,
- Gitlab::Ci::Pipeline::Chain::Create].freeze
+ Gitlab::Ci::Pipeline::Chain::Create,
+ Gitlab::Ci::Pipeline::Chain::Limit::Activity].freeze
- def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, &block)
+ def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, merge_request: nil, **options, &block)
@pipeline = Ci::Pipeline.new
command = Gitlab::Ci::Pipeline::Chain::Command.new(
@@ -22,7 +25,9 @@ module Ci
origin_ref: params[:ref],
checkout_sha: params[:checkout_sha],
after_sha: params[:after],
- before_sha: params[:before],
+ before_sha: params[:before], # The base SHA of the source branch (i.e merge_request.diff_base_sha).
+ source_sha: params[:source_sha], # The HEAD SHA of the source branch (i.e merge_request.diff_head_sha).
+ target_sha: params[:target_sha], # The HEAD SHA of the target branch.
trigger_request: trigger_request,
schedule: schedule,
merge_request: merge_request,
@@ -31,7 +36,10 @@ module Ci
seeds_block: block,
variables_attributes: params[:variables_attributes],
project: project,
- current_user: current_user)
+ current_user: current_user,
+ push_options: params[:push_options] || {},
+ chat_data: params[:chat_data],
+ **extra_options(options))
sequence = Gitlab::Ci::Pipeline::Chain::Sequence
.new(pipeline, command, SEQUENCE)
@@ -47,6 +55,10 @@ module Ci
end
end
+ # If pipeline is not persisted, try to recover IID
+ pipeline.reset_project_iid unless pipeline.persisted? ||
+ Feature.disabled?(:ci_pipeline_rewind_iid, project, default_enabled: true)
+
pipeline
end
@@ -92,15 +104,18 @@ module Ci
end
def schedule_head_pipeline_update
- related_merge_requests.each do |merge_request|
+ pipeline.all_merge_requests.opened.each do |merge_request|
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
end
- # rubocop: disable CodeReuse/ActiveRecord
- def related_merge_requests
- pipeline.project.source_of_merge_requests.opened.where(source_branch: pipeline.ref)
+ def extra_options(options = {})
+ # In Ruby 2.4, even when options is empty, f(**options) doesn't work when f
+ # doesn't have any parameters. We reproduce the Ruby 2.5 behavior by
+ # checking explicitly that no arguments are given.
+ raise ArgumentError if options.any?
+
+ {} # overridden in EE
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb
new file mode 100644
index 00000000000..7d2f5d33fed
--- /dev/null
+++ b/app/services/ci/destroy_expired_job_artifacts_service.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Ci
+ class DestroyExpiredJobArtifactsService
+ include ::Gitlab::ExclusiveLeaseHelpers
+ include ::Gitlab::LoopHelpers
+
+ BATCH_SIZE = 100
+ LOOP_TIMEOUT = 45.minutes
+ LOOP_LIMIT = 1000
+ EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock'
+ LOCK_TIMEOUT = 50.minutes
+
+ ##
+ # Destroy expired job artifacts on GitLab instance
+ #
+ # This destroy process cannot run for more than 45 minutes. This is for
+ # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently,
+ # which is scheduled at every hour.
+ def execute
+ in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
+ loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do
+ destroy_batch
+ end
+ end
+ end
+
+ private
+
+ def destroy_batch
+ artifacts = Ci::JobArtifact.expired(BATCH_SIZE).to_a
+
+ return false if artifacts.empty?
+
+ artifacts.each(&:destroy!)
+ end
+ end
+end
diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb
index 13f892aabb8..9aea20c45f7 100644
--- a/app/services/ci/destroy_pipeline_service.rb
+++ b/app/services/ci/destroy_pipeline_service.rb
@@ -5,7 +5,7 @@ module Ci
def execute(pipeline)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :destroy_pipeline, pipeline)
- AuditEventService.new(current_user, pipeline).security_event
+ Ci::ExpirePipelineCacheService.new.execute(pipeline, delete: true)
pipeline.destroy!
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..d8d38128af6
--- /dev/null
+++ b/app/services/ci/expire_pipeline_cache_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Ci
+ class ExpirePipelineCacheService
+ def execute(pipeline, delete: false)
+ store = Gitlab::EtagCaching::Store.new
+
+ update_etag_cache(pipeline, store)
+
+ if delete
+ Gitlab::Cache::Ci::ProjectPipelineStatus.new(pipeline.project).delete_from_cache
+ else
+ Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline)
+ end
+ end
+
+ private
+
+ def project_pipelines_path(project)
+ Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
+ end
+
+ def project_pipeline_path(project, pipeline)
+ Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
+ end
+
+ def commit_pipelines_path(project, commit)
+ Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
+ end
+
+ def new_merge_request_pipelines_path(project)
+ Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
+ end
+
+ def each_pipelines_merge_request_path(pipeline)
+ pipeline.all_merge_requests.each do |merge_request|
+ path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(merge_request.target_project, merge_request, format: :json)
+
+ yield(path)
+ end
+ end
+
+ # Updates ETag caches of a pipeline.
+ #
+ # This logic resides in a separate method so that EE can more easily extend
+ # it.
+ #
+ # @param [Ci::Pipeline] pipeline
+ # @param [Gitlab::EtagCaching::Store] store
+ def update_etag_cache(pipeline, store)
+ project = pipeline.project
+
+ store.touch(project_pipelines_path(project))
+ store.touch(project_pipeline_path(project, pipeline))
+ store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
+ store.touch(new_merge_request_pipelines_path(project))
+ each_pipelines_merge_request_path(pipeline) do |path|
+ store.touch(path)
+ end
+ end
+ end
+end
diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb
index f54574b026b..2dbb7c3917d 100644
--- a/app/services/ci/pipeline_trigger_service.rb
+++ b/app/services/ci/pipeline_trigger_service.rb
@@ -7,6 +7,8 @@ module Ci
def execute
if trigger_from_token
create_pipeline_from_trigger(trigger_from_token)
+ elsif job_from_token
+ create_pipeline_from_job(job_from_token)
end
end
@@ -35,6 +37,14 @@ module Ci
end
end
+ def create_pipeline_from_job(job)
+ # overridden in EE
+ end
+
+ def job_from_token
+ # overridden in EE
+ end
+
def variables
params[:variables].to_h.map do |key, value|
{ key: key, value: value }
diff --git a/app/services/ci/play_manual_stage_service.rb b/app/services/ci/play_manual_stage_service.rb
new file mode 100644
index 00000000000..2497fc52e6b
--- /dev/null
+++ b/app/services/ci/play_manual_stage_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Ci
+ class PlayManualStageService < BaseService
+ def initialize(project, current_user, params)
+ super
+
+ @pipeline = params[:pipeline]
+ end
+
+ def execute(stage)
+ stage.builds.manual.each do |build|
+ next unless build.playable?
+
+ build.play(current_user)
+ rescue Gitlab::Access::AccessDeniedError
+ logger.error(message: 'Unable to play manual action', build_id: build.id)
+ end
+ end
+
+ private
+
+ attr_reader :pipeline, :current_user
+
+ def logger
+ Gitlab::AppLogger
+ end
+ end
+end
diff --git a/app/services/ci/prepare_build_service.rb b/app/services/ci/prepare_build_service.rb
new file mode 100644
index 00000000000..3722faeb020
--- /dev/null
+++ b/app/services/ci/prepare_build_service.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Ci
+ class PrepareBuildService
+ attr_reader :build
+
+ def initialize(build)
+ @build = build
+ end
+
+ def execute
+ prerequisites.each(&:complete!)
+
+ build.enqueue!
+ rescue => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { build_id: build.id })
+
+ build.drop(:unmet_prerequisites)
+ end
+
+ private
+
+ def prerequisites
+ build.prerequisites
+ end
+ end
+end
diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb
index 446188347df..4a7ce00b8e2 100644
--- a/app/services/ci/process_pipeline_service.rb
+++ b/app/services/ci/process_pipeline_service.rb
@@ -10,7 +10,7 @@ module Ci
update_retried
new_builds =
- stage_indexes_of_created_builds.map do |index|
+ stage_indexes_of_created_processables.map do |index|
process_stage(index)
end
@@ -27,7 +27,7 @@ module Ci
return if HasStatus::BLOCKED_STATUS.include?(current_status)
if HasStatus::COMPLETED_STATUSES.include?(current_status)
- created_builds_in_stage(index).select do |build|
+ created_processables_in_stage(index).select do |build|
Gitlab::OptimisticLocking.retry_lock(build) do |subject|
Ci::ProcessBuildService.new(project, @user)
.execute(build, current_status)
@@ -43,19 +43,19 @@ module Ci
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def stage_indexes_of_created_builds
- created_builds.order(:stage_idx).pluck('distinct stage_idx')
+ def stage_indexes_of_created_processables
+ created_processables.order(:stage_idx).pluck('distinct stage_idx')
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
- def created_builds_in_stage(index)
- created_builds.where(stage_idx: index)
+ def created_processables_in_stage(index)
+ created_processables.where(stage_idx: index)
end
# rubocop: enable CodeReuse/ActiveRecord
- def created_builds
- pipeline.builds.created
+ def created_processables
+ pipeline.processables.created
end
# This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb
index 13321b2682e..dedab98b56d 100644
--- a/app/services/ci/register_job_service.rb
+++ b/app/services/ci/register_job_service.rb
@@ -6,7 +6,7 @@ module Ci
class RegisterJobService
attr_reader :runner
- JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30].freeze
+ JOB_QUEUE_DURATION_SECONDS_BUCKETS = [1, 3, 10, 30, 60, 300].freeze
JOBS_RUNNING_FOR_PROJECT_MAX_BUCKET = 5.freeze
Result = Struct.new(:build, :valid?)
@@ -36,6 +36,11 @@ module Ci
builds = builds.with_any_tags
end
+ # pick builds that older than specified age
+ if params.key?(:job_age)
+ builds = builds.queued_before(params[:job_age].seconds.ago)
+ end
+
builds.each do |build|
next unless runner.can_pick?(build)
@@ -118,7 +123,7 @@ module Ci
# Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL`
groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces)
- hierarchy_groups = Gitlab::GroupHierarchy.new(groups).base_and_descendants
+ hierarchy_groups = Gitlab::ObjectHierarchy.new(groups).base_and_descendants
projects = Project.where(namespace_id: hierarchy_groups)
.with_group_runners_enabled
.with_builds_enabled
diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb
index 218f1e63d08..fab8a179843 100644
--- a/app/services/ci/retry_build_service.rb
+++ b/app/services/ci/retry_build_service.rb
@@ -2,7 +2,7 @@
module Ci
class RetryBuildService < ::BaseService
- CLONE_ACCESSORS = %i[pipeline project ref tag options commands name
+ CLONE_ACCESSORS = %i[pipeline project ref tag options name
allow_failure stage stage_id stage_idx trigger_request
yaml_variables when environment coverage_regex
description tag_list protected].freeze
diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb
index 973ae5ce5aa..d9a800791f2 100644
--- a/app/services/ci/stop_environments_service.rb
+++ b/app/services/ci/stop_environments_service.rb
@@ -9,12 +9,11 @@ module Ci
return unless @ref.present?
- environments.each do |environment|
- next unless environment.stop_action_available?
- next unless can?(current_user, :stop_environment, environment)
+ environments.each { |environment| stop(environment) }
+ end
- environment.stop_with_action!(current_user)
- end
+ def execute_for_merge_request(merge_request)
+ merge_request.environments.each { |environment| stop(environment) }
end
private
@@ -24,5 +23,12 @@ module Ci
.new(project, current_user, ref: @ref, recently_updated: true)
.execute
end
+
+ def stop(environment)
+ return unless environment.stop_action_available?
+ return unless can?(current_user, :stop_environment, environment)
+
+ environment.stop_with_action!(current_user)
+ end
end
end
diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb
index e86ca8cf1d0..3e7f55f0c63 100644
--- a/app/services/clusters/applications/base_helm_service.rb
+++ b/app/services/clusters/applications/base_helm_service.rb
@@ -13,19 +13,37 @@ module Clusters
def log_error(error)
meta = {
- exception: error.class.name,
error_code: error.respond_to?(:error_code) ? error.error_code : nil,
service: self.class.name,
app_id: app.id,
+ app_name: app.name,
project_ids: app.cluster.project_ids,
- group_ids: app.cluster.group_ids,
- message: error.message
+ group_ids: app.cluster.group_ids
}
- logger.error(meta)
+ logger_meta = meta.merge(
+ exception: error.class.name,
+ message: error.message,
+ backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace)
+ )
+
+ logger.error(logger_meta)
Gitlab::Sentry.track_acceptable_exception(error, extra: meta)
end
+ def log_event(event)
+ meta = {
+ service: self.class.name,
+ app_id: app.id,
+ app_name: app.name,
+ project_ids: app.cluster.project_ids,
+ group_ids: app.cluster.group_ids,
+ event: event
+ }
+
+ logger.info(meta)
+ end
+
def logger
@logger ||= Gitlab::Kubernetes::Logger.build
end
@@ -45,6 +63,14 @@ module Clusters
def install_command
@install_command ||= app.install_command
end
+
+ def update_command
+ @update_command ||= app.update_command
+ end
+
+ def upgrade_command(new_values = "")
+ app.upgrade_command(new_values)
+ end
end
end
end
diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb
new file mode 100644
index 00000000000..14a45437287
--- /dev/null
+++ b/app/services/clusters/applications/base_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class BaseService
+ InvalidApplicationError = Class.new(StandardError)
+
+ attr_reader :cluster, :current_user, :params
+
+ def initialize(cluster, user, params = {})
+ @cluster = cluster
+ @current_user = user
+ @params = params.dup
+ end
+
+ def execute(request)
+ instantiate_application.tap do |application|
+ if application.has_attribute?(:hostname)
+ application.hostname = params[:hostname]
+ end
+
+ if application.has_attribute?(:email)
+ application.email = params[:email]
+ end
+
+ if application.respond_to?(:oauth_application)
+ application.oauth_application = create_oauth_application(application, request)
+ end
+
+ worker = worker_class(application)
+
+ application.make_scheduled!
+
+ worker.perform_async(application.name, application.id)
+ end
+ end
+
+ protected
+
+ def worker_class(application)
+ raise NotImplementedError
+ end
+
+ def builder
+ raise NotImplementedError
+ end
+
+ def project_builders
+ raise NotImplementedError
+ end
+
+ def instantiate_application
+ raise_invalid_application_error if invalid_application?
+
+ builder || raise(InvalidApplicationError, "invalid application: #{application_name}")
+ end
+
+ def raise_invalid_application_error
+ raise(InvalidApplicationError, "invalid application: #{application_name}")
+ end
+
+ def invalid_application?
+ unknown_application? || (!cluster.project_type? && project_only_application?)
+ end
+
+ def unknown_application?
+ Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name)
+ end
+
+ # These applications will need extra configuration to enable them to work
+ # with groups of projects
+ def project_only_application?
+ Clusters::Cluster::PROJECT_ONLY_APPLICATIONS.include?(application_name)
+ end
+
+ def application_name
+ params[:application]
+ end
+
+ def create_oauth_application(application, request)
+ oauth_application_params = {
+ name: params[:application],
+ redirect_uri: application.callback_url,
+ scopes: 'api read_user openid',
+ owner: current_user
+ }
+
+ ::Applications::CreateService.new(current_user, oauth_application_params).execute(request)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/check_ingress_ip_address_service.rb b/app/services/clusters/applications/check_ingress_ip_address_service.rb
index 0ec06e776a7..e254a0358a0 100644
--- a/app/services/clusters/applications/check_ingress_ip_address_service.rb
+++ b/app/services/clusters/applications/check_ingress_ip_address_service.rb
@@ -11,9 +11,13 @@ module Clusters
def execute
return if app.external_ip
+ return if app.external_hostname
return unless try_obtain_lease
- app.update!(external_ip: ingress_ip) if ingress_ip
+ app.external_ip = ingress_ip if ingress_ip
+ app.external_hostname = ingress_hostname if ingress_hostname
+
+ app.save! if app.changed?
end
private
@@ -25,12 +29,16 @@ module Clusters
end
def ingress_ip
- service.status.loadBalancer.ingress&.first&.ip
+ ingress_service&.ip
+ end
+
+ def ingress_hostname
+ ingress_service&.hostname
end
- def service
+ def ingress_service
strong_memoize(:ingress_service) do
- app.ingress_service
+ app.ingress_service.status.loadBalancer.ingress&.first
end
end
end
diff --git a/app/services/clusters/applications/check_installation_progress_service.rb b/app/services/clusters/applications/check_installation_progress_service.rb
index 21ec26ea233..3c6803d24e6 100644
--- a/app/services/clusters/applications/check_installation_progress_service.rb
+++ b/app/services/clusters/applications/check_installation_progress_service.rb
@@ -4,7 +4,7 @@ module Clusters
module Applications
class CheckInstallationProgressService < BaseHelmService
def execute
- return unless app.installing?
+ return unless operation_in_progress?
case installation_phase
when Gitlab::Kubernetes::Pod::SUCCEEDED
@@ -16,11 +16,16 @@ module Clusters
end
rescue Kubeclient::HttpError => e
log_error(e)
- app.make_errored!("Kubernetes error: #{e.error_code}") unless app.errored?
+
+ app.make_errored!("Kubernetes error: #{e.error_code}")
end
private
+ def operation_in_progress?
+ app.installing? || app.updating?
+ end
+
def on_success
app.make_installed!
ensure
@@ -28,13 +33,13 @@ module Clusters
end
def on_failed
- app.make_errored!("Installation failed. Check pod logs for #{install_command.pod_name} for more details.")
+ app.make_errored!("Operation failed. Check pod logs for #{pod_name} for more details.")
end
def check_timeout
- if timeouted?
+ if timed_out?
begin
- app.make_errored!("Installation timed out. Check pod logs for #{install_command.pod_name} for more details.")
+ app.make_errored!("Operation timed out. Check pod logs for #{pod_name} for more details.")
end
else
ClusterWaitForAppInstallationWorker.perform_in(
@@ -42,20 +47,24 @@ module Clusters
end
end
- def timeouted?
- Time.now.utc - app.updated_at.to_time.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
+ def pod_name
+ install_command.pod_name
+ end
+
+ def timed_out?
+ Time.now.utc - app.updated_at.utc > ClusterWaitForAppInstallationWorker::TIMEOUT
end
def remove_installation_pod
- helm_api.delete_pod!(install_command.pod_name)
+ helm_api.delete_pod!(pod_name)
end
def installation_phase
- helm_api.status(install_command.pod_name)
+ helm_api.status(pod_name)
end
def installation_errors
- helm_api.log(install_command.pod_name)
+ helm_api.log(pod_name)
end
end
end
diff --git a/app/services/clusters/applications/check_uninstall_progress_service.rb b/app/services/clusters/applications/check_uninstall_progress_service.rb
new file mode 100644
index 00000000000..8786d295d6a
--- /dev/null
+++ b/app/services/clusters/applications/check_uninstall_progress_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class CheckUninstallProgressService < BaseHelmService
+ def execute
+ return unless app.uninstalling?
+
+ case installation_phase
+ when Gitlab::Kubernetes::Pod::SUCCEEDED
+ on_success
+ when Gitlab::Kubernetes::Pod::FAILED
+ on_failed
+ else
+ check_timeout
+ end
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ end
+
+ private
+
+ def on_success
+ app.destroy!
+ rescue StandardError => e
+ app.make_errored!(_('Application uninstalled but failed to destroy: %{error_message}') % { error_message: e.message })
+ ensure
+ remove_installation_pod
+ end
+
+ def on_failed
+ app.make_errored!(_('Operation failed. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name })
+ end
+
+ def check_timeout
+ if timed_out?
+ app.make_errored!(_('Operation timed out. Check pod logs for %{pod_name} for more details.') % { pod_name: pod_name })
+ else
+ WaitForUninstallAppWorker.perform_in(WaitForUninstallAppWorker::INTERVAL, app.name, app.id)
+ end
+ end
+
+ def pod_name
+ app.uninstall_command.pod_name
+ end
+
+ def timed_out?
+ Time.now.utc - app.updated_at.utc > WaitForUninstallAppWorker::TIMEOUT
+ end
+
+ def remove_installation_pod
+ helm_api.delete_pod!(pod_name)
+ end
+
+ def installation_phase
+ helm_api.status(pod_name)
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/create_service.rb b/app/services/clusters/applications/create_service.rb
index 92c2c1b9834..f723c42c049 100644
--- a/app/services/clusters/applications/create_service.rb
+++ b/app/services/clusters/applications/create_service.rb
@@ -2,81 +2,16 @@
module Clusters
module Applications
- class CreateService
- InvalidApplicationError = Class.new(StandardError)
-
- attr_reader :cluster, :current_user, :params
-
- def initialize(cluster, user, params = {})
- @cluster = cluster
- @current_user = user
- @params = params.dup
- end
-
- def execute(request)
- create_application.tap do |application|
- if application.has_attribute?(:hostname)
- application.hostname = params[:hostname]
- end
-
- if application.has_attribute?(:email)
- application.email = params[:email]
- end
-
- if application.respond_to?(:oauth_application)
- application.oauth_application = create_oauth_application(application, request)
- end
-
- application.save!
-
- Clusters::Applications::ScheduleInstallationService.new(application).execute
- end
- end
-
+ class CreateService < Clusters::Applications::BaseService
private
- def create_application
- builder.call(@cluster)
+ def worker_class(application)
+ application.updateable? ? ClusterUpgradeAppWorker : ClusterInstallAppWorker
end
def builder
- builders[application_name] || raise(InvalidApplicationError, "invalid application: #{application_name}")
- end
-
- def builders
- {
- "helm" => -> (cluster) { cluster.application_helm || cluster.build_application_helm },
- "ingress" => -> (cluster) { cluster.application_ingress || cluster.build_application_ingress },
- "cert_manager" => -> (cluster) { cluster.application_cert_manager || cluster.build_application_cert_manager }
- }.tap do |hash|
- hash.merge!(project_builders) if cluster.project_type?
- end
- end
-
- # These applications will need extra configuration to enable them to work
- # with groups of projects
- def project_builders
- {
- "prometheus" => -> (cluster) { cluster.application_prometheus || cluster.build_application_prometheus },
- "runner" => -> (cluster) { cluster.application_runner || cluster.build_application_runner },
- "jupyter" => -> (cluster) { cluster.application_jupyter || cluster.build_application_jupyter },
- "knative" => -> (cluster) { cluster.application_knative || cluster.build_application_knative }
- }
- end
-
- def application_name
- params[:application]
- end
-
- def create_oauth_application(application, request)
- oauth_application_params = {
- name: params[:application],
- redirect_uri: application.callback_url,
- scopes: 'api read_user openid',
- owner: current_user
- }
-
- ::Applications::CreateService.new(current_user, oauth_application_params).execute(request)
+ cluster.public_send(:"application_#{application_name}") || # rubocop:disable GitlabSecurity/PublicSend
+ cluster.public_send(:"build_application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
end
end
end
diff --git a/app/services/clusters/applications/destroy_service.rb b/app/services/clusters/applications/destroy_service.rb
new file mode 100644
index 00000000000..f3a4c4f754a
--- /dev/null
+++ b/app/services/clusters/applications/destroy_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class DestroyService < ::Clusters::Applications::BaseService
+ def execute(_request)
+ instantiate_application.tap do |application|
+ break unless application.can_uninstall?
+
+ application.make_scheduled!
+
+ Clusters::Applications::UninstallWorker.perform_async(application.name, application.id)
+ end
+ end
+
+ private
+
+ def builder
+ cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/install_service.rb b/app/services/clusters/applications/install_service.rb
index 5a65dc4ef59..dffb4ce65ab 100644
--- a/app/services/clusters/applications/install_service.rb
+++ b/app/services/clusters/applications/install_service.rb
@@ -6,19 +6,26 @@ module Clusters
def execute
return unless app.scheduled?
- begin
- app.make_installing!
- helm_api.install(install_command)
+ app.make_installing!
- ClusterWaitForAppInstallationWorker.perform_in(
- ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
- rescue Kubeclient::HttpError => e
- log_error(e)
- app.make_errored!("Kubernetes error: #{e.error_code}")
- rescue StandardError => e
- log_error(e)
- app.make_errored!("Can't start installation process.")
- end
+ install
+ end
+
+ private
+
+ def install
+ log_event(:begin_install)
+ helm_api.install(install_command)
+
+ log_event(:schedule_wait_for_installation)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to install.'))
end
end
end
diff --git a/app/services/clusters/applications/patch_service.rb b/app/services/clusters/applications/patch_service.rb
new file mode 100644
index 00000000000..fbea18bae6b
--- /dev/null
+++ b/app/services/clusters/applications/patch_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class PatchService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ app.make_updating!
+
+ patch
+ end
+
+ private
+
+ def patch
+ log_event(:begin_patch)
+ helm_api.update(update_command)
+
+ log_event(:schedule_wait_for_patch)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to update.'))
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/schedule_installation_service.rb b/app/services/clusters/applications/schedule_installation_service.rb
deleted file mode 100644
index d75ba70c27e..00000000000
--- a/app/services/clusters/applications/schedule_installation_service.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Clusters
- module Applications
- class ScheduleInstallationService
- attr_reader :application
-
- def initialize(application)
- @application = application
- end
-
- def execute
- application.make_scheduled!
-
- ClusterInstallAppWorker.perform_async(application.name, application.id)
- end
- end
- end
-end
diff --git a/app/services/clusters/applications/uninstall_service.rb b/app/services/clusters/applications/uninstall_service.rb
new file mode 100644
index 00000000000..50c8d806c14
--- /dev/null
+++ b/app/services/clusters/applications/uninstall_service.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UninstallService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ app.make_uninstalling!
+ uninstall
+ end
+
+ private
+
+ def uninstall
+ helm_api.uninstall(app.uninstall_command)
+
+ Clusters::Applications::WaitForUninstallAppWorker.perform_in(
+ Clusters::Applications::WaitForUninstallAppWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!("Kubernetes error: #{e.error_code}")
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!('Failed to uninstall.')
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb
new file mode 100644
index 00000000000..0fa937da865
--- /dev/null
+++ b/app/services/clusters/applications/update_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UpdateService < Clusters::Applications::BaseService
+ private
+
+ def worker_class(application)
+ ClusterPatchAppWorker
+ end
+
+ def builder
+ cluster.public_send(:"application_#{application_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/applications/upgrade_service.rb b/app/services/clusters/applications/upgrade_service.rb
new file mode 100644
index 00000000000..ac68e64af38
--- /dev/null
+++ b/app/services/clusters/applications/upgrade_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UpgradeService < BaseHelmService
+ def execute
+ return unless app.scheduled?
+
+ app.make_updating!
+
+ upgrade
+ end
+
+ private
+
+ def upgrade
+ # install_command works with upgrades too
+ # as it basically does `helm upgrade --install`
+ log_event(:begin_upgrade)
+ helm_api.update(install_command)
+
+ log_event(:schedule_wait_for_upgrade)
+ ClusterWaitForAppInstallationWorker.perform_in(
+ ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id)
+ rescue Kubeclient::HttpError => e
+ log_error(e)
+ app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code })
+ rescue StandardError => e
+ log_error(e)
+ app.make_errored!(_('Failed to upgrade.'))
+ end
+ end
+ end
+end
diff --git a/app/services/clusters/build_service.rb b/app/services/clusters/build_service.rb
index 8de73831164..b1ac5549e30 100644
--- a/app/services/clusters/build_service.rb
+++ b/app/services/clusters/build_service.rb
@@ -12,6 +12,8 @@ module Clusters
cluster.cluster_type = :project_type
when ::Group
cluster.cluster_type = :group_type
+ when Instance
+ cluster.cluster_type = :instance_type
else
raise NotImplementedError
end
diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb
index 5a9da053780..886e484caaf 100644
--- a/app/services/clusters/create_service.rb
+++ b/app/services/clusters/create_service.rb
@@ -38,6 +38,8 @@ module Clusters
{ cluster_type: :project_type, projects: [clusterable] }
when ::Group
{ cluster_type: :group_type, groups: [clusterable] }
+ when Instance
+ { cluster_type: :instance_type }
else
raise NotImplementedError
end
diff --git a/app/services/clusters/gcp/finalize_creation_service.rb b/app/services/clusters/gcp/finalize_creation_service.rb
index 301059f0326..5525c1b9b7f 100644
--- a/app/services/clusters/gcp/finalize_creation_service.rb
+++ b/app/services/clusters/gcp/finalize_creation_service.rb
@@ -13,17 +13,17 @@ module Clusters
configure_kubernetes
cluster.save!
- ClusterPlatformConfigureWorker.perform_async(cluster.id)
+ ClusterConfigureWorker.perform_async(cluster.id)
rescue Google::Apis::ServerError, Google::Apis::ClientError, Google::Apis::AuthorizationError => e
log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!("Failed to request to CloudPlatform; #{e.message}")
+ provider.make_errored!(s_('ClusterIntegration|Failed to request to Google Cloud Platform: %{message}') % { message: e.message })
rescue Kubeclient::HttpError => e
log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!("Failed to run Kubeclient: #{e.message}")
+ provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message })
rescue ActiveRecord::RecordInvalid => e
log_service_error(e.class.name, provider.id, e.message)
- provider.make_errored!("Failed to configure Google Kubernetes Engine Cluster: #{e.message}")
+ provider.make_errored!(s_('ClusterIntegration|Failed to configure Google Kubernetes Engine Cluster: %{message}') % { message: e.message })
end
private
diff --git a/app/services/clusters/refresh_service.rb b/app/services/clusters/refresh_service.rb
index 7c82b98a33f..3752a306793 100644
--- a/app/services/clusters/refresh_service.rb
+++ b/app/services/clusters/refresh_service.rb
@@ -21,7 +21,7 @@ module Clusters
private_class_method :projects_with_missing_kubernetes_namespaces_for_cluster
def self.clusters_with_missing_kubernetes_namespaces_for_project(project)
- project.all_clusters.missing_kubernetes_namespace(project.kubernetes_namespaces)
+ project.clusters.managed.missing_kubernetes_namespace(project.kubernetes_namespaces)
end
private_class_method :clusters_with_missing_kubernetes_namespaces_for_project
diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb
index 34593e12bd5..bb34a3d3352 100644
--- a/app/services/commits/create_service.rb
+++ b/app/services/commits/create_service.rb
@@ -11,6 +11,7 @@ module Commits
@start_project = params[:start_project] || @project
@start_branch = params[:start_branch]
@branch_name = params[:branch_name]
+ @force = params[:force] || false
end
def execute
@@ -42,10 +43,14 @@ module Commits
@start_branch != @branch_name || @start_project != @project
end
+ def force?
+ !!@force
+ end
+
def validate!
validate_permissions!
validate_on_branch!
- validate_branch_existance!
+ validate_branch_existence!
validate_new_branch_name! if different_branch?
end
@@ -64,14 +69,14 @@ module Commits
end
end
- def validate_branch_existance!
- if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name)
+ def validate_branch_existence!
+ if !project.empty_repo? && different_branch? && repository.branch_exists?(@branch_name) && !force?
raise_error("A branch called '#{@branch_name}' already exists. Switch to that branch in order to make changes")
end
end
def validate_new_branch_name!
- result = ValidateNewBranchService.new(project, current_user).execute(@branch_name)
+ result = ValidateNewBranchService.new(project, current_user).execute(@branch_name, force: force?)
if result[:status] == :error
raise_error("Something went wrong when we tried to create '#{@branch_name}' for you: #{result[:message]}")
diff --git a/app/services/commits/tag_service.rb b/app/services/commits/tag_service.rb
index 7961ba4d3c4..bb8cfb63f98 100644
--- a/app/services/commits/tag_service.rb
+++ b/app/services/commits/tag_service.rb
@@ -9,11 +9,10 @@ module Commits
tag_name = params[:tag_name]
message = params[:tag_message]
- release_description = nil
result = Tags::CreateService
.new(commit.project, current_user)
- .execute(tag_name, commit.sha, message, release_description)
+ .execute(tag_name, commit.sha, message)
if result[:status] == :success
tag = result[:tag]
diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb
index 3adf8a0c1a1..3f0aedfbfb2 100644
--- a/app/services/compare_service.rb
+++ b/app/services/compare_service.rb
@@ -3,7 +3,7 @@
require 'securerandom'
# Compare 2 refs for one repo or between repositories
-# and return Gitlab::Git::Compare object that responds to commits and diffs
+# and return Compare object that responds to commits and diffs
class CompareService
attr_reader :start_project, :start_ref_name
@@ -15,7 +15,7 @@ class CompareService
def execute(target_project, target_ref, base_sha: nil, straight: false)
raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
- return unless raw_compare
+ return unless raw_compare && raw_compare.base && raw_compare.head
Compare.new(raw_compare,
target_project,
diff --git a/app/services/concerns/exclusive_lease_guard.rb b/app/services/concerns/exclusive_lease_guard.rb
index f102e00d150..2cb73555d85 100644
--- a/app/services/concerns/exclusive_lease_guard.rb
+++ b/app/services/concerns/exclusive_lease_guard.rb
@@ -6,9 +6,14 @@
#
# `#try_obtain_lease` takes a block which will be run if it was able to
# obtain the lease. Implement `#lease_timeout` to configure the timeout
-# for the exclusive lease. Optionally override `#lease_key` to set the
+# for the exclusive lease.
+#
+# Optionally override `#lease_key` to set the
# lease key, it defaults to the class name with underscores.
#
+# Optionally override `#lease_release?` to prevent the job to
+# be re-executed more often than LEASE_TIMEOUT.
+#
module ExclusiveLeaseGuard
extend ActiveSupport::Concern
@@ -23,7 +28,7 @@ module ExclusiveLeaseGuard
begin
yield lease
ensure
- release_lease(lease)
+ release_lease(lease) if lease_release?
end
end
@@ -37,7 +42,11 @@ module ExclusiveLeaseGuard
def lease_timeout
raise NotImplementedError,
- "#{self.class.name} does not implement #{__method__}"
+ "#{self.class.name} does not implement #{__method__}"
+ end
+
+ def lease_release?
+ true
end
def release_lease(uuid)
diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb
new file mode 100644
index 00000000000..0cba9bf1b8a
--- /dev/null
+++ b/app/services/concerns/suggestible.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Suggestible
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ # This translates into limiting suggestion changes to `suggestion:-100+100`.
+ MAX_LINES_CONTEXT = 100.freeze
+
+ def diff_lines
+ strong_memoize(:diff_lines) do
+ Gitlab::Diff::SuggestionDiff.new(self).diff_lines
+ end
+ end
+
+ def fetch_from_content
+ diff_file.new_blob_lines_between(from_line, to_line).join
+ end
+
+ def from_line
+ real_above = [lines_above, MAX_LINES_CONTEXT].min
+ [target_line - real_above, 1].max
+ end
+
+ def to_line
+ real_below = [lines_below, MAX_LINES_CONTEXT].min
+ target_line + real_below
+ end
+
+ def diff_file
+ raise NotImplementedError
+ end
+
+ def target_line
+ raise NotImplementedError
+ end
+end
diff --git a/app/services/concerns/users/participable_service.rb b/app/services/concerns/users/participable_service.rb
index 5b408bd96c7..1c828234f1b 100644
--- a/app/services/concerns/users/participable_service.rb
+++ b/app/services/concerns/users/participable_service.rb
@@ -11,7 +11,7 @@ module Users
def noteable_owner
return [] unless noteable && noteable.author.present?
- [as_hash(noteable.author)]
+ [user_as_hash(noteable.author)]
end
def participants_in_noteable
@@ -23,21 +23,40 @@ module Users
def sorted(users)
users.uniq.to_a.compact.sort_by(&:username).map do |user|
- as_hash(user)
+ user_as_hash(user)
end
end
def groups
- current_user.authorized_groups.sort_by(&:path).map do |group|
- count = group.users.count
- { username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url }
+ group_counts = GroupMember
+ .of_groups(current_user.authorized_groups)
+ .non_request
+ .count_users_by_group_id
+
+ current_user.authorized_groups.with_route.sort_by(&:path).map do |group|
+ group_as_hash(group, group_counts)
end
end
private
- def as_hash(user)
- { username: user.username, name: user.name, avatar_url: user.avatar_url }
+ def user_as_hash(user)
+ {
+ type: user.class.name,
+ username: user.username,
+ name: user.name,
+ avatar_url: user.avatar_url
+ }
+ end
+
+ def group_as_hash(group, group_counts)
+ {
+ type: group.class.name,
+ username: group.full_path,
+ name: group.full_name,
+ avatar_url: group.avatar_url,
+ count: group_counts.fetch(group.id, 0)
+ }
end
end
end
diff --git a/app/services/concerns/validates_classification_label.rb b/app/services/concerns/validates_classification_label.rb
new file mode 100644
index 00000000000..ebcf5c24ff8
--- /dev/null
+++ b/app/services/concerns/validates_classification_label.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ValidatesClassificationLabel
+ def validate_classification_label(record, attribute_name)
+ return unless ::Gitlab::ExternalAuthorization.enabled?
+ return unless classification_label_change?(record, attribute_name)
+
+ new_label = params[attribute_name].presence
+ new_label ||= ::Gitlab::CurrentSettings.current_application_settings
+ .external_authorization_service_default_label
+
+ unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, new_label)
+ reason = rejection_reason_for_label(new_label)
+ message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason }
+ record.errors.add(attribute_name, message)
+ end
+ end
+
+ def rejection_reason_for_label(label)
+ reason_from_service = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label).presence
+ reason_from_service || _("Access to '%{classification_label}' not allowed") % { classification_label: label }
+ end
+
+ def classification_label_change?(record, attribute_name)
+ params.key?(attribute_name) || record.new_record?
+ end
+end
diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb
index 65208b07e27..110e589e30d 100644
--- a/app/services/create_branch_service.rb
+++ b/app/services/create_branch_service.rb
@@ -1,8 +1,8 @@
# frozen_string_literal: true
class CreateBranchService < BaseService
- def execute(branch_name, ref)
- create_master_branch if project.empty_repo?
+ def execute(branch_name, ref, create_master_if_empty: true)
+ create_master_branch if create_master_if_empty && project.empty_repo?
result = ValidateNewBranchService.new(project, current_user)
.execute(branch_name)
diff --git a/app/services/create_release_service.rb b/app/services/create_release_service.rb
deleted file mode 100644
index ab2dc5337aa..00000000000
--- a/app/services/create_release_service.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-class CreateReleaseService < BaseService
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(tag_name, release_description)
- repository = project.repository
- existing_tag = repository.find_tag(tag_name)
-
- # Only create a release if the tag exists
- if existing_tag
- release = project.releases.find_by(tag: tag_name)
-
- if release
- error('Release already exists', 409)
- else
- release = project.releases.create!(
- tag: tag_name,
- name: tag_name,
- sha: existing_tag.dereferenced_target.sha,
- author: current_user,
- description: release_description
- )
-
- success(release)
- end
- else
- error('Tag does not exist', 404)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def success(release)
- super().merge(release: release)
- end
-end
diff --git a/app/services/delete_branch_service.rb b/app/services/delete_branch_service.rb
index 44252f7b0a6..fd41ce54486 100644
--- a/app/services/delete_branch_service.rb
+++ b/app/services/delete_branch_service.rb
@@ -6,37 +6,25 @@ class DeleteBranchService < BaseService
branch = repository.find_branch(branch_name)
unless current_user.can?(:push_code, project)
- return error('You dont have push access to repo', 405)
+ return ServiceResponse.error(
+ message: 'You dont have push access to repo',
+ http_status: 405)
end
unless branch
- return error('No such branch', 404)
+ return ServiceResponse.error(
+ message: 'No such branch',
+ http_status: 404)
end
if repository.rm_branch(current_user, branch_name)
- success('Branch was removed')
+ ServiceResponse.success(message: 'Branch was deleted')
else
- error('Failed to remove branch')
+ ServiceResponse.error(
+ message: 'Failed to remove branch',
+ http_status: 400)
end
rescue Gitlab::Git::PreReceiveError => ex
- error(ex.message)
- end
-
- def error(message, return_code = 400)
- super(message).merge(return_code: return_code)
- end
-
- def success(message)
- super().merge(message: message)
- end
-
- def build_push_data(branch)
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- branch.dereferenced_target.sha,
- Gitlab::Git::BLANK_SHA,
- "#{Gitlab::Git::BRANCH_REF_PREFIX}#{branch.name}",
- [])
+ ServiceResponse.error(message: ex.message, http_status: 400)
end
end
diff --git a/app/services/deploy_keys/create_service.rb b/app/services/deploy_keys/create_service.rb
index a25e73666f8..0c935285657 100644
--- a/app/services/deploy_keys/create_service.rb
+++ b/app/services/deploy_keys/create_service.rb
@@ -2,7 +2,7 @@
module DeployKeys
class CreateService < Keys::BaseService
- def execute
+ def execute(project: nil)
DeployKey.create(params.merge(user: user))
end
end
diff --git a/app/services/emails/base_service.rb b/app/services/emails/base_service.rb
index 988215ffc78..99324638300 100644
--- a/app/services/emails/base_service.rb
+++ b/app/services/emails/base_service.rb
@@ -2,10 +2,11 @@
module Emails
class BaseService
- attr_reader :current_user
+ attr_reader :current_user, :params, :user
def initialize(current_user, params = {})
- @current_user, @params = current_user, params.dup
+ @current_user = current_user
+ @params = params.dup
@user = params.delete(:user)
end
end
diff --git a/app/services/emails/create_service.rb b/app/services/emails/create_service.rb
index 56925a724fe..dc06a5caa40 100644
--- a/app/services/emails/create_service.rb
+++ b/app/services/emails/create_service.rb
@@ -3,12 +3,11 @@
module Emails
class CreateService < ::Emails::BaseService
def execute(extra_params = {})
- skip_confirmation = @params.delete(:skip_confirmation)
+ skip_confirmation = params.delete(:skip_confirmation)
- email = @user.emails.create(@params.merge(extra_params))
-
- email&.confirm if skip_confirmation && current_user.admin?
- email
+ user.emails.create(params.merge(extra_params)).tap do |email|
+ email&.confirm if skip_confirmation && current_user.admin?
+ end
end
end
end
diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb
new file mode 100644
index 00000000000..86ab21fa865
--- /dev/null
+++ b/app/services/error_tracking/list_issues_service.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ListIssuesService < ::BaseService
+ DEFAULT_ISSUE_STATUS = 'unresolved'
+ DEFAULT_LIMIT = 20
+
+ def execute
+ return error('Error Tracking is not enabled') unless enabled?
+ return error('Access denied', :unauthorized) unless can_read?
+
+ result = project_error_tracking_setting
+ .list_sentry_issues(issue_status: issue_status, limit: limit)
+
+ # our results are not yet ready
+ unless result
+ return error('Not ready. Try again later', :no_content)
+ end
+
+ if result[:error].present?
+ return error(result[:error], http_status_from_error_type(result[:error_type]))
+ end
+
+ success(issues: result[:issues])
+ end
+
+ def external_url
+ project_error_tracking_setting&.sentry_external_url
+ end
+
+ private
+
+ def http_status_from_error_type(error_type)
+ case error_type
+ when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS
+ :internal_server_error
+ else
+ :bad_request
+ end
+ end
+
+ def project_error_tracking_setting
+ project.error_tracking_setting
+ end
+
+ def issue_status
+ params[:issue_status] || DEFAULT_ISSUE_STATUS
+ end
+
+ def limit
+ params[:limit] || DEFAULT_LIMIT
+ end
+
+ def enabled?
+ project_error_tracking_setting&.enabled?
+ end
+
+ def can_read?
+ can?(current_user, :read_sentry_issue, project)
+ end
+ end
+end
diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb
new file mode 100644
index 00000000000..8d08f0cda94
--- /dev/null
+++ b/app/services/error_tracking/list_projects_service.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module ErrorTracking
+ class ListProjectsService < ::BaseService
+ def execute
+ return error('access denied') unless can_read?
+
+ setting = project_error_tracking_setting
+
+ unless setting.valid?
+ return error(setting.errors.full_messages.join(', '), :bad_request)
+ end
+
+ begin
+ result = setting.list_sentry_projects
+ rescue Sentry::Client::Error => e
+ return error(e.message, :bad_request)
+ rescue Sentry::Client::MissingKeysError => e
+ return error(e.message, :internal_server_error)
+ end
+
+ success(projects: result[:projects])
+ end
+
+ private
+
+ def project_error_tracking_setting
+ (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting|
+ setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
+ api_host: params[:api_host],
+ organization_slug: 'org',
+ project_slug: 'proj'
+ )
+
+ setting.token = params[:token]
+ setting.enabled = true
+ end
+ end
+
+ def can_read?
+ can?(current_user, :read_sentry_issue, project)
+ end
+ end
+end
diff --git a/app/services/files/delete_service.rb b/app/services/files/delete_service.rb
index 0ec1f79d396..f47eb4fccd4 100644
--- a/app/services/files/delete_service.rb
+++ b/app/services/files/delete_service.rb
@@ -20,7 +20,7 @@ module Files
super
if file_has_changed?(@file_path, @last_commit_sha)
- raise FileChangedError, "You are attempting to delete a file that has been previously updated."
+ raise FileChangedError, _("You are attempting to delete a file that has been previously updated.")
end
end
end
diff --git a/app/services/files/multi_service.rb b/app/services/files/multi_service.rb
index 927634c2159..c1bc26c330a 100644
--- a/app/services/files/multi_service.rb
+++ b/app/services/files/multi_service.rb
@@ -46,7 +46,8 @@ module Files
author_email: @author_email,
author_name: @author_name,
start_project: @start_project,
- start_branch_name: @start_branch
+ start_branch_name: @start_branch,
+ force: force?
)
rescue ArgumentError => e
raise_error(e)
diff --git a/app/services/files/update_service.rb b/app/services/files/update_service.rb
index 2b3e96e6c53..54ab07da680 100644
--- a/app/services/files/update_service.rb
+++ b/app/services/files/update_service.rb
@@ -19,7 +19,7 @@ module Files
super
if file_has_changed?(@file_path, @last_commit_sha)
- raise FileChangedError, "You are attempting to update a file that has changed since you started editing it."
+ raise FileChangedError, _('You are attempting to update a file that has changed since you started editing it.')
end
end
end
diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb
new file mode 100644
index 00000000000..d30df34e54b
--- /dev/null
+++ b/app/services/git/base_hooks_service.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+module Git
+ class BaseHooksService < ::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ # The N most recent commits to process in a single push payload.
+ PROCESS_COMMIT_LIMIT = 100
+
+ def execute
+ project.repository.after_create if project.empty_repo?
+
+ create_events
+ create_pipelines
+ execute_project_hooks
+
+ # Not a hook, but it needs access to the list of changed commits
+ enqueue_invalidate_cache
+
+ update_remote_mirrors
+
+ push_data
+ end
+
+ private
+
+ def hook_name
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
+ end
+
+ def commits
+ raise NotImplementedError, "Please implement #{self.class}##{__method__}"
+ end
+
+ def limited_commits
+ commits.last(PROCESS_COMMIT_LIMIT)
+ end
+
+ def commits_count
+ commits.count
+ end
+
+ def event_message
+ nil
+ end
+
+ def invalidated_file_types
+ []
+ end
+
+ def create_events
+ EventCreateService.new.push(project, current_user, push_data)
+ end
+
+ def create_pipelines
+ return unless params.fetch(:create_pipelines, true)
+
+ Ci::CreatePipelineService
+ .new(project, current_user, push_data)
+ .execute(:push, pipeline_options)
+ end
+
+ def execute_project_hooks
+ project.execute_hooks(push_data, hook_name)
+ project.execute_services(push_data, hook_name)
+ end
+
+ def enqueue_invalidate_cache
+ ProjectCacheWorker.perform_async(
+ project.id,
+ invalidated_file_types,
+ [:commit_count, :repository_size]
+ )
+ end
+
+ def push_data
+ @push_data ||= Gitlab::DataBuilder::Push.build(
+ project: project,
+ user: current_user,
+ oldrev: params[:oldrev],
+ newrev: params[:newrev],
+ ref: params[:ref],
+ commits: limited_commits,
+ message: event_message,
+ commits_count: commits_count,
+ push_options: params[:push_options] || {}
+ )
+
+ # Dependent code may modify the push data, so return a duplicate each time
+ @push_data.dup
+ end
+
+ # to be overridden in EE
+ def pipeline_options
+ {}
+ end
+
+ def update_remote_mirrors
+ return unless project.has_remote_mirror?
+
+ project.mark_stuck_remote_mirrors_as_failed!
+ project.update_remote_mirrors
+ end
+ end
+end
diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb
new file mode 100644
index 00000000000..d21a6bb1b9a
--- /dev/null
+++ b/app/services/git/branch_hooks_service.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module Git
+ class BranchHooksService < ::Git::BaseHooksService
+ def execute
+ execute_branch_hooks
+
+ super.tap do
+ enqueue_update_gpg_signatures
+ end
+ end
+
+ private
+
+ def hook_name
+ :push_hooks
+ end
+
+ def commits
+ strong_memoize(:commits) do
+ if creating_default_branch?
+ # The most recent PROCESS_COMMIT_LIMIT commits in the default branch
+ offset = [count_commits_in_branch - PROCESS_COMMIT_LIMIT, 0].max
+ project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
+ elsif creating_branch?
+ # Use the pushed commits that aren't reachable by the default branch
+ # as a heuristic. This may include more commits than are actually
+ # pushed, but that shouldn't matter because we check for existing
+ # cross-references later.
+ project.repository.commits_between(project.default_branch, params[:newrev])
+ elsif updating_branch?
+ project.repository.commits_between(params[:oldrev], params[:newrev])
+ else # removing branch
+ []
+ end
+ end
+ end
+
+ def commits_count
+ return count_commits_in_branch if creating_default_branch?
+
+ super
+ end
+
+ def invalidated_file_types
+ return super unless default_branch? && !creating_branch?
+
+ paths = limited_commits.each_with_object(Set.new) do |commit, set|
+ commit.raw_deltas.each do |diff|
+ set << diff.new_path
+ end
+ end
+
+ Gitlab::FileDetector.types_in_paths(paths)
+ end
+
+ def execute_branch_hooks
+ project.repository.after_push_commit(branch_name)
+
+ branch_create_hooks if creating_branch?
+ branch_update_hooks if updating_branch?
+ branch_change_hooks if creating_branch? || updating_branch?
+ branch_remove_hooks if removing_branch?
+ end
+
+ def branch_create_hooks
+ project.repository.after_create_branch
+ project.after_create_default_branch if default_branch?
+ end
+
+ def branch_update_hooks
+ # Update the bare repositories info/attributes file using the contents of
+ # the default branch's .gitattributes file
+ project.repository.copy_gitattributes(params[:ref]) if default_branch?
+ end
+
+ def branch_change_hooks
+ enqueue_process_commit_messages
+ end
+
+ def branch_remove_hooks
+ project.repository.after_remove_branch
+ end
+
+ # Schedules processing of commit messages
+ def enqueue_process_commit_messages
+ # don't process commits for the initial push to the default branch
+ return if creating_default_branch?
+
+ limited_commits.each do |commit|
+ next unless commit.matches_cross_reference_regex?
+
+ ProcessCommitWorker.perform_async(
+ project.id,
+ current_user.id,
+ commit.to_hash,
+ default_branch?
+ )
+ end
+ end
+
+ def enqueue_update_gpg_signatures
+ unsigned = GpgSignature.unsigned_commit_shas(limited_commits.map(&:sha))
+ return if unsigned.empty?
+
+ signable = Gitlab::Git::Commit.shas_with_signatures(project.repository, unsigned)
+ return if signable.empty?
+
+ CreateGpgSignatureWorker.perform_async(signable, project.id)
+ end
+
+ def creating_branch?
+ Gitlab::Git.blank_ref?(params[:oldrev])
+ end
+
+ def updating_branch?
+ !creating_branch? && !removing_branch?
+ end
+
+ def removing_branch?
+ Gitlab::Git.blank_ref?(params[:newrev])
+ end
+
+ def creating_default_branch?
+ creating_branch? && default_branch?
+ end
+
+ def count_commits_in_branch
+ strong_memoize(:count_commits_in_branch) do
+ project.repository.commit_count_for_ref(params[:ref])
+ end
+ end
+
+ def default_branch?
+ strong_memoize(:default_branch) do
+ [nil, branch_name].include?(project.default_branch)
+ end
+ end
+
+ def branch_name
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ end
+ end
+end
diff --git a/app/services/git/branch_push_service.rb b/app/services/git/branch_push_service.rb
new file mode 100644
index 00000000000..c4910180787
--- /dev/null
+++ b/app/services/git/branch_push_service.rb
@@ -0,0 +1,92 @@
+# frozen_string_literal: true
+
+module Git
+ class BranchPushService < ::BaseService
+ include Gitlab::Access
+ include Gitlab::Utils::StrongMemoize
+
+ # This method will be called after each git update
+ # and only if the provided user and project are present in GitLab.
+ #
+ # All callbacks for post receive action should be placed here.
+ #
+ # Next, this method:
+ # 1. Creates the push event
+ # 2. Updates merge requests
+ # 3. Recognizes cross-references from commit messages
+ # 4. Executes the project's webhooks
+ # 5. Executes the project's services
+ # 6. Checks if the project's main language has changed
+ #
+ def execute
+ return unless Gitlab::Git.branch_ref?(params[:ref])
+
+ enqueue_update_mrs
+ enqueue_detect_repository_languages
+
+ execute_related_hooks
+ perform_housekeeping
+
+ stop_environments
+
+ true
+ end
+
+ # Update merge requests that may be affected by this push. A new branch
+ # could cause the last commit of a merge request to change.
+ def enqueue_update_mrs
+ UpdateMergeRequestsWorker.perform_async(
+ project.id,
+ current_user.id,
+ params[:oldrev],
+ params[:newrev],
+ params[:ref]
+ )
+ end
+
+ def enqueue_detect_repository_languages
+ return unless default_branch?
+
+ DetectRepositoryLanguagesWorker.perform_async(project.id)
+ end
+
+ # Only stop environments if the ref is a branch that is being deleted
+ def stop_environments
+ return unless removing_branch?
+
+ Ci::StopEnvironmentsService.new(project, current_user).execute(branch_name)
+ end
+
+ def update_remote_mirrors
+ return unless project.has_remote_mirror?
+
+ project.mark_stuck_remote_mirrors_as_failed!
+ project.update_remote_mirrors
+ end
+
+ def execute_related_hooks
+ BranchHooksService.new(project, current_user, params).execute
+ end
+
+ def perform_housekeeping
+ housekeeping = Projects::HousekeepingService.new(project)
+ housekeeping.increment!
+ housekeeping.execute if housekeeping.needed?
+ rescue Projects::HousekeepingService::LeaseTaken
+ end
+
+ def removing_branch?
+ Gitlab::Git.blank_ref?(params[:newrev])
+ end
+
+ def branch_name
+ strong_memoize(:branch_name) { Gitlab::Git.ref_name(params[:ref]) }
+ end
+
+ def default_branch?
+ strong_memoize(:default_branch) do
+ [nil, branch_name].include?(project.default_branch)
+ end
+ end
+ end
+end
diff --git a/app/services/git/tag_hooks_service.rb b/app/services/git/tag_hooks_service.rb
new file mode 100644
index 00000000000..18eb780579f
--- /dev/null
+++ b/app/services/git/tag_hooks_service.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+module Git
+ class TagHooksService < ::Git::BaseHooksService
+ private
+
+ def hook_name
+ :tag_push_hooks
+ end
+
+ def commits
+ [tag_commit].compact
+ end
+
+ def event_message
+ tag&.message
+ end
+
+ def tag
+ strong_memoize(:tag) do
+ next if Gitlab::Git.blank_ref?(params[:newrev])
+
+ tag_name = Gitlab::Git.ref_name(params[:ref])
+ tag = project.repository.find_tag(tag_name)
+
+ tag if tag && tag.target == params[:newrev]
+ end
+ end
+
+ def tag_commit
+ strong_memoize(:tag_commit) do
+ project.commit(tag.dereferenced_target) if tag
+ end
+ end
+ end
+end
diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb
new file mode 100644
index 00000000000..ee4166dccd0
--- /dev/null
+++ b/app/services/git/tag_push_service.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Git
+ class TagPushService < ::BaseService
+ def execute
+ return unless Gitlab::Git.tag_ref?(params[:ref])
+
+ project.repository.before_push_tag
+ TagHooksService.new(project, current_user, params).execute
+
+ true
+ end
+ end
+end
diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb
deleted file mode 100644
index f1883877d56..00000000000
--- a/app/services/git_push_service.rb
+++ /dev/null
@@ -1,233 +0,0 @@
-# frozen_string_literal: true
-
-class GitPushService < BaseService
- attr_accessor :push_data, :push_commits
- include Gitlab::Access
- include Gitlab::Utils::StrongMemoize
-
- # The N most recent commits to process in a single push payload.
- PROCESS_COMMIT_LIMIT = 100
-
- # This method will be called after each git update
- # and only if the provided user and project are present in GitLab.
- #
- # All callbacks for post receive action should be placed here.
- #
- # Next, this method:
- # 1. Creates the push event
- # 2. Updates merge requests
- # 3. Recognizes cross-references from commit messages
- # 4. Executes the project's webhooks
- # 5. Executes the project's services
- # 6. Checks if the project's main language has changed
- #
- def execute
- project.repository.after_create if project.empty_repo?
- project.repository.after_push_commit(branch_name)
-
- if push_remove_branch?
- project.repository.after_remove_branch
- @push_commits = []
- elsif push_to_new_branch?
- project.repository.after_create_branch
-
- # Re-find the pushed commits.
- if default_branch?
- # Initial push to the default branch. Take the full history of that branch as "newly pushed".
- process_default_branch
- else
- # Use the pushed commits that aren't reachable by the default branch
- # as a heuristic. This may include more commits than are actually pushed, but
- # that shouldn't matter because we check for existing cross-references later.
- @push_commits = project.repository.commits_between(project.default_branch, params[:newrev])
-
- # don't process commits for the initial push to the default branch
- process_commit_messages
- end
- elsif push_to_existing_branch?
- # Collect data for this git push
- @push_commits = project.repository.commits_between(params[:oldrev], params[:newrev])
-
- process_commit_messages
-
- # Update the bare repositories info/attributes file using the contents of the default branches
- # .gitattributes file
- update_gitattributes if default_branch?
- end
-
- execute_related_hooks
- perform_housekeeping
-
- update_remote_mirrors
- update_caches
-
- update_signatures
- end
-
- def update_gitattributes
- project.repository.copy_gitattributes(params[:ref])
- end
-
- def update_caches
- if default_branch?
- if push_to_new_branch?
- # If this is the initial push into the default branch, the file type caches
- # will already be reset as a result of `Project#change_head`.
- types = []
- else
- paths = Set.new
-
- last_pushed_commits.each do |commit|
- commit.raw_deltas.each do |diff|
- paths << diff.new_path
- end
- end
-
- types = Gitlab::FileDetector.types_in_paths(paths.to_a)
- end
-
- DetectRepositoryLanguagesWorker.perform_async(@project.id, current_user.id)
- else
- types = []
- end
-
- ProjectCacheWorker.perform_async(project.id, types, [:commit_count, :repository_size])
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def update_signatures
- commit_shas = last_pushed_commits.map(&:sha)
-
- return if commit_shas.empty?
-
- shas_with_cached_signatures = GpgSignature.where(commit_sha: commit_shas).pluck(:commit_sha)
- commit_shas -= shas_with_cached_signatures
-
- return if commit_shas.empty?
-
- commit_shas = Gitlab::Git::Commit.shas_with_signatures(project.repository, commit_shas)
-
- CreateGpgSignatureWorker.perform_async(commit_shas, project.id)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # Schedules processing of commit messages.
- def process_commit_messages
- default = default_branch?
-
- last_pushed_commits.each do |commit|
- if commit.matches_cross_reference_regex?
- ProcessCommitWorker
- .perform_async(project.id, current_user.id, commit.to_hash, default)
- end
- end
- end
-
- protected
-
- def update_remote_mirrors
- return unless project.has_remote_mirror?
-
- project.mark_stuck_remote_mirrors_as_failed!
- project.update_remote_mirrors
- end
-
- def execute_related_hooks
- # Update merge requests that may be affected by this push. A new branch
- # could cause the last commit of a merge request to change.
- #
- UpdateMergeRequestsWorker
- .perform_async(project.id, current_user.id, params[:oldrev], params[:newrev], params[:ref])
-
- EventCreateService.new.push(project, current_user, build_push_data)
- Ci::CreatePipelineService.new(project, current_user, build_push_data).execute(:push)
-
- project.execute_hooks(build_push_data.dup, :push_hooks)
- project.execute_services(build_push_data.dup, :push_hooks)
-
- if push_remove_branch?
- AfterBranchDeleteService
- .new(project, current_user)
- .execute(branch_name)
- end
- end
-
- def perform_housekeeping
- housekeeping = Projects::HousekeepingService.new(project)
- housekeeping.increment!
- housekeeping.execute if housekeeping.needed?
- rescue Projects::HousekeepingService::LeaseTaken
- end
-
- def process_default_branch
- offset = [push_commits_count_for_ref - PROCESS_COMMIT_LIMIT, 0].max
- @push_commits = project.repository.commits(params[:newrev], offset: offset, limit: PROCESS_COMMIT_LIMIT)
-
- project.after_create_default_branch
- end
-
- def build_push_data
- @push_data ||= Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- @push_commits,
- commits_count: commits_count)
- end
-
- def push_to_existing_branch?
- # Return if this is not a push to a branch (e.g. new commits)
- branch_ref? && !Gitlab::Git.blank_ref?(params[:oldrev])
- end
-
- def push_to_new_branch?
- strong_memoize(:push_to_new_branch) do
- branch_ref? && Gitlab::Git.blank_ref?(params[:oldrev])
- end
- end
-
- def push_remove_branch?
- strong_memoize(:push_remove_branch) do
- branch_ref? && Gitlab::Git.blank_ref?(params[:newrev])
- end
- end
-
- def default_branch?
- branch_ref? &&
- (branch_name == project.default_branch || project.default_branch.nil?)
- end
-
- def commit_user(commit)
- commit.author || current_user
- end
-
- def branch_name
- strong_memoize(:branch_name) do
- Gitlab::Git.ref_name(params[:ref])
- end
- end
-
- def branch_ref?
- strong_memoize(:branch_ref) do
- Gitlab::Git.branch_ref?(params[:ref])
- end
- end
-
- def commits_count
- return push_commits_count_for_ref if default_branch? && push_to_new_branch?
-
- Array(@push_commits).size
- end
-
- def push_commits_count_for_ref
- strong_memoize(:push_commits_count_for_ref) do
- project.repository.commit_count_for_ref(params[:ref])
- end
- end
-
- def last_pushed_commits
- @last_pushed_commits ||= @push_commits.last(PROCESS_COMMIT_LIMIT)
- end
-end
diff --git a/app/services/git_tag_push_service.rb b/app/services/git_tag_push_service.rb
deleted file mode 100644
index dbadafc0f52..00000000000
--- a/app/services/git_tag_push_service.rb
+++ /dev/null
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-class GitTagPushService < BaseService
- attr_accessor :push_data
-
- def execute
- project.repository.after_create if project.empty_repo?
- project.repository.before_push_tag
-
- @push_data = build_push_data
-
- EventCreateService.new.push(project, current_user, push_data)
- Ci::CreatePipelineService.new(project, current_user, push_data).execute(:push)
-
- SystemHooksService.new.execute_hooks(build_system_push_data, :tag_push_hooks)
- project.execute_hooks(push_data.dup, :tag_push_hooks)
- project.execute_services(push_data.dup, :tag_push_hooks)
-
- ProjectCacheWorker.perform_async(project.id, [], [:commit_count, :repository_size])
-
- true
- end
-
- private
-
- def build_push_data
- commits = []
- message = nil
-
- unless Gitlab::Git.blank_ref?(params[:newrev])
- tag_name = Gitlab::Git.ref_name(params[:ref])
- tag = project.repository.find_tag(tag_name)
-
- if tag && tag.target == params[:newrev]
- commit = project.commit(tag.dereferenced_target)
- commits = [commit].compact
- message = tag.message
- end
- end
-
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- commits,
- message)
- end
-
- def build_system_push_data
- Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- params[:oldrev],
- params[:newrev],
- params[:ref],
- [],
- '')
- end
-end
diff --git a/app/services/groups/auto_devops_service.rb b/app/services/groups/auto_devops_service.rb
new file mode 100644
index 00000000000..1925e0cc0ea
--- /dev/null
+++ b/app/services/groups/auto_devops_service.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Groups
+ class AutoDevopsService < Groups::BaseService
+ def execute
+ raise Gitlab::Access::AccessDeniedError unless can?(current_user, :admin_group, group)
+
+ group.update(auto_devops_enabled: auto_devops_enabled)
+ end
+
+ private
+
+ def auto_devops_enabled
+ params[:auto_devops_enabled]
+ end
+ end
+end
diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb
index 8c8acce5ca5..019cd047ae9 100644
--- a/app/services/groups/base_service.rb
+++ b/app/services/groups/base_service.rb
@@ -7,5 +7,11 @@ module Groups
def initialize(group, user, params = {})
@group, @current_user, @params = group, user, params.dup
end
+
+ private
+
+ def remove_unallowed_params
+ # overridden in EE
+ end
end
end
diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb
index 24d8400c625..e9659f5489a 100644
--- a/app/services/groups/create_service.rb
+++ b/app/services/groups/create_service.rb
@@ -8,8 +8,12 @@ module Groups
end
def execute
+ remove_unallowed_params
+
@group = Group.new(params)
+ after_build_hook(@group, params)
+
unless can_use_visibility_level? && can_create_group?
return @group
end
@@ -30,6 +34,10 @@ module Groups
private
+ def after_build_hook(group, params)
+ # overridden in EE
+ end
+
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
@@ -38,13 +46,13 @@ module Groups
if @group.subgroup?
unless can?(current_user, :create_subgroup, @group.parent)
@group.parent = nil
- @group.errors.add(:parent_id, 'You don’t have permission to create a subgroup in this group.')
+ @group.errors.add(:parent_id, s_('CreateGroup|You don’t have permission to create a subgroup in this group.'))
return false
end
else
unless can?(current_user, :create_group)
- @group.errors.add(:base, 'You don’t have permission to create groups.')
+ @group.errors.add(:base, s_('CreateGroup|You don’t have permission to create groups.'))
return false
end
@@ -54,12 +62,16 @@ module Groups
end
def can_use_visibility_level?
- unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level])
+ unless Gitlab::VisibilityLevel.allowed_for?(current_user, visibility_level)
deny_visibility_level(@group)
return false
end
true
end
+
+ def visibility_level
+ params[:visibility].present? ? Gitlab::VisibilityLevel.level_value(params[:visibility]) : params[:visibility_level]
+ end
end
end
diff --git a/app/services/groups/destroy_service.rb b/app/services/groups/destroy_service.rb
index 641111aeadc..654fe84e3dc 100644
--- a/app/services/groups/destroy_service.rb
+++ b/app/services/groups/destroy_service.rb
@@ -20,7 +20,7 @@ module Groups
end
# reload the relation to prevent triggering destroy hooks on the projects again
- group.projects.reload
+ group.projects.reset
group.children.each do |group|
# This needs to be synchronous since the namespace gets destroyed below
diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb
index 50d34d8cb91..01bd685712b 100644
--- a/app/services/groups/nested_create_service.rb
+++ b/app/services/groups/nested_create_service.rb
@@ -12,13 +12,13 @@ module Groups
end
def execute
- return nil unless group_path
+ return unless group_path
if namespace = namespace_or_group(group_path)
return namespace
end
- if group_path.include?('/') && !Group.supports_nested_groups?
+ if group_path.include?('/') && !Group.supports_nested_objects?
raise 'Nested groups are not supported on MySQL'
end
diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb
index 5efa746dfb9..98e7c311572 100644
--- a/app/services/groups/transfer_service.rb
+++ b/app/services/groups/transfer_service.rb
@@ -3,11 +3,11 @@
module Groups
class TransferService < Groups::BaseService
ERROR_MESSAGES = {
- database_not_supported: 'Database is not supported.',
- namespace_with_same_path: 'The parent group already has a subgroup with the same path.',
- group_is_already_root: 'Group is already a root group.',
- same_parent_as_current: 'Group is already associated to the parent group.',
- invalid_policies: "You don't have enough permissions."
+ database_not_supported: s_('TransferGroup|Database is not supported.'),
+ namespace_with_same_path: s_('TransferGroup|The parent group already has a subgroup with the same path.'),
+ group_is_already_root: s_('TransferGroup|Group is already a root group.'),
+ same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'),
+ invalid_policies: s_("TransferGroup|You don't have enough permissions.")
}.freeze
TransferError = Class.new(StandardError)
@@ -26,7 +26,7 @@ module Groups
rescue TransferError, ActiveRecord::RecordInvalid, Gitlab::UpdatePathError => e
@group.errors.clear
- @error = "Transfer failed: " + e.message
+ @error = s_("TransferGroup|Transfer failed: %{error_message}") % { error_message: e.message }
false
end
@@ -35,12 +35,15 @@ module Groups
def proceed_to_transfer
Group.transaction do
update_group_attributes
+ ensure_ownership
end
+
+ true
end
def ensure_allowed_transfer
raise_transfer_error(:group_is_already_root) if group_is_already_root?
- raise_transfer_error(:database_not_supported) unless Group.supports_nested_groups?
+ raise_transfer_error(:database_not_supported) unless Group.supports_nested_objects?
raise_transfer_error(:same_parent_as_current) if same_parent?
raise_transfer_error(:invalid_policies) unless valid_policies?
raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path?
@@ -95,6 +98,13 @@ module Groups
end
# rubocop: enable CodeReuse/ActiveRecord
+ def ensure_ownership
+ return if @new_parent_group
+ return unless @group.owners.empty?
+
+ @group.add_owner(current_user)
+ end
+
def raise_transfer_error(message)
raise TransferError, ERROR_MESSAGES[message]
end
diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb
index 0bf0e967dcc..73e1e00dc33 100644
--- a/app/services/groups/update_service.rb
+++ b/app/services/groups/update_service.rb
@@ -6,11 +6,14 @@ module Groups
def execute
reject_parent_id!
+ remove_unallowed_params
return false unless valid_visibility_level_change?(group, params[:visibility_level])
return false unless valid_share_with_group_lock_change?
+ before_assignment_hook(group, params)
+
group.assign_attributes(params)
begin
@@ -28,15 +31,19 @@ module Groups
private
+ def before_assignment_hook(group, params)
+ # overridden in EE
+ end
+
def after_update
if group.previous_changes.include?(:visibility_level) && group.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
- TodosDestroyer::GroupPrivateWorker.perform_in(1.hour, group.id)
+ TodosDestroyer::GroupPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, group.id)
end
end
def reject_parent_id!
- params.except!(:parent_id)
+ params.delete(:parent_id)
end
def valid_share_with_group_lock_change?
diff --git a/app/services/import/base_service.rb b/app/services/import/base_service.rb
new file mode 100644
index 00000000000..2683c75e41f
--- /dev/null
+++ b/app/services/import/base_service.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+module Import
+ class BaseService < ::BaseService
+ def initialize(client, user, params)
+ @client = client
+ @current_user = user
+ @params = params
+ end
+
+ private
+
+ def find_or_create_namespace(namespace, owner)
+ namespace = params[:target_namespace].presence || namespace
+
+ return current_user.namespace if namespace == owner
+
+ group = Groups::NestedCreateService.new(current_user, group_path: namespace).execute
+
+ group.errors.any? ? current_user.namespace : group
+ rescue => e
+ Gitlab::AppLogger.error(e)
+
+ current_user.namespace
+ end
+
+ def project_save_error(project)
+ project.errors.full_messages.join(', ')
+ end
+
+ def success(project)
+ super().merge(project: project, status: :success)
+ end
+ end
+end
diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb
new file mode 100644
index 00000000000..a322a306ba4
--- /dev/null
+++ b/app/services/import/github_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Import
+ class GithubService < Import::BaseService
+ attr_accessor :client
+ attr_reader :params, :current_user
+
+ def execute(access_params, provider)
+ unless authorized?
+ return error(_('This namespace has already been taken! Please choose another one.'), :unprocessable_entity)
+ end
+
+ project = Gitlab::LegacyGithubImport::ProjectCreator
+ .new(repo, project_name, target_namespace, current_user, access_params, type: provider)
+ .execute(extra_project_attrs)
+
+ if project.persisted?
+ success(project)
+ else
+ error(project_save_error(project), :unprocessable_entity)
+ end
+ end
+
+ def repo
+ @repo ||= client.repo(params[:repo_id].to_i)
+ end
+
+ def project_name
+ @project_name ||= params[:new_name].presence || repo.name
+ end
+
+ def namespace_path
+ @namespace_path ||= params[:target_namespace].presence || current_user.namespace_path
+ end
+
+ def target_namespace
+ @target_namespace ||= find_or_create_namespace(namespace_path, current_user.namespace_path)
+ end
+
+ def extra_project_attrs
+ {}
+ end
+
+ def authorized?
+ can?(current_user, :create_projects, target_namespace)
+ end
+ end
+end
diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb
index e1e0b75085d..00d7078859d 100644
--- a/app/services/issuable/clone/content_rewriter.rb
+++ b/app/services/issuable/clone/content_rewriter.rb
@@ -28,6 +28,7 @@ module Issuable
new_params = {
project: new_entity.project, noteable: new_entity,
note: rewrite_content(new_note.note),
+ note_html: nil,
created_at: note.created_at,
updated_at: note.updated_at
}
diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb
index 765de9c66b0..77f38f8882e 100644
--- a/app/services/issuable/common_system_notes_service.rb
+++ b/app/services/issuable/common_system_notes_service.rb
@@ -4,20 +4,23 @@ module Issuable
class CommonSystemNotesService < ::BaseService
attr_reader :issuable
- def execute(issuable, old_labels)
+ def execute(issuable, old_labels: [], is_update: true)
@issuable = issuable
- if issuable.previous_changes.include?('title')
- create_title_change_note(issuable.previous_changes['title'].first)
- end
+ if is_update
+ if issuable.previous_changes.include?('title')
+ create_title_change_note(issuable.previous_changes['title'].first)
+ end
- handle_description_change_note
+ handle_description_change_note
+
+ handle_time_tracking_note if issuable.is_a?(TimeTrackable)
+ create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
+ end
- handle_time_tracking_note if issuable.is_a?(TimeTrackable)
- create_labels_note(old_labels) if issuable.labels != old_labels
- create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
- create_milestone_note if issuable.previous_changes.include?('milestone_id')
create_due_date_note if issuable.previous_changes.include?('due_date')
+ create_milestone_note if issuable.previous_changes.include?('milestone_id')
+ create_labels_note(old_labels) if old_labels && issuable.labels != old_labels
end
private
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index e32e262ac31..26132f1824a 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -34,14 +34,20 @@ class IssuableBaseService < BaseService
end
def filter_assignee(issuable)
- return unless params[:assignee_id].present?
+ return if params[:assignee_ids].blank?
- assignee_id = params[:assignee_id]
+ unless issuable.allows_multiple_assignees?
+ params[:assignee_ids] = params[:assignee_ids].first(1)
+ end
+
+ assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
- if assignee_id.to_s == IssuableFinder::NONE
- params[:assignee_id] = ""
+ if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
+ params[:assignee_ids] = []
+ elsif assignee_ids.any?
+ params[:assignee_ids] = assignee_ids
else
- params.delete(:assignee_id) unless assignee_can_read?(issuable, assignee_id)
+ params.delete(:assignee_ids)
end
end
@@ -61,35 +67,37 @@ class IssuableBaseService < BaseService
return unless milestone_id
params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
- group_ids = project.group&.self_and_ancestors&.pluck(:id)
+ groups = project.group&.self_and_ancestors&.select(:id)
milestone =
- Milestone.for_projects_and_groups([project.id], group_ids).find_by_id(milestone_id)
+ Milestone.for_projects_and_groups([project.id], groups).find_by_id(milestone_id)
params[:milestone_id] = '' unless milestone
end
def filter_labels
- filter_labels_in_param(:add_label_ids)
- filter_labels_in_param(:remove_label_ids)
- filter_labels_in_param(:label_ids)
- find_or_create_label_ids
+ params[:add_label_ids] = labels_service.filter_labels_ids_in_param(:add_label_ids) if params[:add_label_ids]
+ params[:remove_label_ids] = labels_service.filter_labels_ids_in_param(:remove_label_ids) if params[:remove_label_ids]
+
+ if params[:label_ids]
+ params[:label_ids] = labels_service.filter_labels_ids_in_param(:label_ids)
+ elsif params[:labels]
+ params[:label_ids] = labels_service.find_or_create_by_titles.map(&:id)
+ end
end
- # rubocop: disable CodeReuse/ActiveRecord
def filter_labels_in_param(key)
return if params[key].to_a.empty?
- params[key] = available_labels.where(id: params[key]).pluck(:id)
+ params[key] = available_labels.id_in(params[key]).pluck_primary_key
end
- # rubocop: enable CodeReuse/ActiveRecord
def find_or_create_label_ids
labels = params.delete(:labels)
return unless labels
- params[:label_ids] = labels.split(",").map do |label_name|
+ params[:label_ids] = labels.map do |label_name|
label = Labels::FindOrCreateService.new(
current_user,
parent,
@@ -101,12 +109,17 @@ class IssuableBaseService < BaseService
end.compact
end
- def process_label_ids(attributes, existing_label_ids: nil)
+ def labels_service
+ @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
+ end
+
+ def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids)
new_label_ids = existing_label_ids || label_ids || []
+ new_label_ids |= extra_label_ids
if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids
@@ -115,11 +128,7 @@ class IssuableBaseService < BaseService
new_label_ids -= remove_label_ids if remove_label_ids
end
- new_label_ids
- end
-
- def available_labels
- @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute
+ new_label_ids.uniq
end
def handle_quick_actions_on_create(issuable)
@@ -145,13 +154,17 @@ class IssuableBaseService < BaseService
params.delete(:state_event)
params[:author] ||= current_user
- params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params)
+ params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a)
issuable.assign_attributes(params)
before_create(issuable)
if issuable.save
+ ActiveRecord::Base.no_touching do
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, is_update: false)
+ end
+
after_create(issuable)
execute_hooks(issuable)
invalidate_cache_counts(issuable, users: issuable.assignees)
@@ -207,7 +220,7 @@ class IssuableBaseService < BaseService
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
- Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_associations[:labels])
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: old_associations[:labels])
end
handle_changes(issuable, old_associations: old_associations)
@@ -231,6 +244,61 @@ class IssuableBaseService < BaseService
issuable
end
+ def update_task(issuable)
+ filter_params(issuable)
+
+ if issuable.changed? || params.present?
+ issuable.assign_attributes(params.merge(updated_by: current_user,
+ last_edited_at: Time.now,
+ last_edited_by: current_user))
+
+ before_update(issuable)
+
+ if issuable.with_transaction_returning_status { issuable.save }
+ # We do not touch as it will affect a update on updated_at field
+ ActiveRecord::Base.no_touching do
+ Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels: nil)
+ end
+
+ handle_task_changes(issuable)
+ invalidate_cache_counts(issuable, users: issuable.assignees.to_a)
+ after_update(issuable)
+ execute_hooks(issuable, 'update', old_associations: nil)
+ end
+ end
+
+ issuable
+ end
+
+ # Handle the `update_task` event sent from UI. Attempts to update a specific
+ # line in the markdown and cached html, bypassing any unnecessary updates or checks.
+ def update_task_event(issuable)
+ update_task_params = params.delete(:update_task)
+ return unless update_task_params
+
+ tasklist_toggler = TaskListToggleService.new(issuable.description, issuable.description_html,
+ line_source: update_task_params[:line_source],
+ line_number: update_task_params[:line_number].to_i,
+ toggle_as_checked: update_task_params[:checked])
+
+ unless tasklist_toggler.execute
+ # if we make it here, the data is much newer than we thought it was - fail fast
+ raise ActiveRecord::StaleObjectError
+ end
+
+ # by updating the description_html field at the same time,
+ # the markdown cache won't be considered invalid
+ params[:description] = tasklist_toggler.updated_markdown
+ params[:description_html] = tasklist_toggler.updated_markdown_html
+
+ # since we're updating a very specific line, we don't care whether
+ # the `lock_version` sent from the FE is the same or not. Just
+ # make sure the data hasn't changed since we queried it
+ params[:lock_version] = issuable.lock_version
+
+ update_task(issuable)
+ end
+
def labels_changing?(old_label_ids, new_label_ids)
old_label_ids.sort != new_label_ids.sort
end
@@ -290,7 +358,7 @@ class IssuableBaseService < BaseService
end
def has_changes?(issuable, old_labels: [], old_assignees: [])
- valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
+ valid_attrs = [:title, :description, :assignee_ids, :milestone_id, :target_branch]
attrs_changed = valid_attrs.any? do |attr|
issuable.previous_changes.include?(attr.to_s)
@@ -314,6 +382,10 @@ class IssuableBaseService < BaseService
end
# override if needed
+ def handle_task_changes(issuable)
+ end
+
+ # override if needed
def execute_hooks(issuable, action = 'open', params = {})
end
@@ -324,4 +396,10 @@ class IssuableBaseService < BaseService
def parent
project
end
+
+ # we need to check this because milestone from milestone_id param is displayed on "new" page
+ # where private project milestone could leak without this check
+ def ensure_milestone_available(issuable)
+ issuable.milestone_id = nil unless issuable.milestone_available?
+ end
end
diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb
index ef08adf4f92..48ed5afbc2a 100644
--- a/app/services/issues/base_service.rb
+++ b/app/services/issues/base_service.rb
@@ -20,7 +20,7 @@ module Issues
private
def create_assignee_note(issue, old_assignees)
- SystemNoteService.change_issue_assignees(
+ SystemNoteService.change_issuable_assignees(
issue, issue.project, current_user, old_assignees)
end
@@ -31,26 +31,6 @@ module Issues
issue.project.execute_services(issue_data, hooks_scope)
end
- # rubocop: disable CodeReuse/ActiveRecord
- def filter_assignee(issuable)
- return if params[:assignee_ids].blank?
-
- unless issuable.allows_multiple_assignees?
- params[:assignee_ids] = params[:assignee_ids].take(1)
- end
-
- assignee_ids = params[:assignee_ids].select { |assignee_id| assignee_can_read?(issuable, assignee_id) }
-
- if params[:assignee_ids].map(&:to_s) == [IssuableFinder::NONE]
- params[:assignee_ids] = []
- elsif assignee_ids.any?
- params[:assignee_ids] = assignee_ids
- else
- params.delete(:assignee_ids)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
def update_project_counter_caches?(issue)
super || issue.confidential_changed?
end
diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb
index 52b45f1b2ce..61615ac2058 100644
--- a/app/services/issues/build_service.rb
+++ b/app/services/issues/build_service.rb
@@ -6,7 +6,9 @@ module Issues
def execute
filter_resolve_discussion_params
- @issue = project.issues.new(issue_params)
+ @issue = project.issues.new(issue_params).tap do |issue|
+ ensure_milestone_available(issue)
+ end
end
def issue_params_with_info_from_discussions
@@ -57,9 +59,11 @@ module Issues
end
def issue_params
- @issue_params ||= issue_params_with_info_from_discussions.merge(whitelisted_issue_params)
+ @issue_params ||= build_issue_params
end
+ private
+
def whitelisted_issue_params
if can?(current_user, :admin_issue, project)
params.slice(:title, :description, :milestone_id)
@@ -67,5 +71,9 @@ module Issues
params.slice(:title, :description)
end
end
+
+ def build_issue_params
+ issue_params_with_info_from_discussions.merge(whitelisted_issue_params)
+ end
end
end
diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb
index e5cc12e6082..2a19e57a94f 100644
--- a/app/services/issues/close_service.rb
+++ b/app/services/issues/close_service.rb
@@ -7,7 +7,7 @@ module Issues
return issue unless can?(current_user, :update_issue, issue)
close_issue(issue,
- commit: commit,
+ closed_via: commit,
notifications: notifications,
system_note: system_note)
end
@@ -17,9 +17,9 @@ module Issues
#
# The code calling this method is responsible for ensuring that a user is
# allowed to close the given issue.
- def close_issue(issue, commit: nil, notifications: true, system_note: true)
+ def close_issue(issue, closed_via: nil, notifications: true, system_note: true)
if project.jira_tracker? && project.jira_service.active && issue.is_a?(ExternalIssue)
- project.jira_service.close_issue(commit, issue)
+ project.jira_service.close_issue(closed_via, issue)
todo_service.close_issue(issue, current_user)
return issue
end
@@ -27,8 +27,11 @@ module Issues
if project.issues_enabled? && issue.close
issue.update(closed_by: current_user)
event_service.close_issue(issue, current_user)
- create_note(issue, commit) if system_note
- notification_service.async.close_issue(issue, current_user) if notifications
+ create_note(issue, closed_via) if system_note
+
+ closed_via = "commit #{closed_via.id}" if closed_via.is_a?(Commit)
+
+ notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications
todo_service.close_issue(issue, current_user)
execute_hooks(issue, 'close')
invalidate_cache_counts(issue, users: issue.assignees)
diff --git a/app/services/issues/import_csv_service.rb b/app/services/issues/import_csv_service.rb
new file mode 100644
index 00000000000..ef08fafa7cc
--- /dev/null
+++ b/app/services/issues/import_csv_service.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+module Issues
+ class ImportCsvService
+ def initialize(user, project, csv_io)
+ @user = user
+ @project = project
+ @csv_io = csv_io
+ @results = { success: 0, error_lines: [], parse_error: false }
+ end
+
+ def execute
+ process_csv
+ email_results_to_user
+
+ @results
+ end
+
+ private
+
+ def process_csv
+ csv_data = @csv_io.open(&:read).force_encoding(Encoding::UTF_8)
+
+ CSV.new(csv_data, col_sep: detect_col_sep(csv_data.lines.first), headers: true).each.with_index(2) do |row, line_no|
+ issue = Issues::CreateService.new(@project, @user, title: row[0], description: row[1]).execute
+
+ if issue.persisted?
+ @results[:success] += 1
+ else
+ @results[:error_lines].push(line_no)
+ end
+ end
+ rescue ArgumentError, CSV::MalformedCSVError
+ @results[:parse_error] = true
+ end
+
+ def email_results_to_user
+ Notify.import_issues_csv_email(@user.id, @project.id, @results).deliver_now
+ end
+
+ def detect_col_sep(header)
+ if header.include?(",")
+ ","
+ elsif header.include?(";")
+ ";"
+ elsif header.include?("\t")
+ "\t"
+ else
+ raise CSV::MalformedCSVError
+ end
+ end
+ end
+end
diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb
index 41b6a96b005..334fadadb6f 100644
--- a/app/services/issues/move_service.rb
+++ b/app/services/issues/move_service.rb
@@ -8,11 +8,11 @@ module Issues
@target_project = target_project
unless issue.can_move?(current_user, @target_project)
- raise MoveError, 'Cannot move issue due to insufficient permissions!'
+ raise MoveError, s_('MoveIssue|Cannot move issue due to insufficient permissions!')
end
if @project == @target_project
- raise MoveError, 'Cannot move issue to project it originates from!'
+ raise MoveError, s_('MoveIssue|Cannot move issue to project it originates from!')
end
super
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index a1d0cc0e568..cb2337d29d4 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -8,7 +8,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- move_issue_to_new_project(issue) || update(issue)
+ move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
@@ -39,12 +39,12 @@ module Issues
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
notification_service.async.reassigned_issue(issue, current_user, old_assignees)
- todo_service.reassigned_issue(issue, current_user, old_assignees)
+ todo_service.reassigned_issuable(issue, current_user, old_assignees)
end
if issue.previous_changes.include?('confidential')
# don't enqueue immediately to prevent todos removal in case of a mistake
- TodosDestroyer::ConfidentialIssueWorker.perform_in(1.hour, issue.id) if issue.confidential?
+ TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, issue.id) if issue.confidential?
create_confidentiality_note(issue)
end
@@ -63,6 +63,11 @@ module Issues
end
end
+ def handle_task_changes(issuable)
+ todo_service.mark_pending_todos_as_done(issuable, current_user)
+ todo_service.update_issue(issuable, current_user)
+ end
+
def handle_move_between_ids(issue)
return unless params[:move_between_ids]
@@ -78,6 +83,8 @@ module Issues
# rubocop: disable CodeReuse/ActiveRecord
def change_issue_duplicate(issue)
canonical_issue_id = params.delete(:canonical_issue_id)
+ return unless canonical_issue_id
+
canonical_issue = IssuesFinder.new(current_user).find_by(id: canonical_issue_id)
if canonical_issue
diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb
new file mode 100644
index 00000000000..fe477d96970
--- /dev/null
+++ b/app/services/labels/available_labels_service.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+module Labels
+ class AvailableLabelsService
+ attr_reader :current_user, :parent, :params
+
+ def initialize(current_user, parent, params)
+ @current_user = current_user
+ @parent = parent
+ @params = params
+ end
+
+ def find_or_create_by_titles
+ labels = params.delete(:labels)
+
+ return [] unless labels
+
+ labels = labels.split(',') if labels.is_a?(String)
+
+ labels.map do |label_name|
+ label = Labels::FindOrCreateService.new(
+ current_user,
+ parent,
+ include_ancestor_groups: true,
+ title: label_name.strip,
+ available_labels: available_labels
+ ).execute
+
+ label
+ end.compact
+ end
+
+ def filter_labels_ids_in_param(key)
+ return [] if params[key].to_a.empty?
+
+ # rubocop:disable CodeReuse/ActiveRecord
+ available_labels.by_ids(params[key]).pluck(:id)
+ # rubocop:enable CodeReuse/ActiveRecord
+ end
+
+ private
+
+ def available_labels
+ @available_labels ||= LabelsFinder.new(current_user, finder_params).execute
+ end
+
+ def finder_params
+ params = { include_ancestor_groups: true }
+
+ case parent
+ when Group
+ params[:group_id] = parent.id
+ params[:only_group_labels] = true
+ when Project
+ params[:project_id] = parent.id
+ end
+
+ params
+ end
+ end
+end
diff --git a/app/services/labels/create_service.rb b/app/services/labels/create_service.rb
index fe34be41ac1..db710bac900 100644
--- a/app/services/labels/create_service.rb
+++ b/app/services/labels/create_service.rb
@@ -3,7 +3,7 @@
module Labels
class CreateService < Labels::BaseService
def initialize(params = {})
- @params = params.dup.with_indifferent_access
+ @params = params.to_h.dup.with_indifferent_access
end
# returns the created label
diff --git a/app/services/labels/promote_service.rb b/app/services/labels/promote_service.rb
index 3c0e6196d4f..e73e6476c12 100644
--- a/app/services/labels/promote_service.rb
+++ b/app/services/labels/promote_service.rb
@@ -49,7 +49,7 @@ module Labels
.new(current_user, title: new_label.title, group_id: project.group.id)
.execute(skip_authorization: true)
.where.not(id: new_label)
- .select(:id) # Can't use pluck() to avoid object-creation because of the batching
+ .select(:id) # Can't use pluck() to avoid object-creation because of the batching
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/services/labels/update_service.rb b/app/services/labels/update_service.rb
index c3a720a1c66..be33947d0eb 100644
--- a/app/services/labels/update_service.rb
+++ b/app/services/labels/update_service.rb
@@ -3,11 +3,12 @@
module Labels
class UpdateService < Labels::BaseService
def initialize(params = {})
- @params = params.dup.with_indifferent_access
+ @params = params.to_h.dup.with_indifferent_access
end
# returns the updated label
def execute(label)
+ params[:name] = params.delete(:new_name) if params.key?(:new_name)
params[:color] = convert_color_name_to_hex if params[:color].present?
label.update(params)
diff --git a/app/services/lfs/file_transformer.rb b/app/services/lfs/file_transformer.rb
index 6ecf583cb6a..5239fe1b6e3 100644
--- a/app/services/lfs/file_transformer.rb
+++ b/app/services/lfs/file_transformer.rb
@@ -24,7 +24,7 @@ module Lfs
def new_file(file_path, file_content, encoding: nil)
if project.lfs_enabled? && lfs_file?(file_path)
- file_content = Base64.decode64(file_content) if encoding == 'base64'
+ file_content = parse_file_content(file_content, encoding: encoding)
lfs_pointer_file = Gitlab::Git::LfsPointerFile.new(file_content)
lfs_object = create_lfs_object!(lfs_pointer_file, file_content)
@@ -66,5 +66,12 @@ module Lfs
def link_lfs_object!(lfs_object)
project.lfs_objects << lfs_object
end
+
+ def parse_file_content(file_content, encoding: nil)
+ return file_content.read if file_content.respond_to?(:read)
+ return Base64.decode64(file_content) if encoding == 'base64'
+
+ file_content
+ end
end
end
diff --git a/app/services/lfs/locks_finder_service.rb b/app/services/lfs/locks_finder_service.rb
index 4a5b2a52921..192ce3d3c2a 100644
--- a/app/services/lfs/locks_finder_service.rb
+++ b/app/services/lfs/locks_finder_service.rb
@@ -12,7 +12,7 @@ module Lfs
# rubocop: disable CodeReuse/ActiveRecord
def find_locks
- options = params.slice(:id, :path).compact.symbolize_keys
+ options = params.slice(:id, :path).to_h.compact.symbolize_keys
project.lfs_file_locks.where(options)
end
diff --git a/app/services/members/base_service.rb b/app/services/members/base_service.rb
index d734571f835..e78affff797 100644
--- a/app/services/members/base_service.rb
+++ b/app/services/members/base_service.rb
@@ -47,5 +47,11 @@ module Members
raise "Unknown action '#{action}' on #{member}!"
end
end
+
+ def enqueue_delete_todos(member)
+ type = member.is_a?(GroupMember) ? 'Group' : 'Project'
+ # don't enqueue immediately to prevent todos removal in case of a mistake
+ TodosDestroyer::EntityLeaveWorker.perform_in(Todo::WAIT_FOR_DELETE, member.user_id, member.source_id, type)
+ end
end
end
diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb
index 714b8586737..d6b17ec10be 100644
--- a/app/services/members/create_service.rb
+++ b/app/services/members/create_service.rb
@@ -5,11 +5,11 @@ module Members
DEFAULT_LIMIT = 100
def execute(source)
- return error('No users specified.') if params[:user_ids].blank?
+ return error(s_('AddMember|No users specified.')) if params[:user_ids].blank?
user_ids = params[:user_ids].split(',').uniq
- return error("Too many users specified (limit is #{user_limit})") if
+ return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
user_limit && user_ids.size > user_limit
members = source.add_users(
@@ -19,9 +19,28 @@ module Members
current_user: current_user
)
- members.each { |member| after_execute(member: member) }
+ errors = []
- success
+ members.each do |member|
+ if member.errors.any?
+ current_error =
+ # Invited users may not have an associated user
+ if member.user.present?
+ "#{member.user.username}: "
+ else
+ ""
+ end
+
+ current_error += member.errors.full_messages.to_sentence
+ errors << current_error
+ else
+ after_execute(member: member)
+ end
+ end
+
+ return success unless errors.any?
+
+ error(errors.to_sentence)
end
private
diff --git a/app/services/members/destroy_service.rb b/app/services/members/destroy_service.rb
index c186a5971dc..c8d5e563cd8 100644
--- a/app/services/members/destroy_service.rb
+++ b/app/services/members/destroy_service.rb
@@ -2,9 +2,11 @@
module Members
class DestroyService < Members::BaseService
- def execute(member, skip_authorization: false)
+ def execute(member, skip_authorization: false, skip_subresources: false)
raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_destroy_member?(member)
+ @skip_auth = skip_authorization
+
return member if member.is_a?(GroupMember) && member.source.last_owner?(member.user)
member.destroy
@@ -15,7 +17,8 @@ module Members
notification_service.decline_access_request(member)
end
- enqeue_delete_todos(member)
+ delete_subresources(member) unless skip_subresources
+ enqueue_delete_todos(member)
after_execute(member: member)
@@ -24,10 +27,27 @@ module Members
private
- def enqeue_delete_todos(member)
- type = member.is_a?(GroupMember) ? 'Group' : 'Project'
- # don't enqueue immediately to prevent todos removal in case of a mistake
- TodosDestroyer::EntityLeaveWorker.perform_in(1.hour, member.user_id, member.source_id, type)
+ def delete_subresources(member)
+ return unless member.is_a?(GroupMember) && member.user && member.group
+
+ delete_project_members(member)
+ delete_subgroup_members(member) if Group.supports_nested_objects?
+ end
+
+ def delete_project_members(member)
+ groups = member.group.self_and_descendants
+
+ ProjectMember.in_namespaces(groups).with_user(member.user).each do |project_member|
+ self.class.new(current_user).execute(project_member, skip_authorization: @skip_auth)
+ end
+ end
+
+ def delete_subgroup_members(member)
+ groups = member.group.descendants
+
+ GroupMember.of_groups(groups).with_user(member.user).each do |group_member|
+ self.class.new(current_user).execute(group_member, skip_authorization: @skip_auth, skip_subresources: true)
+ end
end
def can_destroy_member?(member)
diff --git a/app/services/members/update_service.rb b/app/services/members/update_service.rb
index 1f5618dae53..ff8d5c1d8c9 100644
--- a/app/services/members/update_service.rb
+++ b/app/services/members/update_service.rb
@@ -10,9 +10,18 @@ module Members
if member.update(params)
after_execute(action: permission, old_access_level: old_access_level, member: member)
+
+ # Deletes only confidential issues todos for guests
+ enqueue_delete_todos(member) if downgrading_to_guest?
end
member
end
+
+ private
+
+ def downgrading_to_guest?
+ params[:access_level] == Gitlab::Access::GUEST
+ end
end
end
diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb
index 79c43b8e7d5..d3ef892875b 100644
--- a/app/services/merge_requests/add_todo_when_build_fails_service.rb
+++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb
@@ -7,7 +7,7 @@ module MergeRequests
def execute(commit_status)
return if commit_status.allow_failure? || commit_status.retried?
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_failed(merge_request)
end
end
@@ -16,7 +16,7 @@ module MergeRequests
# build is retried
#
def close(commit_status)
- commit_status_merge_requests(commit_status) do |merge_request|
+ pipeline_merge_requests(commit_status.pipeline) do |merge_request|
todo_service.merge_request_build_retried(merge_request)
end
end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index fe19abf50f6..bb9062e9b40 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -24,6 +24,11 @@ module MergeRequests
end
end
+ def cleanup_environments(merge_request)
+ Ci::StopEnvironmentsService.new(merge_request.source_project, current_user)
+ .execute_for_merge_request(merge_request)
+ end
+
private
def handle_wip_event(merge_request)
@@ -49,27 +54,42 @@ module MergeRequests
MergeRequestMetricsService.new(merge_request.metrics)
end
- def create_assignee_note(merge_request)
- SystemNoteService.change_assignee(
- merge_request, merge_request.project, current_user, merge_request.assignee)
+ def create_assignee_note(merge_request, old_assignees)
+ SystemNoteService.change_issuable_assignees(
+ merge_request, merge_request.project, current_user, old_assignees)
+ end
+
+ def create_pipeline_for(merge_request, user)
+ return unless can_create_pipeline_for?(merge_request)
+
+ create_detached_merge_request_pipeline(merge_request, user)
end
- def create_merge_request_pipeline(merge_request, user)
- return unless Feature.enabled?(:ci_merge_request_pipeline,
- merge_request.source_project,
- default_enabled: true)
+ def create_detached_merge_request_pipeline(merge_request, user)
+ if can_use_merge_request_ref?(merge_request)
+ Ci::CreatePipelineService.new(merge_request.source_project, user,
+ ref: merge_request.ref_path)
+ .execute(:merge_request_event, merge_request: merge_request)
+ else
+ Ci::CreatePipelineService.new(merge_request.source_project, user,
+ ref: merge_request.source_branch)
+ .execute(:merge_request_event, merge_request: merge_request)
+ end
+ end
+ def can_create_pipeline_for?(merge_request)
##
# UpdateMergeRequestsWorker could be retried by an exception.
- # MR pipelines should not be recreated in such case.
- return if merge_request.merge_request_pipeline_exists?
-
- Ci::CreatePipelineService
- .new(merge_request.source_project, user, ref: merge_request.source_branch)
- .execute(:merge_request,
- ignore_skip_ci: true,
- save_on_errors: false,
- merge_request: merge_request)
+ # pipelines for merge request should not be recreated in such case.
+ return false if merge_request.find_actual_head_pipeline&.triggered_by_merge_request?
+ return false if merge_request.has_no_commits?
+
+ true
+ end
+
+ def can_use_merge_request_ref?(merge_request)
+ Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) &&
+ !merge_request.for_fork?
end
# Returns all origin and fork merge requests from `@project` satisfying passed arguments.
@@ -84,22 +104,11 @@ module MergeRequests
# rubocop: enable CodeReuse/ActiveRecord
def pipeline_merge_requests(pipeline)
- merge_requests_for(pipeline.ref).each do |merge_request|
+ pipeline.all_merge_requests.opened.each do |merge_request|
next unless pipeline == merge_request.head_pipeline
yield merge_request
end
end
-
- def commit_status_merge_requests(commit_status)
- merge_requests_for(commit_status.ref).each do |merge_request|
- pipeline = merge_request.head_pipeline
-
- next unless pipeline
- next unless pipeline.sha == commit_status.sha
-
- yield merge_request
- end
- end
end
end
diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb
index 36767621d74..109c964e577 100644
--- a/app/services/merge_requests/build_service.rb
+++ b/app/services/merge_requests/build_service.rb
@@ -18,7 +18,8 @@ module MergeRequests
merge_request.source_project = find_source_project
merge_request.target_project = find_target_project
merge_request.target_branch = find_target_branch
- merge_request.can_be_created = branches_valid?
+ merge_request.can_be_created = projects_and_branches_valid?
+ ensure_milestone_available(merge_request)
# compare branches only if branches are valid, otherwise
# compare_branches may raise an error
@@ -49,15 +50,19 @@ module MergeRequests
to: :merge_request
def find_source_project
- return source_project if source_project.present? && can?(current_user, :read_project, source_project)
+ return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project)
project
end
def find_target_project
- return target_project if target_project.present? && can?(current_user, :read_project, target_project)
+ return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project)
- project.default_merge_request_target
+ target_project = project.default_merge_request_target
+
+ return target_project if target_project.present? && can?(current_user, :create_merge_request_in, target_project)
+
+ project
end
def find_target_branch
@@ -72,10 +77,11 @@ module MergeRequests
params[:target_branch].present?
end
- def branches_valid?
+ def projects_and_branches_valid?
+ return false if source_project.nil? || target_project.nil?
return false unless source_branch_specified? || target_branch_specified?
- validate_branches
+ validate_projects_and_branches
errors.blank?
end
@@ -94,7 +100,12 @@ module MergeRequests
end
end
- def validate_branches
+ def validate_projects_and_branches
+ merge_request.validate_target_project
+ merge_request.validate_fork
+
+ return if errors.any?
+
add_error('You must select source and target branch') unless branches_present?
add_error('You must select different branches') if same_source_and_target?
add_error("Source branch \"#{source_branch}\" does not exist") unless source_branch_exists?
diff --git a/app/services/merge_requests/close_service.rb b/app/services/merge_requests/close_service.rb
index 04527bb9713..e77051bb1c9 100644
--- a/app/services/merge_requests/close_service.rb
+++ b/app/services/merge_requests/close_service.rb
@@ -17,6 +17,7 @@ module MergeRequests
execute_hooks(merge_request, 'close')
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
+ cleanup_environments(merge_request)
end
merge_request
diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb
index 7bb9fa60515..06e46595b95 100644
--- a/app/services/merge_requests/create_service.rb
+++ b/app/services/merge_requests/create_service.rb
@@ -25,8 +25,8 @@ module MergeRequests
def after_create(issuable)
todo_service.new_merge_request(issuable, current_user)
issuable.cache_merge_request_closes_issues!(current_user)
- create_merge_request_pipeline(issuable, current_user)
- update_merge_requests_head_pipeline(issuable)
+ create_pipeline_for(issuable, current_user)
+ issuable.update_head_pipeline
super
end
@@ -45,20 +45,6 @@ module MergeRequests
private
- def update_merge_requests_head_pipeline(merge_request)
- pipeline = head_pipeline_for(merge_request)
- merge_request.update(head_pipeline_id: pipeline.id) if pipeline
- end
-
- def head_pipeline_for(merge_request)
- return unless merge_request.source_project
-
- sha = merge_request.source_branch_sha
- return unless sha
-
- merge_request.all_pipelines(shas: sha).first
- end
-
def set_projects!
# @project is used to determine whether the user can set the merge request's
# assignee, milestone and labels. Whether they can depends on their
diff --git a/app/services/merge_requests/delete_non_latest_diffs_service.rb b/app/services/merge_requests/delete_non_latest_diffs_service.rb
index d5929446122..bdb7ec8a7c2 100644
--- a/app/services/merge_requests/delete_non_latest_diffs_service.rb
+++ b/app/services/merge_requests/delete_non_latest_diffs_service.rb
@@ -8,15 +8,13 @@ module MergeRequests
@merge_request = merge_request
end
- # rubocop: disable CodeReuse/ActiveRecord
def execute
diffs = @merge_request.non_latest_diffs.with_files
diffs.each_batch(of: BATCH_SIZE) do |relation, index|
- ids = relation.pluck(:id).map { |id| [id] }
+ ids = relation.pluck_primary_key.map { |id| [id] }
DeleteDiffFilesWorker.bulk_perform_in(index * 5.minutes, ids)
end
end
- # rubocop: enable CodeReuse/ActiveRecord
end
end
diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb
new file mode 100644
index 00000000000..095bdca5472
--- /dev/null
+++ b/app/services/merge_requests/merge_base_service.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MergeBaseService < MergeRequests::BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ MergeError = Class.new(StandardError)
+
+ attr_reader :merge_request
+
+ # Overridden in EE.
+ def hooks_validation_pass?(_merge_request)
+ true
+ end
+
+ # Overridden in EE.
+ def hooks_validation_error(_merge_request)
+ # No-op
+ end
+
+ def source
+ if merge_request.squash
+ squash_sha!
+ else
+ merge_request.diff_head_sha
+ end
+ end
+
+ private
+
+ # Overridden in EE.
+ def error_check!
+ # No-op
+ end
+
+ def raise_error(message)
+ raise MergeError, message
+ end
+
+ def handle_merge_error(*args)
+ # No-op
+ end
+
+ def commit_message
+ params[:commit_message] ||
+ merge_request.default_merge_commit_message
+ end
+
+ def squash_sha!
+ strong_memoize(:squash_sha) do
+ params[:merge_request] = merge_request
+ squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute
+
+ case squash_result[:status]
+ when :success
+ squash_result[:squash_sha]
+ when :error
+ raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb
index 70a67baa01c..d8a78001b79 100644
--- a/app/services/merge_requests/merge_service.rb
+++ b/app/services/merge_requests/merge_service.rb
@@ -7,11 +7,7 @@ module MergeRequests
# mark merge request as merged and execute all hooks and notifications
# Executed when you do merge via GitLab UI
#
- class MergeService < MergeRequests::BaseService
- MergeError = Class.new(StandardError)
-
- attr_reader :merge_request, :source
-
+ class MergeService < MergeRequests::MergeBaseService
delegate :merge_jid, :state, to: :@merge_request
def execute(merge_request)
@@ -22,7 +18,7 @@ module MergeRequests
@merge_request = merge_request
- error_check!
+ validate!
merge_request.in_locked_state do
if commit
@@ -36,27 +32,22 @@ module MergeRequests
handle_merge_error(log_message: e.message, save_message_on_model: true)
end
- def source
- return merge_request.diff_head_sha unless merge_request.squash
-
- squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request)
+ private
- case squash_result[:status]
- when :success
- squash_result[:squash_sha]
- when :error
- raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
- end
+ def validate!
+ authorization_check!
+ error_check!
end
- # Overridden in EE.
- def hooks_validation_pass?(_merge_request)
- true
+ def authorization_check!
+ unless @merge_request.can_be_merged_by?(current_user)
+ raise_error('You are not allowed to merge this merge request')
+ end
end
- private
-
def error_check!
+ super
+
error =
if @merge_request.should_be_rebased?
'Only fast-forward merge is allowed for your project. Please update your source branch'
@@ -66,7 +57,7 @@ module MergeRequests
'No source for merge'
end
- raise MergeError, error if error
+ raise_error(error) if error
end
def commit
@@ -76,22 +67,20 @@ module MergeRequests
if commit_id
log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}")
else
- raise MergeError, 'Conflicts detected during merge'
+ raise_error('Conflicts detected during merge')
end
merge_request.update!(merge_commit_sha: commit_id)
end
def try_merge
- message = params[:commit_message] || merge_request.merge_commit_message
-
- repository.merge(current_user, source, merge_request, message)
+ repository.merge(current_user, source, merge_request, commit_message)
rescue Gitlab::Git::PreReceiveError => e
- handle_merge_error(log_message: e.message)
- raise MergeError, 'Something went wrong during merge pre-receive hook'
+ raise MergeError,
+ "Something went wrong during merge pre-receive hook. #{e.message}".strip
rescue => e
handle_merge_error(log_message: e.message)
- raise MergeError, 'Something went wrong during merge'
+ raise_error('Something went wrong during merge')
ensure
merge_request.update!(in_progress_merge_commit_sha: nil)
end
diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb
new file mode 100644
index 00000000000..87147d90c32
--- /dev/null
+++ b/app/services/merge_requests/merge_to_ref_service.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ # Performs the merge between source SHA and the target branch. Instead
+ # of writing the result to the MR target branch, it targets the `target_ref`.
+ #
+ # Ideally this should leave the `target_ref` state with the same state the
+ # target branch would have if we used the regular `MergeService`, but without
+ # every side-effect that comes with it (MR updates, mails, source branch
+ # deletion, etc). This service should be kept idempotent (i.e. can
+ # be executed regardless of the `target_ref` current state).
+ #
+ class MergeToRefService < MergeRequests::MergeBaseService
+ def execute(merge_request)
+ @merge_request = merge_request
+
+ validate!
+
+ commit_id = commit
+
+ raise_error('Conflicts detected during merge') unless commit_id
+
+ commit = project.commit(commit_id)
+ target_id, source_id = commit.parent_ids
+
+ success(commit_id: commit.id,
+ target_id: target_id,
+ source_id: source_id)
+ rescue MergeError => error
+ error(error.message)
+ end
+
+ private
+
+ def validate!
+ authorization_check!
+ error_check!
+ end
+
+ def error_check!
+ super
+
+ error =
+ if !hooks_validation_pass?(merge_request)
+ hooks_validation_error(merge_request)
+ elsif !@merge_request.mergeable_to_ref?
+ "Merge request is not mergeable to #{target_ref}"
+ elsif !source
+ 'No source for merge'
+ end
+
+ raise_error(error) if error
+ end
+
+ def authorization_check!
+ unless Ability.allowed?(current_user, :admin_merge_request, project)
+ raise_error("You are not allowed to merge to this ref")
+ end
+ end
+
+ def target_ref
+ merge_request.merge_ref_path
+ end
+
+ def commit
+ repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message)
+ rescue Gitlab::Git::PreReceiveError => error
+ raise MergeError, error.message
+ end
+ end
+end
diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb
new file mode 100644
index 00000000000..16050244637
--- /dev/null
+++ b/app/services/merge_requests/migrate_external_diffs_service.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class MigrateExternalDiffsService < ::BaseService
+ MAX_JOBS = 1000.freeze
+
+ attr_reader :diff
+
+ def self.enqueue!
+ ids = MergeRequestDiff.ids_for_external_storage_migration(limit: MAX_JOBS)
+
+ MigrateExternalDiffsWorker.bulk_perform_async(ids.map { |id| [id] })
+ end
+
+ def initialize(merge_request_diff)
+ @diff = merge_request_diff
+ end
+
+ def execute
+ diff.migrate_files_to_external_storage!
+ end
+ end
+end
diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb
index f26e3bee06f..c13f7dd5088 100644
--- a/app/services/merge_requests/post_merge_service.rb
+++ b/app/services/merge_requests/post_merge_service.rb
@@ -18,6 +18,7 @@ module MergeRequests
invalidate_cache_counts(merge_request, users: merge_request.assignees)
merge_request.update_project_counter_caches
delete_non_latest_diffs(merge_request)
+ cleanup_environments(merge_request)
end
private
diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb
new file mode 100644
index 00000000000..a24163331e8
--- /dev/null
+++ b/app/services/merge_requests/push_options_handler_service.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+module MergeRequests
+ class PushOptionsHandlerService
+ LIMIT = 10
+
+ attr_reader :branches, :changes_by_branch, :current_user, :errors,
+ :project, :push_options, :target_project
+
+ def initialize(project, current_user, changes, push_options)
+ @project = project
+ @target_project = @project.default_merge_request_target
+ @current_user = current_user
+ @branches = get_branches(changes)
+ @push_options = push_options
+ @errors = []
+ end
+
+ def execute
+ validate_service
+ return self if errors.present?
+
+ branches.each do |branch|
+ execute_for_branch(branch)
+ rescue Gitlab::Access::AccessDeniedError
+ errors << 'User access was denied'
+ rescue StandardError => e
+ Gitlab::AppLogger.error(e)
+ errors << 'An unknown error occurred'
+ end
+
+ self
+ end
+
+ private
+
+ def get_branches(raw_changes)
+ Gitlab::ChangesList.new(raw_changes).map do |changes|
+ next unless Gitlab::Git.branch_ref?(changes[:ref])
+
+ # Deleted branch
+ next if Gitlab::Git.blank_ref?(changes[:newrev])
+
+ # Default branch
+ branch_name = Gitlab::Git.branch_name(changes[:ref])
+ next if branch_name == target_project.default_branch
+
+ branch_name
+ end.compact.uniq
+ end
+
+ def validate_service
+ errors << 'User is required' if current_user.nil?
+
+ unless target_project.merge_requests_enabled?
+ errors << "Merge requests are not enabled for project #{target_project.full_path}"
+ end
+
+ if branches.size > LIMIT
+ errors << "Too many branches pushed (#{branches.size} were pushed, limit is #{LIMIT})"
+ end
+
+ if push_options[:target] && !target_project.repository.branch_exists?(push_options[:target])
+ errors << "Branch #{push_options[:target]} does not exist"
+ end
+ end
+
+ # Returns a Hash of branch => MergeRequest
+ def merge_requests
+ @merge_requests ||= MergeRequest.from_project(target_project)
+ .opened
+ .from_source_branches(branches)
+ .index_by(&:source_branch)
+ end
+
+ def execute_for_branch(branch)
+ merge_request = merge_requests[branch]
+
+ if merge_request
+ update!(merge_request)
+ else
+ create!(branch)
+ end
+ end
+
+ def create!(branch)
+ unless push_options[:create]
+ errors << "A merge_request.create push option is required to create a merge request for branch #{branch}"
+ return
+ end
+
+ # Use BuildService to assign the standard attributes of a merge request
+ merge_request = ::MergeRequests::BuildService.new(
+ project,
+ current_user,
+ create_params(branch)
+ ).execute
+
+ unless merge_request.errors.present?
+ merge_request = ::MergeRequests::CreateService.new(
+ project,
+ current_user,
+ merge_request.attributes.merge(assignees: merge_request.assignees)
+ ).execute
+ end
+
+ collect_errors_from_merge_request(merge_request) unless merge_request.persisted?
+ end
+
+ def update!(merge_request)
+ merge_request = ::MergeRequests::UpdateService.new(
+ target_project,
+ current_user,
+ update_params
+ ).execute(merge_request)
+
+ collect_errors_from_merge_request(merge_request) unless merge_request.valid?
+ end
+
+ def create_params(branch)
+ params = {
+ assignees: [current_user],
+ source_branch: branch,
+ source_project: project,
+ target_branch: push_options[:target] || target_project.default_branch,
+ target_project: target_project
+ }
+
+ if push_options.key?(:merge_when_pipeline_succeeds)
+ params.merge!(
+ merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds],
+ merge_user: current_user
+ )
+ end
+
+ params
+ end
+
+ def update_params
+ params = {}
+
+ if push_options.key?(:merge_when_pipeline_succeeds)
+ params.merge!(
+ merge_when_pipeline_succeeds: push_options[:merge_when_pipeline_succeeds],
+ merge_user: current_user
+ )
+ end
+
+ if push_options.key?(:target)
+ params[:target_branch] = push_options[:target]
+ end
+
+ params
+ end
+
+ def collect_errors_from_merge_request(merge_request)
+ merge_request.errors.full_messages.each do |error|
+ errors << error
+ end
+ end
+ end
+end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index 31b3ebf311e..4b9921c28ba 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -20,17 +20,7 @@ module MergeRequests
return false
end
- log_prefix = "#{self.class.name} info (#{merge_request.to_reference(full: true)}):"
-
- Gitlab::GitLogger.info("#{log_prefix} rebase started")
-
- rebase_sha = repository.rebase(current_user, merge_request)
-
- Gitlab::GitLogger.info("#{log_prefix} rebased to #{rebase_sha}")
-
- merge_request.update(rebase_commit_sha: rebase_sha)
-
- Gitlab::GitLogger.info("#{log_prefix} rebase SHA saved: #{rebase_sha}")
+ repository.rebase(current_user, merge_request)
true
rescue => e
diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb
index f712b8863cd..3abea1ad1ae 100644
--- a/app/services/merge_requests/refresh_service.rb
+++ b/app/services/merge_requests/refresh_service.rb
@@ -14,12 +14,16 @@ module MergeRequests
private
def refresh_merge_requests!
+ # n + 1: https://gitlab.com/gitlab-org/gitlab-ce/issues/60289
Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits))
+
# Be sure to close outstanding MRs before reloading them to avoid generating an
# empty diff during a manual merge
close_upon_missing_source_branch_ref
post_merge_manually_merged
reload_merge_requests
+ outdate_suggestions
+ refresh_pipelines_on_merge_requests
reset_merge_when_pipeline_succeeds
mark_pending_todos_done
cache_merge_requests_closing_issues
@@ -106,8 +110,6 @@ module MergeRequests
end
merge_request.mark_as_unchecked
- create_merge_request_pipeline(merge_request, current_user)
- UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
# Upcoming method calls need the refreshed version of
@@ -125,6 +127,21 @@ module MergeRequests
merge_request.source_branch == @push.branch_name
end
+ def outdate_suggestions
+ outdate_service = Suggestions::OutdateService.new
+
+ merge_requests_for_source_branch.each do |merge_request|
+ outdate_service.execute(merge_request)
+ end
+ end
+
+ def refresh_pipelines_on_merge_requests
+ merge_requests_for_source_branch.each do |merge_request|
+ create_pipeline_for(merge_request, current_user)
+ UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
+ end
+ end
+
def reset_merge_when_pipeline_succeeds
merge_requests_for_source_branch.each(&:reset_merge_when_pipeline_succeeds)
end
diff --git a/app/services/merge_requests/reopen_service.rb b/app/services/merge_requests/reopen_service.rb
index f6cbe769ef4..f87005bcb6c 100644
--- a/app/services/merge_requests/reopen_service.rb
+++ b/app/services/merge_requests/reopen_service.rb
@@ -3,7 +3,7 @@
module MergeRequests
class ReopenService < MergeRequests::BaseService
def execute(merge_request)
- return merge_request unless can?(current_user, :update_merge_request, merge_request)
+ return merge_request unless can?(current_user, :reopen_merge_request, merge_request)
if merge_request.reopen
create_event(merge_request)
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index a439a380255..88ca3b4f5a8 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,23 +2,24 @@
module MergeRequests
class SquashService < MergeRequests::WorkingCopyBaseService
- def execute(merge_request)
- @merge_request = merge_request
- @repository = target_project.repository
-
- squash || error('Failed to squash. Should be done manually.')
- end
-
- def squash
- if merge_request.commits_count < 2
+ def execute
+ # If performing a squash would result in no change, then
+ # immediately return a success message without performing a squash
+ if merge_request.commits_count < 2 && message.nil?
return success(squash_sha: merge_request.diff_head_sha)
end
if merge_request.squash_in_progress?
- return error('Squash task canceled: another squash is already in progress.')
+ return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
end
- squash_sha = repository.squash(current_user, merge_request)
+ squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
+ end
+
+ private
+
+ def squash!
+ squash_sha = repository.squash(current_user, merge_request, message || merge_request.default_squash_commit_message)
success(squash_sha: squash_sha)
rescue => e
@@ -26,5 +27,17 @@ module MergeRequests
log_error(e.message)
false
end
+
+ def repository
+ target_project.repository
+ end
+
+ def merge_request
+ params[:merge_request]
+ end
+
+ def message
+ params[:squash_commit_message].presence
+ end
end
end
diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb
index aacaf10d09c..55546432ce4 100644
--- a/app/services/merge_requests/update_service.rb
+++ b/app/services/merge_requests/update_service.rb
@@ -5,31 +5,32 @@ module MergeRequests
def execute(merge_request)
# We don't allow change of source/target projects and source branch
# after merge request was created
- params.except!(:source_project_id)
- params.except!(:target_project_id)
- params.except!(:source_branch)
+ params.delete(:source_project_id)
+ params.delete(:target_project_id)
+ params.delete(:source_branch)
merge_from_quick_action(merge_request) if params[:merge]
if merge_request.closed_without_fork?
- params.except!(:target_branch, :force_remove_source_branch)
+ params.delete(:target_branch)
+ params.delete(:force_remove_source_branch)
end
- if params[:force_remove_source_branch].present?
+ if params.has_key?(:force_remove_source_branch)
merge_request.merge_params['force_remove_source_branch'] = params.delete(:force_remove_source_branch)
end
handle_wip_event(merge_request)
- update(merge_request)
+ update_task_event(merge_request) || update(merge_request)
end
- # rubocop:disable Metrics/AbcSize
def handle_changes(merge_request, options)
old_associations = options.fetch(:old_associations, {})
old_labels = old_associations.fetch(:labels, [])
old_mentioned_users = old_associations.fetch(:mentioned_users, [])
+ old_assignees = old_associations.fetch(:assignees, [])
- if has_changes?(merge_request, old_labels: old_labels)
+ if has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees)
todo_service.mark_pending_todos_as_done(merge_request, current_user)
end
@@ -44,13 +45,10 @@ module MergeRequests
merge_request.target_branch)
end
- if merge_request.previous_changes.include?('assignee_id')
- old_assignee_id = merge_request.previous_changes['assignee_id'].first
- old_assignee = User.find(old_assignee_id) if old_assignee_id
-
- create_assignee_note(merge_request)
- notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignee)
- todo_service.reassigned_merge_request(merge_request, current_user)
+ if merge_request.assignees != old_assignees
+ create_assignee_note(merge_request, old_assignees)
+ notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees)
+ todo_service.reassigned_issuable(merge_request, current_user, old_assignees)
end
if merge_request.previous_changes.include?('target_branch') ||
@@ -78,7 +76,11 @@ module MergeRequests
)
end
end
- # rubocop:enable Metrics/AbcSize
+
+ def handle_task_changes(merge_request)
+ todo_service.mark_pending_todos_as_done(merge_request, current_user)
+ todo_service.update_merge_request(merge_request, current_user)
+ end
def merge_from_quick_action(merge_request)
last_diff_sha = params.delete(:merge)
diff --git a/app/services/milestones/promote_service.rb b/app/services/milestones/promote_service.rb
index 39071b5dc14..0fe67067eb5 100644
--- a/app/services/milestones/promote_service.rb
+++ b/app/services/milestones/promote_service.rb
@@ -26,17 +26,15 @@ module Milestones
private
- # rubocop: disable CodeReuse/ActiveRecord
def milestone_ids_for_merge(group_milestone)
# Pluck need to be used here instead of select so the array of ids
# is persistent after old milestones gets deleted.
@milestone_ids_for_merge ||= begin
search_params = { title: group_milestone.title, project_ids: group_project_ids, state: 'all' }
milestones = MilestonesFinder.new(search_params).execute
- milestones.pluck(:id)
+ milestones.pluck_primary_key
end
end
- # rubocop: enable CodeReuse/ActiveRecord
def move_children_to_group_milestone(group_milestone)
milestone_ids_for_merge(group_milestone).in_groups_of(100, false) do |milestone_ids|
@@ -45,7 +43,7 @@ module Milestones
end
def check_project_milestone!(milestone)
- raise_error('Only project milestones can be promoted.') unless milestone.project_milestone?
+ raise_error(s_('PromoteMilestone|Only project milestones can be promoted.')) unless milestone.project_milestone?
end
def clone_project_milestone(milestone)
@@ -73,7 +71,7 @@ module Milestones
# rubocop: enable CodeReuse/ActiveRecord
def group
- @group ||= parent.group || raise_error('Project does not belong to a group.')
+ @group ||= parent.group || raise_error(s_('PromoteMilestone|Project does not belong to a group.'))
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -82,14 +80,12 @@ module Milestones
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def group_project_ids
- @group_project_ids ||= group.projects.pluck(:id)
+ group.projects.select(:id)
end
- # rubocop: enable CodeReuse/ActiveRecord
def raise_error(message)
- raise PromoteMilestoneError, "Promotion failed - #{message}"
+ raise PromoteMilestoneError, s_("PromoteMilestone|Promotion failed - %{message}") % { message: message }
end
end
end
diff --git a/app/services/note_summary.rb b/app/services/note_summary.rb
index 81f6f92f75c..60a68568833 100644
--- a/app/services/note_summary.rb
+++ b/app/services/note_summary.rb
@@ -5,7 +5,9 @@ class NoteSummary
attr_reader :metadata
def initialize(noteable, project, author, body, action: nil, commit_count: nil)
- @note = { noteable: noteable, project: project, author: author, note: body }
+ @note = { noteable: noteable,
+ created_at: noteable.system_note_timestamp,
+ project: project, author: author, note: body }
@metadata = { action: action, commit_count: commit_count }.compact
set_commit_params if note[:noteable].is_a?(Commit)
diff --git a/app/services/notes/build_service.rb b/app/services/notes/build_service.rb
index 7b92fe6fe14..541f3e0d23c 100644
--- a/app/services/notes/build_service.rb
+++ b/app/services/notes/build_service.rb
@@ -9,12 +9,14 @@ module Notes
if in_reply_to_discussion_id.present?
discussion = find_discussion(in_reply_to_discussion_id)
- unless discussion
+ unless discussion && can?(current_user, :create_note, discussion.noteable)
note = Note.new
note.errors.add(:base, 'Discussion to reply to cannot be found')
return note
end
+ discussion = discussion.convert_to_discussion! if discussion.can_convert_to_discussion?
+
params.merge!(discussion.reply_attributes)
should_resolve = discussion.resolved?
end
@@ -34,19 +36,8 @@ module Notes
if project
project.notes.find_discussion(discussion_id)
else
- discussion = Note.find_discussion(discussion_id)
- noteable = discussion.noteable
-
- return nil unless noteable_without_project?(noteable)
-
- discussion
+ Note.find_discussion(discussion_id)
end
end
-
- def noteable_without_project?(noteable)
- return true if noteable.is_a?(PersonalSnippet) && can?(current_user, :comment_personal_snippet, noteable)
-
- false
- end
end
end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index c4546f30235..1b46f6d8a72 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -21,7 +21,7 @@ module Notes
if quick_actions_service.supported?(note)
options = { merge_request_diff_head_sha: merge_request_diff_head_sha }
- content, command_params = quick_actions_service.extract_commands(note, options)
+ content, update_params = quick_actions_service.execute(note, options)
only_commands = content.empty?
@@ -34,21 +34,26 @@ module Notes
end
if !only_commands && note.save
+ if note.part_of_discussion? && note.discussion.can_convert_to_discussion?
+ note.discussion.convert_to_discussion!(save: true)
+ end
+
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
Suggestions::CreateService.new(note).execute
end
- if command_params.present?
- quick_actions_service.execute(command_params, note)
+ if quick_actions_service.commands_executed_count.to_i > 0
+ if update_params.present?
+ quick_actions_service.apply_updates(update_params, note)
+ note.commands_changes = update_params
+ end
# We must add the error after we call #save because errors are reset
# when #save is called
if only_commands
note.errors.add(:commands_only, 'Commands applied')
end
-
- note.commands_changes = command_params
end
note
diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb
index 4c14d834949..0852a708240 100644
--- a/app/services/notes/quick_actions_service.rb
+++ b/app/services/notes/quick_actions_service.rb
@@ -1,15 +1,31 @@
# frozen_string_literal: true
+# QuickActionsService class
+#
+# Executes quick actions commands extracted from note text
+#
+# Most commands returns parameters to be applied later
+# using QuickActionService#apply_updates
+#
module Notes
class QuickActionsService < BaseService
+ attr_reader :interpret_service
+
+ delegate :commands_executed_count, to: :interpret_service, allow_nil: true
+
UPDATE_SERVICES = {
'Issue' => Issues::UpdateService,
'MergeRequest' => MergeRequests::UpdateService,
'Commit' => Commits::TagService
}.freeze
+ private_constant :UPDATE_SERVICES
+
+ def self.update_services
+ UPDATE_SERVICES
+ end
def self.noteable_update_service(note)
- UPDATE_SERVICES[note.noteable_type]
+ update_services[note.noteable_type]
end
def self.supported?(note)
@@ -20,18 +36,21 @@ module Notes
self.class.supported?(note)
end
- def extract_commands(note, options = {})
+ def execute(note, options = {})
return [note.note, {}] unless supported?(note)
- QuickActions::InterpretService.new(project, current_user, options)
- .execute(note.note, note.noteable)
+ @interpret_service = QuickActions::InterpretService.new(project, current_user, options)
+
+ @interpret_service.execute(note.note, note.noteable)
end
- def execute(command_params, note)
- return if command_params.empty?
+ # Applies updates extracted to note#noteable
+ # The update parameters are extracted on self#execute
+ def apply_updates(update_params, note)
+ return if update_params.empty?
return unless supported?(note)
- self.class.noteable_update_service(note).new(project, current_user, command_params).execute(note.noteable)
+ self.class.noteable_update_service(note).new(note.parent, current_user, update_params).execute(note.noteable)
end
end
end
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index d2052bed646..384d1dd2e50 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -22,7 +22,7 @@ module Notes
# We need to refresh the previous suggestions call cache
# in order to get the new records.
- note.reload
+ note.reset
end
note
diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb
index 68cdc69023a..ca3f0b73096 100644
--- a/app/services/notification_recipient_service.rb
+++ b/app/services/notification_recipient_service.rb
@@ -135,7 +135,7 @@ module NotificationRecipientService
global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
- add_recipients(user_scope.where(id: user_ids), :watch, nil)
+ add_recipients(user_scope.where(id: user_ids), :custom, nil)
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -247,29 +247,30 @@ module NotificationRecipientService
attr_reader :target
attr_reader :current_user
attr_reader :action
- attr_reader :previous_assignee
+ attr_reader :previous_assignees
attr_reader :skip_current_user
- def initialize(target, current_user, action:, custom_action: nil, previous_assignee: nil, skip_current_user: true)
+
+ def initialize(target, current_user, action:, custom_action: nil, previous_assignees: nil, skip_current_user: true)
@target = target
@current_user = current_user
@action = action
@custom_action = custom_action
- @previous_assignee = previous_assignee
+ @previous_assignees = previous_assignees
@skip_current_user = skip_current_user
end
+ def add_watchers
+ add_project_watchers
+ end
+
def build!
add_participants(current_user)
- add_project_watchers
+ add_watchers
add_custom_notifications
# Re-assign is considered as a mention of the new assignee
case custom_action
- when :reassign_merge_request
- add_recipients(previous_assignee, :mention, nil)
- add_recipients(target.assignee, :mention, NotificationReason::ASSIGNED)
- when :reassign_issue
- previous_assignees = Array(previous_assignee)
+ when :reassign_merge_request, :reassign_issue
add_recipients(previous_assignees, :mention, nil)
add_recipients(target.assignees, :mention, NotificationReason::ASSIGNED)
end
@@ -282,17 +283,11 @@ module NotificationRecipientService
# receive them, too.
add_mentions(current_user, target: target)
- # Add the assigned users, if any
- assignees = case custom_action
- when :new_issue
- target.assignees
- else
- target.assignee
- end
-
# We use the `:participating` notification level in order to match existing legacy behavior as captured
# in existing specs (notification_service_spec.rb ~ line 507)
- add_recipients(assignees, :participating, NotificationReason::ASSIGNED) if assignees
+ if target.is_a?(Issuable)
+ add_recipients(target.assignees, :participating, NotificationReason::ASSIGNED)
+ end
add_labels_subscribers
end
@@ -396,7 +391,7 @@ module NotificationRecipientService
def build!
return [] unless project
- add_recipients(project.team.maintainers, :watch, nil)
+ add_recipients(project.team.maintainers, :mention, nil)
end
def acting_user
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index ff035fea216..f797c0f11c6 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -89,14 +89,14 @@ class NotificationService
# * project team members with notification level higher then Participating
# * users with custom level checked with "close issue"
#
- def close_issue(issue, current_user)
- close_resource_email(issue, current_user, :closed_issue_email)
+ def close_issue(issue, current_user, closed_via: nil)
+ close_resource_email(issue, current_user, :closed_issue_email, closed_via: closed_via)
end
# When we reassign an issue we should send an email to:
#
- # * issue old assignee if their notification level is not Disabled
- # * issue new assignee if their notification level is not Disabled
+ # * issue old assignees if their notification level is not Disabled
+ # * issue new assignees if their notification level is not Disabled
# * users with custom level checked with "reassign issue"
#
def reassigned_issue(issue, current_user, previous_assignees = [])
@@ -104,7 +104,7 @@ class NotificationService
issue,
current_user,
action: "reassign",
- previous_assignee: previous_assignees
+ previous_assignees: previous_assignees
)
previous_assignee_ids = previous_assignees.map(&:id)
@@ -140,7 +140,7 @@ class NotificationService
# When create a merge request we should send an email to:
#
# * mr author
- # * mr assignee if their notification level is not Disabled
+ # * mr assignees if their notification level is not Disabled
# * project team members with notification level higher then Participating
# * watchers of the mr's labels
# * users with custom level checked with "new merge request"
@@ -184,23 +184,25 @@ class NotificationService
# When we reassign a merge_request we should send an email to:
#
- # * merge_request old assignee if their notification level is not Disabled
- # * merge_request assignee if their notification level is not Disabled
+ # * merge_request old assignees if their notification level is not Disabled
+ # * merge_request new assignees if their notification level is not Disabled
# * users with custom level checked with "reassign merge request"
#
- def reassigned_merge_request(merge_request, current_user, previous_assignee)
+ def reassigned_merge_request(merge_request, current_user, previous_assignees = [])
recipients = NotificationRecipientService.build_recipients(
merge_request,
current_user,
action: "reassign",
- previous_assignee: previous_assignee
+ previous_assignees: previous_assignees
)
+ previous_assignee_ids = previous_assignees.map(&:id)
+
recipients.each do |recipient|
mailer.reassigned_merge_request_email(
recipient.user.id,
merge_request.id,
- previous_assignee&.id,
+ previous_assignee_ids,
current_user.id,
recipient.reason
).deliver_later
@@ -373,7 +375,8 @@ class NotificationService
end
def project_was_moved(project, old_path_with_namespace)
- recipients = notifiable_users(project.team.members, :mention, project: project)
+ recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members
+ recipients = notifiable_users(recipients, :mention, project: project)
recipients.each do |recipient|
mailer.project_was_moved_email(
@@ -501,7 +504,7 @@ class NotificationService
end
end
- def close_resource_email(target, current_user, method, skip_current_user: true)
+ def close_resource_email(target, current_user, method, skip_current_user: true, closed_via: nil)
action = method == :merged_merge_request_email ? "merge" : "close"
recipients = NotificationRecipientService.build_recipients(
@@ -512,7 +515,7 @@ class NotificationService
)
recipients.each do |recipient|
- mailer.send(method, recipient.user.id, target.id, current_user.id, recipient.reason).deliver_later
+ mailer.send(method, recipient.user.id, target.id, current_user.id, reason: recipient.reason, closed_via: closed_via).deliver_later
end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index a449a5dc3e9..7386530f45f 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -10,15 +10,14 @@ class PreviewMarkdownService < BaseService
text: text,
users: users,
suggestions: suggestions,
- commands: commands.join(' '),
- markdown_engine: markdown_engine
+ commands: commands.join(' ')
)
end
private
def explain_quick_actions(text)
- return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type)
+ return text, [] unless %w(Issue MergeRequest Commit).include?(target_type)
quick_actions_service = QuickActions::InterpretService.new(project, current_user)
quick_actions_service.explain(text, find_commands_target)
@@ -31,30 +30,34 @@ class PreviewMarkdownService < BaseService
end
def find_suggestions(text)
- return [] unless params[:preview_suggestions]
+ return [] unless preview_sugestions?
- Banzai::SuggestionsParser.parse(text)
+ position = Gitlab::Diff::Position.new(new_path: params[:file_path],
+ new_line: params[:line].to_i,
+ base_sha: params[:base_sha],
+ head_sha: params[:head_sha],
+ start_sha: params[:start_sha])
+
+ Gitlab::Diff::SuggestionsParser.parse(text, position: position, project: project)
+ end
+
+ def preview_sugestions?
+ params[:preview_suggestions] &&
+ target_type == 'MergeRequest' &&
+ Ability.allowed?(current_user, :download_code, project)
end
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
- .execute(commands_target_type, commands_target_id)
- end
-
- def commands_target_type
- params[:quick_actions_target_type]
+ .execute(target_type, target_id)
end
- def commands_target_id
- params[:quick_actions_target_id]
+ def target_type
+ params[:target_type]
end
- def markdown_engine
- if params[:legacy_render]
- :redcarpet
- else
- CacheMarkdownField::MarkdownEngine.from_version(params[:markdown_version].to_i)
- end
+ def target_id
+ params[:target_id]
end
end
diff --git a/app/services/projects/after_rename_service.rb b/app/services/projects/after_rename_service.rb
index 4131da44f5a..fafdecb3222 100644
--- a/app/services/projects/after_rename_service.rb
+++ b/app/services/projects/after_rename_service.rb
@@ -12,22 +12,27 @@ module Projects
#
# Projects::AfterRenameService.new(project).execute
class AfterRenameService
- attr_reader :project, :full_path_before, :full_path_after, :path_before
+ # @return [String] The Project being renamed.
+ attr_reader :project
- RenameFailedError = Class.new(StandardError)
+ # @return [String] The path slug the project was using, before the rename took place.
+ attr_reader :path_before
- # @param [Project] project The Project of the repository to rename.
- def initialize(project)
- @project = project
+ # @return [String] The full path of the namespace + project, before the rename took place.
+ attr_reader :full_path_before
- # The full path of the namespace + project, before the rename took place.
- @full_path_before = project.full_path_was
+ # @return [String] The full path of the namespace + project, after the rename took place.
+ attr_reader :full_path_after
- # The full path of the namespace + project, after the rename took place.
- @full_path_after = project.build_full_path
+ RenameFailedError = Class.new(StandardError)
- # The path of just the project, before the rename took place.
- @path_before = project.path_was
+ # @param [Project] project The Project being renamed.
+ # @param [String] path_before The path slug the project was using, before the rename took place.
+ def initialize(project, path_before:, full_path_before:)
+ @project = project
+ @path_before = path_before
+ @full_path_before = full_path_before
+ @full_path_after = project.full_path
end
def execute
@@ -57,11 +62,11 @@ module Projects
def rename_or_migrate_repository!
success =
if migrate_to_hashed_storage?
- ::Projects::HashedStorageMigrationService
+ ::Projects::HashedStorage::MigrationService
.new(project, full_path_before)
.execute
else
- project.storage.rename_repo
+ project.storage.rename_repo(old_full_path: full_path_before, new_full_path: full_path_after)
end
rename_failed! unless success
@@ -81,6 +86,7 @@ module Projects
def update_repository_configuration
project.reload_repository!
project.write_repository_config
+ project.track_project_repository
end
def rename_transferred_documents
diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb
index 61f6402a810..3dad90188cf 100644
--- a/app/services/projects/autocomplete_service.rb
+++ b/app/services/projects/autocomplete_service.rb
@@ -14,7 +14,7 @@ module Projects
order: { due_date: :asc, title: :asc }
}
- finder_params[:group_ids] = @project.group.self_and_ancestors_ids if @project.group
+ finder_params[:group_ids] = @project.group.self_and_ancestors.select(:id) if @project.group
MilestonesFinder.new(finder_params).execute.select([:iid, :title])
end
diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb
index 12103ea34b5..5972bfd4071 100644
--- a/app/services/projects/cleanup_service.rb
+++ b/app/services/projects/cleanup_service.rb
@@ -18,9 +18,6 @@ module Projects
# per rewritten object, with the old and new SHAs space-separated. It can be
# used to update or remove content that references the objects that BFG has
# altered
- #
- # Currently, only the project repository is modified by this service, but we
- # may wish to modify other data sources in the future.
def execute
apply_bfg_object_map!
@@ -41,10 +38,52 @@ module Projects
raise NoUploadError unless project.bfg_object_map.exists?
project.bfg_object_map.open do |io|
- repository_cleaner.apply_bfg_object_map(io)
+ repository_cleaner.apply_bfg_object_map_stream(io) do |response|
+ cleanup_diffs(response)
+ end
+ end
+ end
+
+ def cleanup_diffs(response)
+ old_commit_shas = extract_old_commit_shas(response.entries)
+
+ ActiveRecord::Base.transaction do
+ cleanup_merge_request_diffs(old_commit_shas)
+ cleanup_note_diff_files(old_commit_shas)
end
end
+ def extract_old_commit_shas(batch)
+ batch.lazy.select { |entry| entry.type == :COMMIT }.map(&:old_oid).force
+ end
+
+ def cleanup_merge_request_diffs(old_commit_shas)
+ merge_request_diffs = MergeRequestDiff
+ .by_project_id(project.id)
+ .by_commit_sha(old_commit_shas)
+
+ # It's important to run the ActiveRecord callbacks here
+ merge_request_diffs.destroy_all # rubocop:disable Cop/DestroyAll
+
+ # TODO: ensure the highlight cache is removed immediately. It's too hard
+ # to calculate the Redis keys at present.
+ #
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/61115
+ end
+
+ def cleanup_note_diff_files(old_commit_shas)
+ # Pluck the IDs instead of running the query twice to ensure we clear the
+ # cache for exactly the note diffs we remove
+ ids = NoteDiffFile
+ .referencing_sha(old_commit_shas, project_id: project.id)
+ .pluck_primary_key
+
+ NoteDiffFile.id_in(ids).delete_all
+
+ # A highlighted version of the diff is stored in redis. Remove it now.
+ Gitlab::DiscussionsDiff::HighlightCache.clear_multiple(ids)
+ end
+
def repository_cleaner
@repository_cleaner ||= Gitlab::Git::RepositoryCleaner.new(repository.raw)
end
diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb
new file mode 100644
index 00000000000..488290db824
--- /dev/null
+++ b/app/services/projects/container_repository/cleanup_tags_service.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+module Projects
+ module ContainerRepository
+ class CleanupTagsService < BaseService
+ def execute(container_repository)
+ return error('feature disabled') unless can_use?
+ return error('access denied') unless can_admin?
+
+ tags = container_repository.tags
+ tags_by_digest = group_by_digest(tags)
+
+ tags = without_latest(tags)
+ tags = filter_by_name(tags)
+ tags = with_manifest(tags)
+ tags = order_by_date(tags)
+ tags = filter_keep_n(tags)
+ tags = filter_by_older_than(tags)
+
+ deleted_tags = delete_tags(tags, tags_by_digest)
+
+ success(deleted: deleted_tags.map(&:name))
+ end
+
+ private
+
+ def delete_tags(tags_to_delete, tags_by_digest)
+ deleted_digests = group_by_digest(tags_to_delete).select do |digest, tags|
+ delete_tag_digest(digest, tags, tags_by_digest[digest])
+ end
+
+ deleted_digests.values.flatten
+ end
+
+ def delete_tag_digest(digest, tags, other_tags)
+ # Issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/21405
+ # we have to remove all tags due
+ # to Docker Distribution bug unable
+ # to delete single tag
+ return unless tags.count == other_tags.count
+
+ # delete all tags
+ tags.map(&:delete)
+ end
+
+ def group_by_digest(tags)
+ tags.group_by(&:digest)
+ end
+
+ def without_latest(tags)
+ tags.reject(&:latest?)
+ end
+
+ def with_manifest(tags)
+ tags.select(&:valid?)
+ end
+
+ def order_by_date(tags)
+ now = DateTime.now
+ tags.sort_by { |tag| tag.created_at || now }.reverse
+ end
+
+ def filter_by_name(tags)
+ regex = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex']}\\z")
+
+ tags.select do |tag|
+ regex.scan(tag.name).any?
+ end
+ end
+
+ def filter_keep_n(tags)
+ tags.drop(params['keep_n'].to_i)
+ end
+
+ def filter_by_older_than(tags)
+ return tags unless params['older_than']
+
+ older_than = ChronicDuration.parse(params['older_than']).seconds.ago
+
+ tags.select do |tag|
+ tag.created_at && tag.created_at < older_than
+ end
+ end
+
+ def can_admin?
+ can?(current_user, :admin_container_image, project)
+ end
+
+ def can_use?
+ Feature.enabled?(:container_registry_cleanup, project, default_enabled: true)
+ end
+ end
+ end
+end
diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb
index 8306d43ca7c..678bc0d24c3 100644
--- a/app/services/projects/create_from_template_service.rb
+++ b/app/services/projects/create_from_template_service.rb
@@ -5,7 +5,7 @@ module Projects
include Gitlab::Utils::StrongMemoize
def initialize(user, params)
- @current_user, @params = user, params.dup
+ @current_user, @params = user, params.to_h.dup
end
def execute
diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb
index d03137b63b2..9f335cceb67 100644
--- a/app/services/projects/create_service.rb
+++ b/app/services/projects/create_service.rb
@@ -2,6 +2,8 @@
module Projects
class CreateService < BaseService
+ include ValidatesClassificationLabel
+
def initialize(user, params)
@current_user, @params = user, params.dup
@skip_wiki = @params.delete(:skip_wiki)
@@ -45,6 +47,8 @@ module Projects
relations_block&.call(@project)
yield(@project) if block_given?
+ validate_classification_label(@project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return @project if @project.errors.any?
@@ -96,8 +100,6 @@ module Projects
current_user.invalidate_personal_projects_count
create_readme if @initialize_with_readme
-
- configure_group_clusters_for_project
end
# Refresh the current user's authorizations inline (so they can access the
@@ -123,10 +125,6 @@ module Projects
Files::CreateService.new(@project, current_user, commit_attrs).execute
end
- def configure_group_clusters_for_project
- ClusterProjectConfigureWorker.perform_async(@project.id)
- end
-
def skip_wiki?
!@project.feature_available?(:wiki, current_user) || @skip_wiki
end
@@ -155,8 +153,8 @@ module Projects
log_message << " Project ID: #{@project.id}" if @project&.id
Rails.logger.error(log_message)
- if @project
- @project.import_state.mark_as_failed(message) if @project.persisted? && @project.import?
+ if @project && @project.persisted? && @project.import_state
+ @project.import_state.mark_as_failed(message)
end
@project
diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb
index 210571b6b4e..d8e670e40ce 100644
--- a/app/services/projects/destroy_service.rb
+++ b/app/services/projects/destroy_service.rb
@@ -7,9 +7,16 @@ module Projects
DestroyError = Class.new(StandardError)
DELETED_FLAG = '+deleted'.freeze
+ REPO_REMOVAL_DELAY = 5.minutes.to_i
def async_execute
project.update_attribute(:pending_delete, true)
+
+ # Ensure no repository +deleted paths are kept,
+ # regardless of any issue with the ProjectDestroyWorker
+ # job process.
+ schedule_stale_repos_removal
+
job_id = ProjectDestroyWorker.perform_async(project.id, current_user.id, params)
Rails.logger.info("User #{current_user.id} scheduled destruction of project #{project.full_path} with job ID #{job_id}")
end
@@ -54,11 +61,11 @@ module Projects
flush_caches(@project)
unless rollback_repository(removal_path(repo_path), repo_path)
- raise_error('Failed to restore project repository. Please contact the administrator.')
+ raise_error(s_('DeleteProject|Failed to restore project repository. Please contact the administrator.'))
end
unless rollback_repository(removal_path(wiki_path), wiki_path)
- raise_error('Failed to restore wiki repository. Please contact the administrator.')
+ raise_error(s_('DeleteProject|Failed to restore wiki repository. Please contact the administrator.'))
end
end
@@ -74,11 +81,11 @@ module Projects
def trash_repositories!
unless remove_repository(repo_path)
- raise_error('Failed to remove project repository. Please try again or contact administrator.')
+ raise_error(s_('DeleteProject|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(s_('DeleteProject|Failed to remove wiki repository. Please try again or contact administrator.'))
end
end
@@ -92,14 +99,23 @@ module Projects
log_info(%Q{Repository "#{path}" moved to "#{new_path}" for project "#{project.full_path}"})
project.run_after_commit do
- # self is now project
- GitlabShellWorker.perform_in(5.minutes, :remove_repository, self.repository_storage, new_path)
+ GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY, :remove_repository, self.repository_storage, new_path)
end
else
false
end
end
+ def schedule_stale_repos_removal
+ repo_paths = [removal_path(repo_path), removal_path(wiki_path)]
+
+ # Ideally it should wait until the regular removal phase finishes,
+ # so let's delay it a bit further.
+ repo_paths.each do |path|
+ GitlabShellWorker.perform_in(REPO_REMOVAL_DELAY * 2, :remove_repository, project.repository_storage, path)
+ end
+ end
+
def rollback_repository(old_path, new_path)
# There is a possibility project does not have repository or wiki
return true unless repo_exists?(old_path)
@@ -113,13 +129,11 @@ module Projects
end
# rubocop: enable CodeReuse/ActiveRecord
- # rubocop: disable CodeReuse/ActiveRecord
def mv_repository(from_path, to_path)
- return true unless gitlab_shell.exists?(project.repository_storage, from_path + '.git')
+ return true unless repo_exists?(from_path)
gitlab_shell.mv_repository(project.repository_storage, from_path, to_path)
end
- # rubocop: enable CodeReuse/ActiveRecord
def attempt_rollback(project, message)
return unless project
@@ -134,9 +148,11 @@ module Projects
def attempt_destroy_transaction(project)
unless remove_registry_tags
- raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
+ raise_error(s_('DeleteProject|Failed to remove some tags in project container registry. Please try again or contact administrator.'))
end
+ project.leave_pool_repository
+
Project.transaction do
log_destroy_event
trash_repositories!
diff --git a/app/services/projects/detect_repository_languages_service.rb b/app/services/projects/detect_repository_languages_service.rb
index 4a837a4fb6a..d3680637217 100644
--- a/app/services/projects/detect_repository_languages_service.rb
+++ b/app/services/projects/detect_repository_languages_service.rb
@@ -2,7 +2,7 @@
module Projects
class DetectRepositoryLanguagesService < BaseService
- attr_reader :detected_repository_languages, :programming_languages
+ attr_reader :programming_languages
# rubocop: disable CodeReuse/ActiveRecord
def execute
@@ -25,9 +25,11 @@ module Projects
RepositoryLanguage.table_name,
detection.insertions(matching_programming_languages)
)
+
+ set_detected_repository_languages
end
- project.repository_languages.reload
+ project.repository_languages.reset
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -56,5 +58,11 @@ module Projects
retry
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ def set_detected_repository_languages
+ return if project.detected_repository_languages?
+
+ project.update_column(:detected_repository_languages, true)
+ end
end
end
diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb
index dd297c9ba43..aba175eb79b 100644
--- a/app/services/projects/download_service.rb
+++ b/app/services/projects/download_service.rb
@@ -11,7 +11,7 @@ module Projects
end
def execute
- return nil unless valid_url?(@url)
+ return unless valid_url?(@url)
uploader = FileUploader.new(@project)
uploader.download!(@url)
diff --git a/app/services/projects/fetch_statistics_increment_service.rb b/app/services/projects/fetch_statistics_increment_service.rb
new file mode 100644
index 00000000000..8644e6bf313
--- /dev/null
+++ b/app/services/projects/fetch_statistics_increment_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Projects
+ class FetchStatisticsIncrementService
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ increment_fetch_count_sql = <<~SQL
+ INSERT INTO #{table_name} (project_id, date, fetch_count)
+ VALUES (#{project.id}, '#{Date.today}', 1)
+ SQL
+
+ increment_fetch_count_sql += if Gitlab::Database.postgresql?
+ "ON CONFLICT (project_id, date) DO UPDATE SET fetch_count = #{table_name}.fetch_count + 1"
+ else
+ "ON DUPLICATE KEY UPDATE fetch_count = #{table_name}.fetch_count + 1"
+ end
+
+ ActiveRecord::Base.connection.execute(increment_fetch_count_sql)
+ end
+
+ private
+
+ def table_name
+ ProjectDailyStatistic.table_name
+ end
+ end
+end
diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb
index 91091c4393d..fc234bafc57 100644
--- a/app/services/projects/fork_service.rb
+++ b/app/services/projects/fork_service.rb
@@ -38,8 +38,8 @@ module Projects
new_params = {
visibility_level: allowed_visibility_level,
description: @project.description,
- name: @project.name,
- path: @project.path,
+ name: target_name,
+ path: target_path,
shared_runners_enabled: @project.shared_runners_enabled,
namespace_id: target_namespace.id,
fork_network: fork_network,
@@ -94,6 +94,14 @@ module Projects
Projects::ForksCountService.new(@project).refresh_cache
end
+ def target_path
+ @target_path ||= @params[:path] || @project.path
+ end
+
+ def target_name
+ @target_name ||= @params[:name] || @project.name
+ end
+
def target_namespace
@target_namespace ||= @params[:namespace] || current_user.namespace
end
diff --git a/app/services/projects/git_deduplication_service.rb b/app/services/projects/git_deduplication_service.rb
new file mode 100644
index 00000000000..74d469ecf37
--- /dev/null
+++ b/app/services/projects/git_deduplication_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module Projects
+ class GitDeduplicationService < BaseService
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 86400
+
+ delegate :pool_repository, to: :project
+ attr_reader :project
+
+ def initialize(project)
+ @project = project
+ end
+
+ def execute
+ try_obtain_lease do
+ unless project.has_pool_repository?
+ disconnect_git_alternates
+ break
+ end
+
+ if source_project? && pool_can_fetch_from_source?
+ fetch_from_source
+ end
+
+ project.link_pool_repository if same_storage_as_pool?(project.repository)
+ end
+ end
+
+ private
+
+ def disconnect_git_alternates
+ project.repository.disconnect_alternates
+ end
+
+ def pool_can_fetch_from_source?
+ project.git_objects_poolable? &&
+ same_storage_as_pool?(pool_repository.source_project.repository)
+ end
+
+ def same_storage_as_pool?(repository)
+ pool_repository.object_pool.repository.storage == repository.storage
+ end
+
+ def fetch_from_source
+ project.pool_repository.object_pool.fetch
+ end
+
+ def source_project?
+ return unless project.has_pool_repository?
+
+ project.pool_repository.source_project == project
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+
+ def lease_key
+ "git_deduplication:#{project.id}"
+ end
+ end
+end
diff --git a/app/services/projects/group_links/create_service.rb b/app/services/projects/group_links/create_service.rb
index 1392775f805..e3d5bea0852 100644
--- a/app/services/projects/group_links/create_service.rb
+++ b/app/services/projects/group_links/create_service.rb
@@ -4,13 +4,19 @@ module Projects
module GroupLinks
class CreateService < BaseService
def execute(group)
- return false unless group
+ return error('Not Found', 404) unless group && can?(current_user, :read_namespace, group)
- project.project_group_links.create(
+ link = project.project_group_links.new(
group: group,
group_access: params[:link_group_access],
expires_at: params[:expires_at]
)
+
+ if link.save
+ success(link: link)
+ else
+ error(link.errors.full_messages.to_sentence, 409)
+ end
end
end
end
diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb
new file mode 100644
index 00000000000..828ab616bab
--- /dev/null
+++ b/app/services/projects/hashed_storage/base_attachment_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ AttachmentMigrationError = Class.new(StandardError)
+
+ AttachmentCannotMoveError = Class.new(StandardError)
+
+ class BaseAttachmentService < BaseService
+ # Returns the disk_path value before the execution
+ attr_reader :old_disk_path
+
+ # Returns the disk_path value after the execution
+ attr_reader :new_disk_path
+
+ # Returns the logger currently in use
+ attr_reader :logger
+
+ # Return whether this operation was skipped or not
+ #
+ # @return [Boolean] true if skipped of false otherwise
+ def skipped?
+ @skipped
+ end
+
+ protected
+
+ def move_folder!(old_path, new_path)
+ unless File.directory?(old_path)
+ logger.info("Skipped attachments move from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
+ @skipped = true
+
+ return true
+ end
+
+ if File.exist?(new_path)
+ logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
+ raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists"
+ end
+
+ # Create base path folder on the new storage layout
+ FileUtils.mkdir_p(File.dirname(new_path))
+
+ FileUtils.mv(old_path, new_path)
+ logger.info("Project attachments moved from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
+
+ true
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb
new file mode 100644
index 00000000000..f97a28b8c3b
--- /dev/null
+++ b/app/services/projects/hashed_storage/base_repository_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ # Returned when repository can't be made read-only because there is already a git transfer in progress
+ RepositoryInUseError = Class.new(StandardError)
+
+ class BaseRepositoryService < BaseService
+ include Gitlab::ShellAdapter
+
+ attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki
+
+ def initialize(project, old_disk_path, logger: nil)
+ @project = project
+ @logger = logger || Gitlab::AppLogger
+ @old_disk_path = old_disk_path
+ @old_wiki_disk_path = "#{old_disk_path}.wiki"
+ @move_wiki = has_wiki?
+ end
+
+ protected
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def has_wiki?
+ gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def move_repository(from_name, to_name)
+ from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git")
+ to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git")
+
+ # If we don't find the repository on either original or target we should log that as it could be an issue if the
+ # project was not originally empty.
+ if !from_exists && !to_exists
+ logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
+
+ # We return true so we still reflect the change in the database.
+ # Next time the repository is (re)created it will be under the new storage layout
+ return true
+ elsif !from_exists
+ # Repository have been moved already.
+ return true
+ end
+
+ gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def rollback_folder_move
+ move_repository(new_disk_path, old_disk_path)
+ move_repository("#{new_disk_path}.wiki", old_wiki_disk_path)
+ end
+
+ def try_to_set_repository_read_only!
+ # Mitigate any push operation to start during migration
+ unless project.set_repository_read_only!
+ migration_error = "Target repository '#{old_disk_path}' cannot be made read-only as there is a git transfer in progress"
+ logger.error migration_error
+
+ raise RepositoryInUseError, migration_error
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb
index a1f0302aeb7..3d0b8f58612 100644
--- a/app/services/projects/hashed_storage/migrate_attachments_service.rb
+++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb
@@ -2,56 +2,37 @@
module Projects
module HashedStorage
- AttachmentMigrationError = Class.new(StandardError)
-
- class MigrateAttachmentsService < BaseService
- attr_reader :logger, :old_disk_path, :new_disk_path
-
+ class MigrateAttachmentsService < BaseAttachmentService
def initialize(project, old_disk_path, logger: nil)
@project = project
@logger = logger || Rails.logger
@old_disk_path = old_disk_path
- @new_disk_path = project.disk_path
+ @skipped = false
end
def execute
origin = FileUploader.absolute_base_dir(project)
- # It's possible that old_disk_path does not match project.disk_path. For example, that happens when we rename a project
+ # It's possible that old_disk_path does not match project.disk_path.
+ # For example, that happens when we rename a project
origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path)
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
target = FileUploader.absolute_base_dir(project)
- result = move_folder!(origin, target)
- project.save!
-
- if result && block_given?
- yield
- end
-
- result
- end
+ @new_disk_path = project.disk_path
- private
+ result = move_folder!(origin, target)
- def move_folder!(old_disk_path, new_disk_path)
- unless File.directory?(old_disk_path)
- logger.info("Skipped attachments migration from '#{old_disk_path}' to '#{new_disk_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
- return
- end
+ if result
+ project.save!(validate: false)
- if File.exist?(new_disk_path)
- logger.error("Cannot migrate attachments from '#{old_disk_path}' to '#{new_disk_path}', target path already exist (PROJECT_ID=#{project.id})")
- raise AttachmentMigrationError, "Target path '#{new_disk_path}' already exist"
+ yield if block_given?
+ else
+ # Rollback changes
+ project.rollback!
end
- # Create hashed storage base path folder
- FileUtils.mkdir_p(File.dirname(new_disk_path))
-
- FileUtils.mv(old_disk_path, new_disk_path)
- logger.info("Migrated project attachments from '#{old_disk_path}' to '#{new_disk_path}' (PROJECT_ID=#{project.id})")
-
- true
+ result
end
end
end
diff --git a/app/services/projects/hashed_storage/migrate_repository_service.rb b/app/services/projects/hashed_storage/migrate_repository_service.rb
index f3e026ba38c..e8393128d58 100644
--- a/app/services/projects/hashed_storage/migrate_repository_service.rb
+++ b/app/services/projects/hashed_storage/migrate_repository_service.rb
@@ -2,20 +2,10 @@
module Projects
module HashedStorage
- class MigrateRepositoryService < BaseService
- include Gitlab::ShellAdapter
-
- attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki
-
- def initialize(project, old_disk_path, logger: nil)
- @project = project
- @logger = logger || Rails.logger
- @old_disk_path = old_disk_path
- @old_wiki_disk_path = "#{old_disk_path}.wiki"
- @move_wiki = has_wiki?
- end
-
+ class MigrateRepositoryService < BaseRepositoryService
def execute
+ try_to_set_repository_read_only!
+
@old_storage_version = project.storage_version
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
project.ensure_storage_path_exists
@@ -25,7 +15,7 @@ module Projects
result = move_repository(old_disk_path, new_disk_path)
if move_wiki
- result &&= move_repository("#{old_wiki_disk_path}", "#{new_disk_path}.wiki")
+ result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki")
end
if result
@@ -37,7 +27,7 @@ module Projects
end
project.repository_read_only = false
- project.save!
+ project.save!(validate: false)
if result && block_given?
yield
@@ -45,38 +35,6 @@ module Projects
result
end
-
- private
-
- # rubocop: disable CodeReuse/ActiveRecord
- def has_wiki?
- gitlab_shell.exists?(project.repository_storage, "#{old_wiki_disk_path}.git")
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- # rubocop: disable CodeReuse/ActiveRecord
- def move_repository(from_name, to_name)
- from_exists = gitlab_shell.exists?(project.repository_storage, "#{from_name}.git")
- to_exists = gitlab_shell.exists?(project.repository_storage, "#{to_name}.git")
-
- # If we don't find the repository on either original or target we should log that as it could be an issue if the
- # project was not originally empty.
- if !from_exists && !to_exists
- logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
- return false
- elsif !from_exists
- # Repository have been moved already.
- return true
- end
-
- gitlab_shell.mv_repository(project.repository_storage, from_name, to_name)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def rollback_folder_move
- move_repository(new_disk_path, old_disk_path)
- move_repository("#{new_disk_path}.wiki", old_wiki_disk_path)
- end
end
end
end
diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb
new file mode 100644
index 00000000000..f132dca61c9
--- /dev/null
+++ b/app/services/projects/hashed_storage/migration_service.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class MigrationService < BaseService
+ attr_reader :logger, :old_disk_path
+
+ def initialize(project, old_disk_path, logger: nil)
+ @project = project
+ @old_disk_path = old_disk_path
+ @logger = logger || Gitlab::AppLogger
+ end
+
+ def execute
+ # Migrate repository from Legacy to Hashed Storage
+ unless project.hashed_storage?(:repository)
+ return false unless migrate_repository
+ end
+
+ # Migrate attachments from Legacy to Hashed Storage
+ unless project.hashed_storage?(:attachments)
+ return false unless migrate_attachments
+ end
+
+ true
+ end
+
+ private
+
+ def migrate_repository
+ HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute
+ end
+
+ def migrate_attachments
+ HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb
new file mode 100644
index 00000000000..5c6b92f965c
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackAttachmentsService < BaseAttachmentService
+ def initialize(project, logger: nil)
+ @project = project
+ @logger = logger || Rails.logger
+ @old_disk_path = project.disk_path
+ end
+
+ def execute
+ origin = FileUploader.absolute_base_dir(project)
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ target = FileUploader.absolute_base_dir(project)
+
+ @new_disk_path = FileUploader.base_dir(project)
+
+ result = move_folder!(origin, target)
+
+ if result
+ project.save!(validate: false)
+
+ yield if block_given?
+ else
+ # Rollback changes
+ project.rollback!
+ end
+
+ result
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/rollback_repository_service.rb b/app/services/projects/hashed_storage/rollback_repository_service.rb
new file mode 100644
index 00000000000..67733f4770b
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_repository_service.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackRepositoryService < BaseRepositoryService
+ def execute
+ try_to_set_repository_read_only!
+
+ @old_storage_version = project.storage_version
+ project.storage_version = nil
+ project.ensure_storage_path_exists
+
+ @new_disk_path = project.disk_path
+
+ result = move_repository(old_disk_path, new_disk_path)
+
+ if move_wiki
+ result &&= move_repository(old_wiki_disk_path, "#{new_disk_path}.wiki")
+ end
+
+ if result
+ project.write_repository_config
+ project.track_project_repository
+ else
+ rollback_folder_move
+ project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
+ end
+
+ project.repository_read_only = false
+ project.save!(validate: false)
+
+ if result && block_given?
+ yield
+ end
+
+ result
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb
new file mode 100644
index 00000000000..25767f5de5e
--- /dev/null
+++ b/app/services/projects/hashed_storage/rollback_service.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Projects
+ module HashedStorage
+ class RollbackService < BaseService
+ attr_reader :logger, :old_disk_path
+
+ def initialize(project, old_disk_path, logger: nil)
+ @project = project
+ @old_disk_path = old_disk_path
+ @logger = logger || Rails.logger
+ end
+
+ def execute
+ # Rollback attachments from Hashed Storage to Legacy
+ if project.hashed_storage?(:attachments)
+ return false unless rollback_attachments
+ end
+
+ # Rollback repository from Hashed Storage to Legacy
+ if project.hashed_storage?(:repository)
+ rollback_repository
+ end
+ end
+
+ private
+
+ def rollback_attachments
+ HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute
+ end
+
+ def rollback_repository
+ HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ end
+end
diff --git a/app/services/projects/hashed_storage_migration_service.rb b/app/services/projects/hashed_storage_migration_service.rb
deleted file mode 100644
index a0e734005f8..00000000000
--- a/app/services/projects/hashed_storage_migration_service.rb
+++ /dev/null
@@ -1,27 +0,0 @@
-# frozen_string_literal: true
-
-module Projects
- class HashedStorageMigrationService < BaseService
- attr_reader :logger, :old_disk_path
-
- def initialize(project, old_disk_path, logger: nil)
- @project = project
- @old_disk_path = old_disk_path
- @logger = logger || Rails.logger
- end
-
- def execute
- # Migrate repository from Legacy to Hashed Storage
- unless project.hashed_storage?(:repository)
- return unless HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute
- end
-
- # Migrate attachments from Legacy to Hashed Storage
- unless project.hashed_storage?(:attachments)
- HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute
- end
-
- true
- end
- end
-end
diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb
index 2f6dc4207dd..9428575591e 100644
--- a/app/services/projects/housekeeping_service.rb
+++ b/app/services/projects/housekeeping_service.rb
@@ -11,6 +11,7 @@ module Projects
class HousekeepingService < BaseService
# Timeout set to 24h
LEASE_TIMEOUT = 86400
+ PACK_REFS_PERIOD = 6
class LeaseTaken < StandardError
def to_s
@@ -18,8 +19,9 @@ module Projects
end
end
- def initialize(project)
+ def initialize(project, task = nil)
@project = project
+ @task = task
end
def execute
@@ -69,17 +71,21 @@ module Projects
end
def task
+ return @task if @task
+
if pushes_since_gc % gc_period == 0
:gc
elsif pushes_since_gc % full_repack_period == 0
:full_repack
- else
+ elsif pushes_since_gc % repack_period == 0
:incremental_repack
+ else
+ :pack_refs
end
end
def period_match?
- [gc_period, full_repack_period, repack_period].any? { |period| pushes_since_gc % period == 0 }
+ [gc_period, full_repack_period, repack_period, PACK_REFS_PERIOD].any? { |period| pushes_since_gc % period == 0 }
end
def housekeeping_enabled?
diff --git a/app/services/projects/import_error_filter.rb b/app/services/projects/import_error_filter.rb
new file mode 100644
index 00000000000..737b794484d
--- /dev/null
+++ b/app/services/projects/import_error_filter.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Projects
+ # Used by project imports, it removes any potential paths
+ # included in an error message that could be stored in the DB
+ class ImportErrorFilter
+ ERROR_MESSAGE_FILTER = /[^\s]*#{File::SEPARATOR}[^\s]*(?=(\s|\z))/.freeze
+ FILTER_MESSAGE = '[FILTERED]'
+
+ def self.filter_message(message)
+ message.gsub(ERROR_MESSAGE_FILTER, FILTER_MESSAGE)
+ end
+ end
+end
diff --git a/app/services/projects/import_service.rb b/app/services/projects/import_service.rb
index 0c426faa22d..073c14040ce 100644
--- a/app/services/projects/import_service.rb
+++ b/app/services/projects/import_service.rb
@@ -24,8 +24,16 @@ module Projects
import_data
success
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+
+ error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: e.message })
rescue => e
- error("Error importing repository #{project.safe_import_url} into #{project.full_path} - #{e.message}")
+ message = Projects::ImportErrorFilter.filter_message(e.message)
+
+ Gitlab::Sentry.track_acceptable_exception(e, extra: { project_path: project.full_path, importer: project.import_type })
+
+ error(s_("ImportProjects|Error importing repository %{project_safe_import_url} into %{project_full_path} - %{message}") % { project_safe_import_url: project.safe_import_url, project_full_path: project.full_path, message: message })
end
private
@@ -35,7 +43,7 @@ module Projects
begin
Gitlab::UrlBlocker.validate!(project.import_url, ports: Project::VALID_IMPORT_PORTS)
rescue Gitlab::UrlBlocker::BlockedUrlError => e
- raise Error, "Blocked import URL: #{e.message}"
+ raise e, s_("ImportProjects|Blocked import URL: %{message}") % { message: e.message }
end
end
@@ -53,7 +61,7 @@ module Projects
def create_repository
unless project.create_repository
- raise Error, 'The repository could not be created.'
+ raise Error, s_('ImportProjects|The repository could not be created.')
end
end
@@ -65,7 +73,7 @@ module Projects
project.ensure_repository
project.repository.fetch_as_mirror(project.import_url, refmap: refmap)
else
- gitlab_shell.import_repository(project.repository_storage, project.disk_path, project.import_url)
+ gitlab_shell.import_project_repository(project)
end
rescue Gitlab::Shell::Error => e
# Expire cache to prevent scenarios such as:
@@ -86,16 +94,13 @@ module Projects
return unless project.lfs_enabled?
- oids_to_download = Projects::LfsPointers::LfsImportService.new(project).execute
- download_service = Projects::LfsPointers::LfsDownloadService.new(project)
+ result = Projects::LfsPointers::LfsImportService.new(project).execute
- oids_to_download.each do |oid, link|
- download_service.execute(oid, link)
+ if result[:status] == :error
+ # To avoid aborting the importing process, we silently fail
+ # if any exception raises.
+ Gitlab::AppLogger.error("The Lfs import process failed. #{result[:message]}")
end
- rescue => e
- # Right now, to avoid aborting the importing process, we silently fail
- # if any exception raises.
- Rails.logger.error("The Lfs import process failed. #{e.message}")
end
def import_data
@@ -104,7 +109,7 @@ module Projects
project.repository.expire_content_cache unless project.gitlab_project_import?
unless importer.execute
- raise Error, 'The remote data could not be imported.'
+ raise Error, s_('ImportProjects|The remote data could not be imported.')
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
index a837ea82e38..9b72480d18b 100644
--- a/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_link_list_service.rb
@@ -21,9 +21,9 @@ module Projects
# This method accepts two parameters:
# - oids: hash of oids to query. The structure is { lfs_file_oid => lfs_file_size }
#
- # Returns a hash with the structure { lfs_file_oids => download_link }
+ # Returns an array of LfsDownloadObject
def execute(oids)
- return {} unless project&.lfs_enabled? && remote_uri && oids.present?
+ return [] unless project&.lfs_enabled? && remote_uri && oids.present?
get_download_links(oids)
end
@@ -37,21 +37,30 @@ module Projects
raise DownloadLinksError, response.message unless response.success?
- parse_response_links(response['objects'])
+ # Since the LFS Batch API may return a Content-Ttpe of
+ # application/vnd.git-lfs+json
+ # (https://github.com/git-lfs/git-lfs/blob/master/docs/api/batch.md#requests),
+ # HTTParty does not know this is actually JSON.
+ data = JSON.parse(response.body)
+
+ raise DownloadLinksError, "LFS Batch API did return any objects" unless data.is_a?(Hash) && data.key?('objects')
+
+ parse_response_links(data['objects'])
+ rescue JSON::ParserError
+ raise DownloadLinksError, "LFS Batch API response is not JSON"
end
def parse_response_links(objects_response)
- objects_response.each_with_object({}) do |entry, link_list|
- begin
- oid = entry['oid']
- link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
+ objects_response.each_with_object([]) do |entry, link_list|
+ link = entry.dig('actions', DOWNLOAD_ACTION, 'href')
- raise DownloadLinkNotFound unless link
+ raise DownloadLinkNotFound unless link
- link_list[oid] = add_credentials(link)
- rescue DownloadLinkNotFound, URI::InvalidURIError
- Rails.logger.error("Link for Lfs Object with oid #{oid} not found or invalid.")
- end
+ link_list << LfsDownloadObject.new(oid: entry['oid'],
+ size: entry['size'],
+ link: add_credentials(link))
+ rescue DownloadLinkNotFound, Addressable::URI::InvalidURIError
+ log_error("Link for Lfs Object with oid #{entry['oid']} not found or invalid.")
end
end
@@ -70,7 +79,7 @@ module Projects
end
def add_credentials(link)
- uri = URI.parse(link)
+ uri = Addressable::URI.parse(link)
if should_add_credentials?(uri)
uri.user = remote_uri.user
diff --git a/app/services/projects/lfs_pointers/lfs_download_service.rb b/app/services/projects/lfs_pointers/lfs_download_service.rb
index f9b9781ad5f..a009f479d5d 100644
--- a/app/services/projects/lfs_pointers/lfs_download_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_download_service.rb
@@ -4,49 +4,91 @@
module Projects
module LfsPointers
class LfsDownloadService < BaseService
- VALID_PROTOCOLS = %w[http https].freeze
+ SizeError = Class.new(StandardError)
+ OidError = Class.new(StandardError)
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(oid, url)
- return unless project&.lfs_enabled? && oid.present? && url.present?
+ attr_reader :lfs_download_object
+ delegate :oid, :size, :credentials, :sanitized_url, to: :lfs_download_object, prefix: :lfs
- return if LfsObject.exists?(oid: oid)
+ def initialize(project, lfs_download_object)
+ super(project)
- sanitized_uri = Gitlab::UrlSanitizer.new(url)
- Gitlab::UrlBlocker.validate!(sanitized_uri.sanitized_url, protocols: VALID_PROTOCOLS)
+ @lfs_download_object = lfs_download_object
+ end
- with_tmp_file(oid) do |file|
- size = download_and_save_file(file, sanitized_uri)
- lfs_object = LfsObject.new(oid: oid, size: size, file: file)
+ # rubocop: disable CodeReuse/ActiveRecord
+ def execute
+ return unless project&.lfs_enabled? && lfs_download_object
+ return error("LFS file with oid #{lfs_oid} has invalid attributes") unless lfs_download_object.valid?
+ return if LfsObject.exists?(oid: lfs_oid)
- project.all_lfs_objects << lfs_object
+ wrap_download_errors do
+ download_lfs_file!
end
- rescue StandardError => e
- Rails.logger.error("LFS file with oid #{oid} could't be downloaded from #{sanitized_uri.sanitized_url}: #{e.message}")
end
# rubocop: enable CodeReuse/ActiveRecord
private
- def download_and_save_file(file, sanitized_uri)
- IO.copy_stream(open(sanitized_uri.sanitized_url, headers(sanitized_uri)), file) # rubocop:disable Security/Open
+ def wrap_download_errors(&block)
+ yield
+ rescue SizeError, OidError, StandardError => e
+ error("LFS file with oid #{lfs_oid} could't be downloaded from #{lfs_sanitized_url}: #{e.message}")
+ end
+
+ def download_lfs_file!
+ with_tmp_file do |tmp_file|
+ download_and_save_file!(tmp_file)
+ project.all_lfs_objects << LfsObject.new(oid: lfs_oid,
+ size: lfs_size,
+ file: tmp_file)
+
+ success
+ end
end
- def headers(sanitized_uri)
- {}.tap do |headers|
- credentials = sanitized_uri.credentials
+ def download_and_save_file!(file)
+ digester = Digest::SHA256.new
+ response = Gitlab::HTTP.get(lfs_sanitized_url, download_headers) do |fragment|
+ digester << fragment
+ file.write(fragment)
+
+ raise_size_error! if file.size > lfs_size
+ end
+
+ raise StandardError, "Received error code #{response.code}" unless response.success?
+
+ raise_size_error! if file.size != lfs_size
+ raise_oid_error! if digester.hexdigest != lfs_oid
+ end
- if credentials[:user].present? || credentials[:password].present?
+ def download_headers
+ { stream_body: true }.tap do |headers|
+ if lfs_credentials[:user].present? || lfs_credentials[:password].present?
# Using authentication headers in the request
- headers[:http_basic_authentication] = [credentials[:user], credentials[:password]]
+ headers[:basic_auth] = { username: lfs_credentials[:user], password: lfs_credentials[:password] }
end
end
end
- def with_tmp_file(oid)
+ def with_tmp_file
create_tmp_storage_dir
- File.open(File.join(tmp_storage_dir, oid), 'w') { |file| yield file }
+ File.open(tmp_filename, 'wb') do |file|
+ yield file
+ rescue StandardError => e
+ # If the lfs file is successfully downloaded it will be removed
+ # when it is added to the project's lfs files.
+ # Nevertheless if any excetion raises the file would remain
+ # in the file system. Here we ensure to remove it
+ File.unlink(file) if File.exist?(file)
+
+ raise e
+ end
+ end
+
+ def tmp_filename
+ File.join(tmp_storage_dir, lfs_oid)
end
def create_tmp_storage_dir
@@ -60,6 +102,20 @@ module Projects
def storage_dir
@storage_dir ||= Gitlab.config.lfs.storage_path
end
+
+ def raise_size_error!
+ raise SizeError, 'Size mistmatch'
+ end
+
+ def raise_oid_error!
+ raise OidError, 'Oid mismatch'
+ end
+
+ def error(message, http_status = nil)
+ log_error(message)
+
+ super
+ end
end
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_import_service.rb b/app/services/projects/lfs_pointers/lfs_import_service.rb
index 9215fa0a7bf..2afcce7099b 100644
--- a/app/services/projects/lfs_pointers/lfs_import_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_import_service.rb
@@ -1,95 +1,23 @@
# frozen_string_literal: true
-# This service manages the whole worflow of discovering the Lfs files in a
-# repository, linking them to the project and downloading (and linking) the non
-# existent ones.
+# This service is responsible of managing the retrieval of the lfs objects,
+# and call the service LfsDownloadService, which performs the download
+# for each of the retrieved lfs objects
module Projects
module LfsPointers
class LfsImportService < BaseService
- include Gitlab::Utils::StrongMemoize
-
- HEAD_REV = 'HEAD'.freeze
- LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze
- LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze
-
- LfsImportError = Class.new(StandardError)
-
def execute
- return {} unless project&.lfs_enabled?
+ return success unless project&.lfs_enabled?
- if external_lfs_endpoint?
- # If the endpoint host is different from the import_url it means
- # that the repo is using a third party service for storing the LFS files.
- # In this case, we have to disable lfs in the project
- disable_lfs!
+ lfs_objects_to_download = LfsObjectDownloadListService.new(project).execute
- return {}
+ lfs_objects_to_download.each do |lfs_download_object|
+ LfsDownloadService.new(project, lfs_download_object).execute
end
- get_download_links
- rescue LfsDownloadLinkListService::DownloadLinksError => e
- raise LfsImportError, "The LFS objects download list couldn't be imported. Error: #{e.message}"
- end
-
- private
-
- def external_lfs_endpoint?
- lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host
- end
-
- def disable_lfs!
- project.update(lfs_enabled: false)
- end
-
- # rubocop: disable CodeReuse/ActiveRecord
- def get_download_links
- existent_lfs = LfsListService.new(project).execute
- linked_oids = LfsLinkService.new(project).execute(existent_lfs.keys)
-
- # Retrieving those oids not linked and which we need to download
- not_linked_lfs = existent_lfs.except(*linked_oids)
-
- LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(not_linked_lfs)
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def lfsconfig_endpoint_uri
- strong_memoize(:lfsconfig_endpoint_uri) do
- # Retrieveing the blob data from the .lfsconfig file
- data = project.repository.lfsconfig_for(HEAD_REV)
- # Parsing the data to retrieve the url
- parsed_data = data&.match(LFS_ENDPOINT_PATTERN)
-
- if parsed_data
- URI.parse(parsed_data[1]).tap do |endpoint|
- endpoint.user ||= import_uri.user
- endpoint.password ||= import_uri.password
- end
- end
- end
- rescue URI::InvalidURIError
- raise LfsImportError, 'Invalid URL in .lfsconfig file'
- end
-
- def import_uri
- @import_uri ||= URI.parse(project.import_url)
- rescue URI::InvalidURIError
- raise LfsImportError, 'Invalid project import URL'
- end
-
- def current_endpoint_uri
- (lfsconfig_endpoint_uri || default_endpoint_uri)
- end
-
- # The import url must end with '.git' here we ensure it is
- def default_endpoint_uri
- @default_endpoint_uri ||= begin
- import_uri.dup.tap do |uri|
- path = uri.path.gsub(%r(/$), '')
- path += '.git' unless path.ends_with?('.git')
- uri.path = path + LFS_BATCH_API_ENDPOINT
- end
- end
+ success
+ rescue => e
+ error(e.message)
end
end
end
diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb
index 8401f3d1d89..e3c956250f0 100644
--- a/app/services/projects/lfs_pointers/lfs_link_service.rb
+++ b/app/services/projects/lfs_pointers/lfs_link_service.rb
@@ -6,9 +6,9 @@ module Projects
class LfsLinkService < BaseService
# Accept an array of oids to link
#
- # Returns a hash with the same structure with oids linked
+ # Returns an array with the oid of the existent lfs objects
def execute(oids)
- return {} unless project&.lfs_enabled?
+ return [] unless project&.lfs_enabled?
# Search and link existing LFS Object
link_existing_lfs_objects(oids)
diff --git a/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb
new file mode 100644
index 00000000000..5ba0f50f2ff
--- /dev/null
+++ b/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+# This service manages the whole worflow of discovering the Lfs files in a
+# repository, linking them to the project and downloading (and linking) the non
+# existent ones.
+module Projects
+ module LfsPointers
+ class LfsObjectDownloadListService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ HEAD_REV = 'HEAD'.freeze
+ LFS_ENDPOINT_PATTERN = /^\t?url\s*=\s*(.+)$/.freeze
+ LFS_BATCH_API_ENDPOINT = '/info/lfs/objects/batch'.freeze
+
+ LfsObjectDownloadListError = Class.new(StandardError)
+
+ def execute
+ return [] unless project&.lfs_enabled?
+
+ if external_lfs_endpoint?
+ # If the endpoint host is different from the import_url it means
+ # that the repo is using a third party service for storing the LFS files.
+ # In this case, we have to disable lfs in the project
+ disable_lfs!
+
+ return []
+ end
+
+ # Getting all Lfs pointers already in the database and linking them to the project
+ linked_oids = LfsLinkService.new(project).execute(lfs_pointers_in_repository.keys)
+ # Retrieving those oids not present in the database which we need to download
+ missing_oids = lfs_pointers_in_repository.except(*linked_oids) # rubocop: disable CodeReuse/ActiveRecord
+ # Downloading the required information and gathering it inside a LfsDownloadObject for each oid
+ LfsDownloadLinkListService.new(project, remote_uri: current_endpoint_uri).execute(missing_oids)
+ rescue LfsDownloadLinkListService::DownloadLinksError => e
+ raise LfsObjectDownloadListError, "The LFS objects download list couldn't be imported. Error: #{e.message}"
+ end
+
+ private
+
+ def external_lfs_endpoint?
+ lfsconfig_endpoint_uri && lfsconfig_endpoint_uri.host != import_uri.host
+ end
+
+ def disable_lfs!
+ unless project.update(lfs_enabled: false)
+ raise LfsDownloadLinkListService::DownloadLinksError, "Invalid project state"
+ end
+ end
+
+ # Retrieves all lfs pointers in the repository
+ def lfs_pointers_in_repository
+ @lfs_pointers_in_repository ||= LfsListService.new(project).execute
+ end
+
+ def lfsconfig_endpoint_uri
+ strong_memoize(:lfsconfig_endpoint_uri) do
+ # Retrieveing the blob data from the .lfsconfig file
+ data = project.repository.lfsconfig_for(HEAD_REV)
+ # Parsing the data to retrieve the url
+ parsed_data = data&.match(LFS_ENDPOINT_PATTERN)
+
+ if parsed_data
+ URI.parse(parsed_data[1]).tap do |endpoint|
+ endpoint.user ||= import_uri.user
+ endpoint.password ||= import_uri.password
+ end
+ end
+ end
+ rescue URI::InvalidURIError
+ raise LfsObjectDownloadListError, 'Invalid URL in .lfsconfig file'
+ end
+
+ def import_uri
+ @import_uri ||= URI.parse(project.import_url)
+ rescue URI::InvalidURIError
+ raise LfsObjectDownloadListError, 'Invalid project import URL'
+ end
+
+ def current_endpoint_uri
+ (lfsconfig_endpoint_uri || default_endpoint_uri)
+ end
+
+ # The import url must end with '.git' here we ensure it is
+ def default_endpoint_uri
+ @default_endpoint_uri ||= begin
+ import_uri.dup.tap do |uri|
+ path = uri.path.gsub(%r(/$), '')
+ path += '.git' unless path.ends_with?('.git')
+ uri.path = path + LFS_BATCH_API_ENDPOINT
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/projects/move_project_group_links_service.rb b/app/services/projects/move_project_group_links_service.rb
index 36afcd0c503..cf4b291c761 100644
--- a/app/services/projects/move_project_group_links_service.rb
+++ b/app/services/projects/move_project_group_links_service.rb
@@ -26,7 +26,7 @@ module Projects
# Remove remaining project group links from source_project
def remove_remaining_project_group_links
- source_project.reload.project_group_links.destroy_all # rubocop: disable DestroyAll
+ source_project.reset.project_group_links.destroy_all # rubocop: disable DestroyAll
end
def group_links_in_target_project
diff --git a/app/services/projects/operations/update_service.rb b/app/services/projects/operations/update_service.rb
new file mode 100644
index 00000000000..48eddb0e8d0
--- /dev/null
+++ b/app/services/projects/operations/update_service.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+module Projects
+ module Operations
+ class UpdateService < BaseService
+ def execute
+ Projects::UpdateService
+ .new(project, current_user, project_update_params)
+ .execute
+ end
+
+ private
+
+ def project_update_params
+ error_tracking_params.merge(metrics_setting_params)
+ end
+
+ def metrics_setting_params
+ attribs = params[:metrics_setting_attributes]
+ return {} unless attribs
+
+ destroy = attribs[:external_dashboard_url].blank?
+
+ { metrics_setting_attributes: attribs.merge(_destroy: destroy) }
+ end
+
+ def error_tracking_params
+ settings = params[:error_tracking_setting_attributes]
+ return {} if settings.blank?
+
+ api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from(
+ api_host: settings[:api_host],
+ project_slug: settings.dig(:project, :slug),
+ organization_slug: settings.dig(:project, :organization_slug)
+ )
+
+ {
+ error_tracking_setting_attributes: {
+ api_url: api_url,
+ token: settings[:token],
+ enabled: settings[:enabled],
+ project_name: settings.dig(:project, :name),
+ organization_name: settings.dig(:project, :organization_name)
+ }
+ }
+ end
+ end
+ end
+end
diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb
index 633a263af7b..a2f36d2bd1b 100644
--- a/app/services/projects/propagate_service_template.rb
+++ b/app/services/projects/propagate_service_template.rb
@@ -80,7 +80,7 @@ module Projects
value = value.is_a?(Hash) ? value.to_json : value
service_hash[ActiveRecord::Base.connection.quote_column_name(key)] =
- ActiveRecord::Base.sanitize(value)
+ ActiveRecord::Base.connection.quote(value)
end
end
end
diff --git a/app/services/projects/protect_default_branch_service.rb b/app/services/projects/protect_default_branch_service.rb
new file mode 100644
index 00000000000..245490791bf
--- /dev/null
+++ b/app/services/projects/protect_default_branch_service.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+module Projects
+ # Service class that can be used to execute actions necessary after creating a
+ # default branch.
+ class ProtectDefaultBranchService
+ attr_reader :project, :default_branch_protection
+
+ # @param [Project] project
+ def initialize(project)
+ @project = project
+
+ @default_branch_protection = Gitlab::Access::BranchProtection
+ .new(Gitlab::CurrentSettings.default_branch_protection)
+ end
+
+ def execute
+ protect_default_branch if default_branch
+ end
+
+ def protect_default_branch
+ # Ensure HEAD points to the default branch in case it is not master
+ project.change_head(default_branch)
+
+ create_protected_branch if protect_branch?
+ end
+
+ def create_protected_branch
+ params = {
+ name: default_branch,
+ push_access_levels_attributes: [{ access_level: push_access_level }],
+ merge_access_levels_attributes: [{ access_level: merge_access_level }]
+ }
+
+ # The creator of the project is always allowed to create protected
+ # branches, so we skip the authorization check in this service class.
+ ProtectedBranches::CreateService
+ .new(project, project.creator, params)
+ .execute(skip_authorization: true)
+ end
+
+ def protect_branch?
+ default_branch_protection.any? &&
+ !ProtectedBranch.protected?(project, default_branch)
+ end
+
+ def default_branch
+ project.default_branch
+ end
+
+ def push_access_level
+ if default_branch_protection.developer_can_push?
+ Gitlab::Access::DEVELOPER
+ else
+ Gitlab::Access::MAINTAINER
+ end
+ end
+
+ def merge_access_level
+ if default_branch_protection.developer_can_merge?
+ Gitlab::Access::DEVELOPER
+ else
+ Gitlab::Access::MAINTAINER
+ end
+ end
+ end
+end
diff --git a/app/services/projects/repository_languages_service.rb b/app/services/projects/repository_languages_service.rb
new file mode 100644
index 00000000000..05f43c2264b
--- /dev/null
+++ b/app/services/projects/repository_languages_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Projects
+ class RepositoryLanguagesService < BaseService
+ def execute
+ perform_language_detection unless project.detected_repository_languages?
+ persisted_repository_languages
+ end
+
+ private
+
+ def perform_language_detection
+ if persisted_repository_languages.blank?
+ ::DetectRepositoryLanguagesWorker.perform_async(project.id)
+ else
+ project.update_column(:detected_repository_languages, true)
+ end
+ end
+
+ def persisted_repository_languages
+ project.repository_languages
+ end
+ end
+end
diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb
index 9db3fd9cf17..233dcf37e35 100644
--- a/app/services/projects/transfer_service.rb
+++ b/app/services/projects/transfer_service.rb
@@ -17,11 +17,11 @@ module Projects
@new_namespace = new_namespace
if @new_namespace.blank?
- raise TransferError, 'Please select a new namespace for your project.'
+ raise TransferError, s_('TransferProject|Please select a new namespace for your project.')
end
unless allowed_transfer?(current_user, project)
- raise TransferError, 'Transfer failed, please contact an admin.'
+ raise TransferError, s_('TransferProject|Transfer failed, please contact an admin.')
end
transfer(project)
@@ -30,7 +30,7 @@ module Projects
true
rescue Projects::TransferService::TransferError => ex
- project.reload
+ project.reset
project.errors.add(:new_namespace, ex.message)
false
end
@@ -45,16 +45,15 @@ module Projects
@old_namespace = project.namespace
if Project.where(namespace_id: @new_namespace.try(:id)).where('path = ? or name = ?', project.path, project.name).exists?
- raise TransferError.new("Project with same name or path in target namespace already exists")
+ raise TransferError.new(s_("TransferProject|Project with same name or path in target namespace already exists"))
end
if project.has_container_registry_tags?
# We currently don't support renaming repository if it contains tags in container registry
- raise TransferError.new('Project cannot be transferred, because tags are present in its container registry')
+ raise TransferError.new(s_('TransferProject|Project cannot be transferred, because tags are present in its container registry'))
end
attempt_transfer_transaction
- configure_group_clusters_for_project
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -81,7 +80,7 @@ module Projects
project.old_path_with_namespace = @old_path
- write_repository_config(@new_path)
+ update_repository_configuration(@new_path)
execute_system_hooks
end
@@ -106,8 +105,9 @@ module Projects
project.save!
end
- def write_repository_config(full_path)
+ def update_repository_configuration(full_path)
project.write_repository_config(gl_full_path: full_path)
+ project.track_project_repository
end
def refresh_permissions
@@ -121,9 +121,9 @@ module Projects
def rollback_side_effects
rollback_folder_move
- project.reload
+ project.reset
update_namespace_and_visibility(@old_namespace)
- write_repository_config(@old_path)
+ update_repository_configuration(@old_path)
end
def rollback_folder_move
@@ -144,7 +144,7 @@ module Projects
# Move main repository
unless move_repo_folder(@old_path, @new_path)
- raise TransferError.new("Cannot move project")
+ raise TransferError.new(s_("TransferProject|Cannot move project"))
end
# Disk path is changed; we need to ensure we reload it
@@ -163,9 +163,5 @@ module Projects
@new_namespace.full_path
)
end
-
- def configure_group_clusters_for_project
- ClusterProjectConfigureWorker.perform_async(project.id)
- end
end
end
diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb
index abf40b3ad7a..674071ad92a 100644
--- a/app/services/projects/update_pages_configuration_service.rb
+++ b/app/services/projects/update_pages_configuration_service.rb
@@ -2,6 +2,8 @@
module Projects
class UpdatePagesConfigurationService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
attr_reader :project
def initialize(project)
@@ -9,15 +11,25 @@ module Projects
end
def execute
- update_file(pages_config_file, pages_config.to_json)
+ if file_equals?(pages_config_file, pages_config_json)
+ return success(reload: false)
+ end
+
+ update_file(pages_config_file, pages_config_json)
reload_daemon
- success
+ success(reload: true)
rescue => e
error(e.message)
end
private
+ def pages_config_json
+ strong_memoize(:pages_config_json) do
+ pages_config.to_json
+ end
+ end
+
def pages_config
{
domains: pages_domains_config,
@@ -67,11 +79,6 @@ module Projects
end
def update_file(file, data)
- unless data
- FileUtils.remove(file, force: true)
- return
- end
-
temp_file = "#{file}.#{SecureRandom.hex(16)}"
File.open(temp_file, 'w') do |f|
f.write(data)
@@ -81,5 +88,18 @@ module Projects
# In case if the updating fails
FileUtils.remove(temp_file, force: true)
end
+
+ def file_equals?(file, data)
+ existing_data = read_file(file)
+ data == existing_data.to_s
+ end
+
+ def read_file(file)
+ File.open(file, 'r') do |f|
+ f.read
+ end
+ rescue
+ nil
+ end
end
end
diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb
index eb2478be3cf..5caeb4cfa5f 100644
--- a/app/services/projects/update_pages_service.rb
+++ b/app/services/projects/update_pages_service.rb
@@ -7,7 +7,11 @@ module Projects
BLOCK_SIZE = 32.kilobytes
MAX_SIZE = 1.terabyte
- SITE_PATH = 'public/'.freeze
+ PUBLIC_DIR = 'public'.freeze
+
+ # this has to be invalid group name,
+ # as it shares the namespace with groups
+ TMP_EXTRACT_PATH = '@pages.tmp'.freeze
attr_reader :build
@@ -27,12 +31,11 @@ module Projects
raise InvalidStateError, 'pages are outdated' unless latest?
# Create temporary directory in which we will extract the artifacts
- FileUtils.mkdir_p(tmp_path)
- Dir.mktmpdir(nil, tmp_path) do |archive_path|
+ make_secure_tmp_dir(tmp_path) do |archive_path|
extract_archive!(archive_path)
# Check if we did extract public directory
- archive_public_path = File.join(archive_path, 'public')
+ archive_public_path = File.join(archive_path, PUBLIC_DIR)
raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
raise InvalidStateError, 'pages are outdated' unless latest?
@@ -85,22 +88,18 @@ module Projects
raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
# Calculate page size after extract
- public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true)
+ public_entry = build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true)
if public_entry.total_size > max_size
raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}"
end
- # Requires UnZip at least 6.00 Info-ZIP.
- # -qq be (very) quiet
- # -n never overwrite existing files
- # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories
- site_path = File.join(SITE_PATH, '*')
build.artifacts_file.use_file do |artifacts_path|
- unless system(*%W(unzip -n #{artifacts_path} #{site_path} -d #{temp_path}))
- raise FailedToExtractError, 'pages failed to extract'
- end
+ SafeZip::Extract.new(artifacts_path)
+ .extract(directories: [PUBLIC_DIR], to: temp_path)
end
+ rescue SafeZip::Extract::Error => e
+ raise FailedToExtractError, e.message
end
def deploy_page!(archive_public_path)
@@ -139,7 +138,7 @@ module Projects
end
def tmp_path
- @tmp_path ||= File.join(::Settings.pages.path, 'tmp')
+ @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH)
end
def pages_path
@@ -147,11 +146,11 @@ module Projects
end
def public_path
- @public_path ||= File.join(pages_path, 'public')
+ @public_path ||= File.join(pages_path, PUBLIC_DIR)
end
def previous_public_path
- @previous_public_path ||= File.join(pages_path, "public.#{SecureRandom.hex}")
+ @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}")
end
def ref
@@ -188,5 +187,15 @@ module Projects
def pages_deployments_failed_total_counter
@pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
end
+
+ def make_secure_tmp_dir(tmp_path)
+ FileUtils.mkdir_p(tmp_path)
+ path = Dir.mktmpdir(nil, tmp_path)
+ begin
+ yield(path)
+ ensure
+ FileUtils.remove_entry_secure(path)
+ end
+ end
end
end
diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb
index 93e48fc0199..dfa7bd20254 100644
--- a/app/services/projects/update_service.rb
+++ b/app/services/projects/update_service.rb
@@ -3,6 +3,7 @@
module Projects
class UpdateService < BaseService
include UpdateVisibilityLevel
+ include ValidatesClassificationLabel
ValidationError = Class.new(StandardError)
@@ -14,6 +15,8 @@ module Projects
yield if block_given?
+ validate_classification_label(project, :external_authorization_classification_label)
+
# If the block added errors, don't try to save the project
return update_failed! if project.errors.any?
@@ -39,15 +42,15 @@ module Projects
def validate!
unless valid_visibility_level_change?(project, params[:visibility_level])
- raise ValidationError.new('New visibility level not allowed!')
+ raise ValidationError.new(s_('UpdateProject|New visibility level not allowed!'))
end
if renaming_project_with_container_registry_tags?
- raise ValidationError.new('Cannot rename project because it contains container registry tags!')
+ raise ValidationError.new(s_('UpdateProject|Cannot rename project because it contains container registry tags!'))
end
if changing_default_branch?
- raise ValidationError.new("Could not set the default branch") unless project.change_head(params[:default_branch])
+ raise ValidationError.new(s_("UpdateProject|Could not set the default branch")) unless project.change_head(params[:default_branch])
end
end
@@ -61,13 +64,13 @@ module Projects
if project.previous_changes.include?(:visibility_level) && project.private?
# don't enqueue immediately to prevent todos removal in case of a mistake
- TodosDestroyer::ProjectPrivateWorker.perform_in(1.hour, project.id)
+ TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id)
elsif (project_changed_feature_keys & todos_features_changes).present?
- TodosDestroyer::PrivateFeaturesWorker.perform_in(1.hour, project.id)
+ TodosDestroyer::PrivateFeaturesWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id)
end
if project.previous_changes.include?('path')
- AfterRenameService.new(project).execute
+ after_rename_service(project).execute
else
system_hook_service.execute_hooks_for(project, :update)
end
@@ -75,13 +78,17 @@ module Projects
update_pages_config if changing_pages_related_config?
end
+ def after_rename_service(project)
+ AfterRenameService.new(project, path_before: project.path_before_last_save, full_path_before: project.full_path_before_last_save)
+ end
+
def changing_pages_related_config?
changing_pages_https_only? || changing_pages_access_level?
end
def update_failed!
model_errors = project.errors.full_messages.to_sentence
- error_message = model_errors.presence || 'Project could not be updated!'
+ error_message = model_errors.presence || s_('UpdateProject|Project could not be updated!')
error(error_message)
end
diff --git a/app/services/projects/update_statistics_service.rb b/app/services/projects/update_statistics_service.rb
new file mode 100644
index 00000000000..f32a779fab0
--- /dev/null
+++ b/app/services/projects/update_statistics_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Projects
+ class UpdateStatisticsService < BaseService
+ def execute
+ return unless project && project.repository.exists?
+
+ Rails.logger.info("Updating statistics for project #{project.id}")
+
+ project.statistics.refresh!(only: statistics.map(&:to_sym))
+ end
+
+ private
+
+ def statistics
+ params[:statistics]
+ end
+ end
+end
diff --git a/app/services/prometheus/adapter_service.rb b/app/services/prometheus/adapter_service.rb
index a791845ba20..3be958e1613 100644
--- a/app/services/prometheus/adapter_service.rb
+++ b/app/services/prometheus/adapter_service.rb
@@ -30,7 +30,7 @@ module Prometheus
return unless deployment_platform.respond_to?(:cluster)
cluster = deployment_platform.cluster
- return unless cluster.application_prometheus&.ready?
+ return unless cluster.application_prometheus&.available?
cluster.application_prometheus
end
diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb
new file mode 100644
index 00000000000..c5d2b84878b
--- /dev/null
+++ b/app/services/prometheus/proxy_service.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module Prometheus
+ class ProxyService < BaseService
+ include ReactiveCaching
+ include Gitlab::Utils::StrongMemoize
+
+ self.reactive_cache_key = ->(service) { service.cache_key }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
+
+ attr_accessor :proxyable, :method, :path, :params
+
+ PROXY_SUPPORT = {
+ 'query' => {
+ method: ['GET'],
+ params: %w(query time timeout)
+ },
+ 'query_range' => {
+ method: ['GET'],
+ params: %w(query start end step timeout)
+ }
+ }.freeze
+
+ def self.from_cache(proxyable_class_name, proxyable_id, method, path, params)
+ proxyable_class = begin
+ proxyable_class_name.constantize
+ rescue NameError
+ nil
+ end
+ return unless proxyable_class
+
+ proxyable = proxyable_class.find(proxyable_id)
+
+ new(proxyable, method, path, params)
+ end
+
+ # proxyable can be any model which responds to .prometheus_adapter
+ # like Environment.
+ def initialize(proxyable, method, path, params)
+ @proxyable = proxyable
+ @path = path
+
+ # Convert ActionController::Parameters to hash because reactive_cache_worker
+ # does not play nice with ActionController::Parameters.
+ @params = filter_params(params, path).to_hash
+
+ @method = method
+ end
+
+ def id
+ nil
+ end
+
+ def execute
+ return cannot_proxy_response unless can_proxy?
+ return no_prometheus_response unless can_query?
+
+ with_reactive_cache(*cache_key) do |result|
+ result
+ end
+ end
+
+ def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params)
+ return no_prometheus_response unless can_query?
+
+ response = prometheus_client_wrapper.proxy(path, params)
+
+ success(http_status: response.code, body: response.body)
+ rescue Gitlab::PrometheusClient::Error => err
+ service_unavailable_response(err)
+ end
+
+ def cache_key
+ [@proxyable.class.name, @proxyable.id, @method, @path, @params]
+ end
+
+ private
+
+ def service_unavailable_response(exception)
+ error(exception.message, :service_unavailable)
+ end
+
+ def no_prometheus_response
+ error('No prometheus server found', :service_unavailable)
+ end
+
+ def cannot_proxy_response
+ error('Proxy support for this API is not available currently')
+ end
+
+ def prometheus_adapter
+ strong_memoize(:prometheus_adapter) do
+ @proxyable.prometheus_adapter
+ end
+ end
+
+ def prometheus_client_wrapper
+ prometheus_adapter&.prometheus_client_wrapper
+ end
+
+ def can_query?
+ prometheus_adapter&.can_query?
+ end
+
+ def filter_params(params, path)
+ params.slice(*PROXY_SUPPORT.dig(path, :params))
+ end
+
+ def can_proxy?
+ PROXY_SUPPORT.dig(@path, :method)&.include?(@method)
+ end
+ end
+end
diff --git a/app/services/protected_branches/api_service.rb b/app/services/protected_branches/api_service.rb
index 4340d3e8260..1b13dace5f2 100644
--- a/app/services/protected_branches/api_service.rb
+++ b/app/services/protected_branches/api_service.rb
@@ -3,24 +3,15 @@
module ProtectedBranches
class ApiService < BaseService
def create
- @push_params = AccessLevelParams.new(:push, params)
- @merge_params = AccessLevelParams.new(:merge, params)
-
- verify_params!
-
- protected_branch_params = {
- name: params[:name],
- push_access_levels_attributes: @push_params.access_levels,
- merge_access_levels_attributes: @merge_params.access_levels
- }
-
::ProtectedBranches::CreateService.new(@project, @current_user, protected_branch_params).execute
end
- private
-
- def verify_params!
- # EE-only
+ def protected_branch_params
+ {
+ name: params[:name],
+ push_access_levels_attributes: AccessLevelParams.new(:push, params).access_levels,
+ merge_access_levels_attributes: AccessLevelParams.new(:merge, params).access_levels
+ }
end
end
end
diff --git a/app/services/protected_branches/legacy_api_update_service.rb b/app/services/protected_branches/legacy_api_update_service.rb
index da8bf2ce02a..7cb8d41818f 100644
--- a/app/services/protected_branches/legacy_api_update_service.rb
+++ b/app/services/protected_branches/legacy_api_update_service.rb
@@ -6,30 +6,31 @@
# lives in this service.
module ProtectedBranches
class LegacyApiUpdateService < BaseService
+ attr_reader :protected_branch, :developers_can_push, :developers_can_merge
+
def execute(protected_branch)
+ @protected_branch = protected_branch
@developers_can_push = params.delete(:developers_can_push)
@developers_can_merge = params.delete(:developers_can_merge)
- @protected_branch = protected_branch
-
protected_branch.transaction do
delete_redundant_access_levels
- case @developers_can_push
+ case developers_can_push
when true
params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
when false
params[:push_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }]
end
- case @developers_can_merge
+ case developers_can_merge
when true
params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::DEVELOPER }]
when false
params[:merge_access_levels_attributes] = [{ access_level: Gitlab::Access::MAINTAINER }]
end
- service = ProtectedBranches::UpdateService.new(@project, @current_user, @params)
+ service = ProtectedBranches::UpdateService.new(project, current_user, params)
service.execute(protected_branch)
end
end
@@ -37,12 +38,12 @@ module ProtectedBranches
private
def delete_redundant_access_levels
- unless @developers_can_merge.nil?
- @protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll
+ unless developers_can_merge.nil?
+ protected_branch.merge_access_levels.destroy_all # rubocop: disable DestroyAll
end
- unless @developers_can_push.nil?
- @protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll
+ unless developers_can_push.nil?
+ protected_branch.push_access_levels.destroy_all # rubocop: disable DestroyAll
end
end
end
diff --git a/app/services/push_event_payload_service.rb b/app/services/push_event_payload_service.rb
index bb1259787af..fe366ac225b 100644
--- a/app/services/push_event_payload_service.rb
+++ b/app/services/push_event_payload_service.rb
@@ -46,7 +46,7 @@ class PushEventPayloadService
def commit_title
commit = @push_data.fetch(:commits).last
- return nil unless commit && commit[:message]
+ return unless commit && commit[:message]
raw_msg = commit[:message]
diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb
index d248b10f41e..8ff73522e5f 100644
--- a/app/services/quick_actions/interpret_service.rb
+++ b/app/services/quick_actions/interpret_service.rb
@@ -2,17 +2,26 @@
module QuickActions
class InterpretService < BaseService
+ include Gitlab::Utils::StrongMemoize
include Gitlab::QuickActions::Dsl
+ include Gitlab::QuickActions::IssueActions
+ include Gitlab::QuickActions::IssueAndMergeRequestActions
+ include Gitlab::QuickActions::IssuableActions
+ include Gitlab::QuickActions::MergeRequestActions
+ include Gitlab::QuickActions::CommitActions
+ include Gitlab::QuickActions::CommonActions
- attr_reader :issuable
+ attr_reader :quick_action_target
- SHRUG = '¯\\_(ツ)_/¯'.freeze
- TABLEFLIP = '(╯°□°)╯︵ ┻━┻'.freeze
+ # Counts how many commands have been executed.
+ # Used to display relevant feedback on UI when a note
+ # with only commands has been processed.
+ attr_accessor :commands_executed_count
- # Takes an issuable and returns an array of all the available commands
+ # Takes an quick_action_target and returns an array of all the available commands
# represented with .to_h
- def available_commands(issuable)
- @issuable = issuable
+ def available_commands(quick_action_target)
+ @quick_action_target = quick_action_target
self.class.command_definitions.map do |definition|
next unless definition.available?(self)
@@ -23,10 +32,10 @@ module QuickActions
# 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, only: nil)
+ def execute(content, quick_action_target, only: nil)
return [content, {}] unless current_user.can?(:use_quick_actions)
- @issuable = issuable
+ @quick_action_target = quick_action_target
@updates = {}
content, commands = extractor.extract_commands(content, only: only)
@@ -37,10 +46,10 @@ module QuickActions
# Takes a text and interprets the commands that are extracted from it.
# Returns the content without commands, and array of changes explained.
- def explain(content, issuable)
+ def explain(content, quick_action_target)
return [content, []] unless current_user.can?(:use_quick_actions)
- @issuable = issuable
+ @quick_action_target = quick_action_target
content, commands = extractor.extract_commands(content)
commands = explain_commands(commands)
@@ -53,604 +62,6 @@ module QuickActions
Gitlab::QuickActions::Extractor.new(self.class.command_definitions)
end
- desc do
- "Close this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- explanation do
- "Closes this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.open? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :close do
- @updates[:state_event] = 'close'
- end
-
- desc do
- "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}"
- end
- explanation do
- "Reopens this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.closed? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :reopen do
- @updates[:state_event] = 'reopen'
- end
-
- desc 'Merge (when the pipeline succeeds)'
- explanation 'Merges this merge request when the pipeline succeeds.'
- condition do
- last_diff_sha = params && params[:merge_request_diff_head_sha]
- issuable.is_a?(MergeRequest) &&
- issuable.persisted? &&
- issuable.mergeable_with_quick_action?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha)
- end
- command :merge do
- @updates[:merge] = params[:merge_request_diff_head_sha]
- end
-
- desc 'Change title'
- explanation do |title_param|
- "Changes the title to \"#{title_param}\"."
- end
- params '<New title>'
- condition do
- issuable.persisted? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :title do |title_param|
- @updates[:title] = title_param
- end
-
- desc 'Assign'
- # rubocop: disable CodeReuse/ActiveRecord
- explanation do |users|
- users = issuable.allows_multiple_assignees? ? users : users.take(1)
- "Assigns #{users.map(&:to_reference).to_sentence}."
- end
- # rubocop: enable CodeReuse/ActiveRecord
- params do
- issuable.allows_multiple_assignees? ? '@user1 @user2' : '@user'
- end
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |assignee_param|
- extract_users(assignee_param)
- end
- command :assign do |users|
- next if users.empty?
-
- if issuable.allows_multiple_assignees?
- @updates[:assignee_ids] ||= issuable.assignees.map(&:id)
- @updates[:assignee_ids] += users.map(&:id)
- else
- @updates[:assignee_ids] = [users.first.id]
- end
- end
-
- desc do
- if issuable.allows_multiple_assignees?
- 'Remove all or specific assignee(s)'
- else
- 'Remove assignee'
- end
- end
- explanation do |users = nil|
- assignees = issuable.assignees
- assignees &= users if users.present? && issuable.allows_multiple_assignees?
- "Removes #{'assignee'.pluralize(assignees.size)} #{assignees.map(&:to_reference).to_sentence}."
- end
- params do
- issuable.allows_multiple_assignees? ? '@user1 @user2' : ''
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.assignees.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |unassign_param|
- # When multiple users are assigned, all will be unassigned if multiple assignees are no longer allowed
- extract_users(unassign_param) if issuable.allows_multiple_assignees?
- end
- command :unassign do |users = nil|
- if issuable.allows_multiple_assignees? && users&.any?
- @updates[:assignee_ids] ||= issuable.assignees.map(&:id)
- @updates[:assignee_ids] -= users.map(&:id)
- else
- @updates[:assignee_ids] = []
- end
- end
-
- desc 'Set milestone'
- explanation do |milestone|
- "Sets the milestone to #{milestone.to_reference}." if milestone
- end
- params '%"milestone"'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- find_milestones(project, state: 'active').any?
- end
- parse_params do |milestone_param|
- extract_references(milestone_param, :milestone).first ||
- find_milestones(project, title: milestone_param.strip).first
- end
- command :milestone do |milestone|
- @updates[:milestone_id] = milestone.id if milestone
- end
-
- desc 'Remove milestone'
- explanation do
- "Removes #{issuable.milestone.to_reference(format: :name)} milestone."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.milestone_id? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_milestone do
- @updates[:milestone_id] = nil
- end
-
- desc 'Add label(s)'
- explanation do |labels_param|
- labels = find_label_references(labels_param)
-
- "Adds #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- end
- params '~label1 ~"label 2"'
- condition do
- if project
- available_labels = LabelsFinder
- .new(current_user, project_id: project.id, include_ancestor_groups: true)
- .execute
- end
-
- project &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
- available_labels.any?
- end
- command :label do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:add_label_ids] ||= []
- @updates[:add_label_ids] += label_ids
-
- @updates[:add_label_ids].uniq!
- end
- end
-
- desc 'Remove all or specific label(s)'
- explanation do |labels_param = nil|
- if labels_param.present?
- labels = find_label_references(labels_param)
- "Removes #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- else
- 'Removes all labels.'
- end
- end
- params '~label1 ~"label 2"'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :unlabel do |labels_param = nil|
- if labels_param.present?
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:remove_label_ids] ||= []
- @updates[:remove_label_ids] += label_ids
-
- @updates[:remove_label_ids].uniq!
- end
- else
- @updates[:label_ids] = []
- end
- end
-
- desc 'Replace all label(s)'
- explanation do |labels_param|
- labels = find_label_references(labels_param)
- "Replaces all labels with #{labels.join(' ')} #{'label'.pluralize(labels.count)}." if labels.any?
- end
- params '~label1 ~"label 2"'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.labels.any? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :relabel do |labels_param|
- label_ids = find_label_ids(labels_param)
-
- if label_ids.any?
- @updates[:label_ids] ||= []
- @updates[:label_ids] += label_ids
-
- @updates[:label_ids].uniq!
- end
- end
-
- desc 'Copy labels and milestone from other issue or merge request'
- explanation do |source_issuable|
- "Copy labels and milestone from #{source_issuable.to_reference}."
- end
- params '#issue | !merge_request'
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- parse_params do |issuable_param|
- extract_references(issuable_param, :issue).first ||
- extract_references(issuable_param, :merge_request).first
- end
- command :copy_metadata do |source_issuable|
- if source_issuable.present? && source_issuable.project.id == issuable.project.id
- @updates[:add_label_ids] = source_issuable.labels.map(&:id)
- @updates[:milestone_id] = source_issuable.milestone.id if source_issuable.milestone
- end
- end
-
- desc 'Add a todo'
- explanation 'Adds a todo.'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- !TodoService.new.todo_exist?(issuable, current_user)
- end
- command :todo do
- @updates[:todo_event] = 'add'
- end
-
- desc 'Mark todo as done'
- explanation 'Marks todo as done.'
- condition do
- issuable.persisted? &&
- TodoService.new.todo_exist?(issuable, current_user)
- end
- command :done do
- @updates[:todo_event] = 'done'
- end
-
- desc 'Subscribe'
- explanation do
- "Subscribes to this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- !issuable.subscribed?(current_user, project)
- end
- command :subscribe do
- @updates[:subscription_event] = 'subscribe'
- end
-
- desc 'Unsubscribe'
- explanation do
- "Unsubscribes from this #{issuable.to_ability_name.humanize(capitalize: false)}."
- end
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted? &&
- issuable.subscribed?(current_user, project)
- end
- command :unsubscribe do
- @updates[:subscription_event] = 'unsubscribe'
- end
-
- desc 'Set due date'
- explanation do |due_date|
- "Sets the due date to #{due_date.to_s(:medium)}." if due_date
- end
- params '<in 2 days | this Friday | December 31st>'
- condition do
- issuable.respond_to?(:due_date) &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |due_date_param|
- Chronic.parse(due_date_param).try(:to_date)
- end
- command :due do |due_date|
- @updates[:due_date] = due_date if due_date
- end
-
- desc 'Remove due date'
- explanation 'Removes the due date.'
- condition do
- issuable.persisted? &&
- issuable.respond_to?(:due_date) &&
- issuable.due_date? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_due_date do
- @updates[:due_date] = nil
- end
-
- desc 'Toggle the Work In Progress status'
- explanation do
- verb = issuable.work_in_progress? ? 'Unmarks' : 'Marks'
- noun = issuable.to_ability_name.humanize(capitalize: false)
- "#{verb} this #{noun} as Work In Progress."
- end
- condition do
- issuable.respond_to?(:work_in_progress?) &&
- # Allow it to mark as WIP on MR creation page _or_ through MR notes.
- (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable))
- end
- command :wip do
- @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
- end
-
- desc 'Toggle emoji award'
- explanation do |name|
- "Toggles :#{name}: emoji award." if name
- end
- params ':emoji:'
- condition do
- issuable.is_a?(Issuable) &&
- issuable.persisted?
- end
- parse_params do |emoji_param|
- match = emoji_param.match(Banzai::Filter::EmojiFilter.emoji_pattern)
- match[1] if match
- end
- command :award do |name|
- if name && issuable.user_can_award?(current_user)
- @updates[:emoji_award] = name
- end
- end
-
- desc 'Set time estimate'
- explanation do |time_estimate|
- time_estimate = Gitlab::TimeTrackingFormatter.output(time_estimate)
-
- "Sets time estimate to #{time_estimate}." if time_estimate
- end
- params '<1w 3d 2h 14m>'
- condition do
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- parse_params do |raw_duration|
- Gitlab::TimeTrackingFormatter.parse(raw_duration)
- end
- command :estimate do |time_estimate|
- if time_estimate
- @updates[:time_estimate] = time_estimate
- end
- end
-
- desc 'Add or subtract spent time'
- explanation do |time_spent, time_spent_date|
- if time_spent
- if time_spent > 0
- verb = 'Adds'
- value = time_spent
- else
- verb = 'Subtracts'
- value = -time_spent
- end
-
- "#{verb} #{Gitlab::TimeTrackingFormatter.output(value)} spent time."
- end
- end
- params '<time(1h30m | -1h30m)> <date(YYYY-MM-DD)>'
- condition do
- issuable.is_a?(TimeTrackable) &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- parse_params do |raw_time_date|
- Gitlab::QuickActions::SpendTimeAndDateSeparator.new(raw_time_date).execute
- end
- command :spend do |time_spent, time_spent_date|
- if time_spent
- @updates[:spend_time] = {
- duration: time_spent,
- user_id: current_user.id,
- spent_at: time_spent_date
- }
- end
- end
-
- desc 'Remove time estimate'
- explanation 'Removes time estimate.'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_estimate do
- @updates[:time_estimate] = 0
- end
-
- desc 'Remove spent time'
- explanation 'Removes spent time.'
- condition do
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :remove_time_spent do
- @updates[:spend_time] = { duration: :reset, user_id: current_user.id }
- end
-
- desc "Append the comment with #{SHRUG}"
- params '<Comment>'
- substitution :shrug do |comment|
- "#{comment} #{SHRUG}"
- end
-
- desc "Append the comment with #{TABLEFLIP}"
- params '<Comment>'
- substitution :tableflip do |comment|
- "#{comment} #{TABLEFLIP}"
- end
-
- desc "Lock the discussion"
- explanation "Locks the discussion"
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- issuable.persisted? &&
- !issuable.discussion_locked? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :lock do
- @updates[:discussion_locked] = true
- end
-
- desc "Unlock the discussion"
- explanation "Unlocks the discussion"
- condition do
- [MergeRequest, Issue].include?(issuable.class) &&
- issuable.persisted? &&
- issuable.discussion_locked? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :unlock do
- @updates[:discussion_locked] = false
- end
-
- # This is a dummy command, so that it appears in the autocomplete commands
- desc 'CC'
- params '@user'
- command :cc
-
- desc 'Set target branch'
- explanation do |branch_name|
- "Sets target branch to #{branch_name}."
- end
- params '<Local branch name>'
- condition do
- issuable.respond_to?(:target_branch) &&
- (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) ||
- issuable.new_record?)
- end
- parse_params do |target_branch_param|
- target_branch_param.strip
- end
- command :target_branch do |branch_name|
- @updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
- end
-
- desc 'Move issue from one column of the board to another'
- explanation do |target_list_name|
- label = find_label_references(target_list_name).first
- "Moves issue to #{label} column in the board." if label
- end
- params '~"Target column"'
- condition do
- issuable.is_a?(Issue) &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable) &&
- issuable.project.boards.count == 1
- end
- # rubocop: disable CodeReuse/ActiveRecord
- command :board_move do |target_list_name|
- label_ids = find_label_ids(target_list_name)
-
- if label_ids.size == 1
- label_id = label_ids.first
-
- # Ensure this label corresponds to a list on the board
- next unless Label.on_project_boards(issuable.project_id).where(id: label_id).exists?
-
- @updates[:remove_label_ids] =
- issuable.labels.on_project_boards(issuable.project_id).where.not(id: label_id).pluck(:id)
- @updates[:add_label_ids] = [label_id]
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- desc 'Mark this issue as a duplicate of another issue'
- explanation do |duplicate_reference|
- "Marks this issue as a duplicate of #{duplicate_reference}."
- end
- params '#issue'
- condition do
- issuable.is_a?(Issue) &&
- issuable.persisted? &&
- current_user.can?(:"update_#{issuable.to_ability_name}", issuable)
- end
- command :duplicate do |duplicate_param|
- canonical_issue = extract_references(duplicate_param, :issue).first
-
- if canonical_issue.present?
- @updates[:canonical_issue_id] = canonical_issue.id
- end
- end
-
- desc 'Move this issue to another project.'
- explanation do |path_to_project|
- "Moves this issue to #{path_to_project}."
- end
- params 'path/to/project'
- condition do
- issuable.is_a?(Issue) &&
- issuable.persisted? &&
- current_user.can?(:"admin_#{issuable.to_ability_name}", project)
- end
- command :move do |target_project_path|
- target_project = Project.find_by_full_path(target_project_path)
-
- if target_project.present?
- @updates[:target_project] = target_project
- end
- end
-
- desc 'Make issue confidential.'
- explanation do
- 'Makes this issue confidential'
- end
- condition do
- issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
- end
- command :confidential do
- @updates[:confidential] = true
- end
-
- desc 'Tag this commit.'
- explanation do |tag_name, message|
- with_message = %{ with "#{message}"} if message.present?
- "Tags this commit to #{tag_name}#{with_message}."
- end
- params 'v1.2.3 <message>'
- parse_params do |tag_name_and_message|
- tag_name_and_message.split(' ', 2)
- end
- condition do
- issuable.is_a?(Commit) && current_user.can?(:push_code, project)
- end
- command :tag do |tag_name, message|
- @updates[:tag_name] = tag_name
- @updates[:tag_message] = message
- end
-
- desc 'Create a merge request.'
- explanation do |branch_name = nil|
- branch_text = branch_name ? "branch '#{branch_name}'" : 'a branch'
- "Creates #{branch_text} and a merge request to resolve this issue"
- end
- params "<branch name>"
- condition do
- issuable.is_a?(Issue) && current_user.can?(:create_merge_request_in, project) && current_user.can?(:push_code, project)
- end
- command :create_merge_request do |branch_name = nil|
- @updates[:create_merge_request] = {
- branch_name: branch_name,
- issue_iid: issuable.iid
- }
- end
-
# rubocop: disable CodeReuse/ActiveRecord
def extract_users(params)
return [] if params.nil?
@@ -674,9 +85,38 @@ module QuickActions
MilestonesFinder.new(params.merge(project_ids: [project.id], group_ids: [project.group&.id])).execute
end
- def find_labels(labels_param)
- extract_references(labels_param, :label) |
- LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute
+ def parent
+ project || group
+ end
+
+ def group
+ strong_memoize(:group) do
+ quick_action_target.group if quick_action_target.respond_to?(:group)
+ end
+ end
+
+ def find_labels(labels_params = nil)
+ extract_references(labels_params, :label) | find_labels_by_name_no_tilde(labels_params)
+ end
+
+ def find_labels_by_name_no_tilde(labels_params)
+ return Label.none if label_with_tilde?(labels_params)
+
+ finder_params = { include_ancestor_groups: true }
+ finder_params[:project_id] = project.id if project
+ finder_params[:group_id] = group.id if group
+ finder_params[:name] = extract_label_names(labels_params) if labels_params
+
+ LabelsFinder.new(current_user, finder_params).execute
+ end
+
+ def label_with_tilde?(labels_params)
+ labels_params&.include?('~')
+ end
+
+ def extract_label_names(labels_params)
+ # '"A" "A B C" A B' => ["A", "A B C", "A", "B"]
+ labels_params.scan(/"([^"]+)"|([^ ]+)/).flatten.compact
end
def find_label_references(labels_param)
@@ -707,9 +147,11 @@ module QuickActions
# rubocop: disable CodeReuse/ActiveRecord
def extract_references(arg, type)
+ return [] unless arg
+
ext = Gitlab::ReferenceExtractor.new(project, current_user)
- ext.analyze(arg, author: current_user)
+ ext.analyze(arg, author: current_user, group: group)
ext.references(type)
end
diff --git a/app/services/releases/concerns.rb b/app/services/releases/concerns.rb
new file mode 100644
index 00000000000..ff6b696ca96
--- /dev/null
+++ b/app/services/releases/concerns.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+module Releases
+ module Concerns
+ extend ActiveSupport::Concern
+ include Gitlab::Utils::StrongMemoize
+
+ included do
+ def tag_name
+ params[:tag]
+ end
+
+ def ref
+ params[:ref]
+ end
+
+ def name
+ params[:name] || tag_name
+ end
+
+ def description
+ params[:description]
+ end
+
+ def release
+ strong_memoize(:release) do
+ project.releases.find_by_tag(tag_name)
+ end
+ end
+
+ def existing_tag
+ strong_memoize(:existing_tag) do
+ repository.find_tag(tag_name)
+ end
+ end
+
+ def tag_exist?
+ existing_tag.present?
+ end
+
+ def repository
+ strong_memoize(:repository) do
+ project.repository
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb
new file mode 100644
index 00000000000..a271a7e5e49
--- /dev/null
+++ b/app/services/releases/create_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Releases
+ class CreateService < BaseService
+ include Releases::Concerns
+
+ def execute
+ return error('Access Denied', 403) unless allowed?
+ return error('Release already exists', 409) if release
+
+ tag = ensure_tag
+
+ return tag unless tag.is_a?(Gitlab::Git::Tag)
+
+ create_release(tag)
+ end
+
+ def find_or_build_release
+ release || build_release(existing_tag)
+ end
+
+ private
+
+ def ensure_tag
+ existing_tag || create_tag
+ end
+
+ def create_tag
+ return error('Ref is not specified', 422) unless ref
+
+ result = Tags::CreateService
+ .new(project, current_user)
+ .execute(tag_name, ref, nil)
+
+ return result unless result[:status] == :success
+
+ result[:tag]
+ end
+
+ def allowed?
+ Ability.allowed?(current_user, :create_release, project)
+ end
+
+ def create_release(tag)
+ release = build_release(tag)
+
+ release.save!
+
+ success(tag: tag, release: release)
+ rescue => e
+ error(e.message, 400)
+ end
+
+ def build_release(tag)
+ project.releases.build(
+ name: name,
+ description: description,
+ author: current_user,
+ tag: tag.name,
+ sha: tag.dereferenced_target.sha,
+ links_attributes: params.dig(:assets, 'links') || []
+ )
+ end
+ end
+end
diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb
new file mode 100644
index 00000000000..f9f6101abdd
--- /dev/null
+++ b/app/services/releases/destroy_service.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module Releases
+ class DestroyService < BaseService
+ include Releases::Concerns
+
+ def execute
+ return error('Release does not exist', 404) unless release
+ return error('Access Denied', 403) unless allowed?
+
+ if release.destroy
+ success(tag: existing_tag, release: release)
+ else
+ error(release.errors.messages || '400 Bad request', 400)
+ end
+ end
+
+ private
+
+ def allowed?
+ Ability.allowed?(current_user, :destroy_release, release)
+ end
+ end
+end
diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb
new file mode 100644
index 00000000000..fabfa398c59
--- /dev/null
+++ b/app/services/releases/update_service.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module Releases
+ class UpdateService < BaseService
+ include Releases::Concerns
+
+ def execute
+ return error('Tag does not exist', 404) unless existing_tag
+ return error('Release does not exist', 404) unless release
+ return error('Access Denied', 403) unless allowed?
+ return error('params is empty', 400) if empty_params?
+
+ if release.update(params)
+ success(tag: existing_tag, release: release)
+ else
+ error(release.errors.messages || '400 Bad request', 400)
+ end
+ end
+
+ private
+
+ def allowed?
+ Ability.allowed?(current_user, :update_release, release)
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def empty_params?
+ params.except(:tag).empty?
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb
index 039d6e2ebad..b45e567079b 100644
--- a/app/services/resource_events/change_labels_service.rb
+++ b/app/services/resource_events/change_labels_service.rb
@@ -12,7 +12,7 @@ module ResourceEvents
label_hash = {
resource_column(resource) => resource.id,
user_id: user.id,
- created_at: Time.now
+ created_at: resource.system_note_timestamp
}
labels = added_labels.map do |label|
diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb
index cb1bf0a03a5..f711839e389 100644
--- a/app/services/search/global_service.rb
+++ b/app/services/search/global_service.rb
@@ -2,6 +2,8 @@
module Search
class GlobalService
+ include Gitlab::Utils::StrongMemoize
+
attr_accessor :current_user, :params
attr_reader :default_project_filter
@@ -19,11 +21,16 @@ module Search
@projects ||= ProjectsFinder.new(current_user: current_user).execute
end
- def scope
- @scope ||= begin
+ def allowed_scopes
+ strong_memoize(:allowed_scopes) do
allowed_scopes = %w[issues merge_requests milestones]
+ allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
+ end
+ end
- allowed_scopes.delete(params[:scope]) { 'projects' }
+ def scope
+ strong_memoize(:scope) do
+ allowed_scopes.include?(params[:scope]) ? params[:scope] : 'projects'
end
end
end
diff --git a/app/services/search/group_service.rb b/app/services/search/group_service.rb
index 34803d005e3..6f3b5f00b86 100644
--- a/app/services/search/group_service.rb
+++ b/app/services/search/group_service.rb
@@ -11,6 +11,12 @@ module Search
@group = group
end
+ def execute
+ Gitlab::GroupSearchResults.new(
+ current_user, projects, group, params[:search], default_project_filter: default_project_filter
+ )
+ end
+
def projects
return Project.none unless group
return @projects if defined? @projects
diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb
index f223c8be103..32d5cd7ddb2 100644
--- a/app/services/search/project_service.rb
+++ b/app/services/search/project_service.rb
@@ -16,7 +16,12 @@ module Search
end
def scope
- @scope ||= %w[notes issues merge_requests milestones wiki_blobs commits].delete(params[:scope]) { 'blobs' }
+ @scope ||= begin
+ allowed_scopes = %w[notes issues merge_requests milestones wiki_blobs commits]
+ allowed_scopes << 'users' if Feature.enabled?(:users_search, default_enabled: true)
+
+ allowed_scopes.delete(params[:scope]) { 'blobs' }
+ end
end
end
end
diff --git a/app/services/service_response.rb b/app/services/service_response.rb
new file mode 100644
index 00000000000..1de30e68d87
--- /dev/null
+++ b/app/services/service_response.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class ServiceResponse
+ def self.success(message: nil)
+ new(status: :success, message: message)
+ end
+
+ def self.error(message:, http_status: nil)
+ new(status: :error, message: message, http_status: http_status)
+ end
+
+ attr_reader :status, :message, :http_status
+
+ def initialize(status:, message: nil, http_status: nil)
+ self.status = status
+ self.message = message
+ self.http_status = http_status
+ end
+
+ def success?
+ status == :success
+ end
+
+ def error?
+ status == :error
+ end
+
+ private
+
+ attr_writer :status, :message, :http_status
+end
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
index d931d528c86..8ba50e22b09 100644
--- a/app/services/suggestions/apply_service.rb
+++ b/app/services/suggestions/apply_service.rb
@@ -7,11 +7,21 @@ module Suggestions
end
def execute(suggestion)
- unless suggestion.appliable?
+ unless suggestion.appliable?(cached: false)
return error('Suggestion is not appliable')
end
- params = file_update_params(suggestion)
+ unless latest_source_head?(suggestion)
+ return error('The file has been changed')
+ end
+
+ diff_file = suggestion.diff_file
+
+ unless diff_file
+ return error('The file was not found')
+ end
+
+ params = file_update_params(suggestion, diff_file)
result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
if result[:status] == :success
@@ -19,30 +29,45 @@ module Suggestions
end
result
+ rescue Files::UpdateService::FileChangedError
+ error('The file has been changed')
end
private
- def file_update_params(suggestion)
- diff_file = suggestion.diff_file
+ # Checks whether the latest source branch HEAD matches with
+ # the position HEAD we're using to update the file content. Since
+ # the persisted HEAD is updated async (for MergeRequest),
+ # it's more consistent to fetch this data directly from the
+ # repository.
+ def latest_source_head?(suggestion)
+ suggestion.position.head_sha == suggestion.noteable.source_branch_sha
+ end
- file_path = diff_file.file_path
- branch_name = suggestion.noteable.source_branch
- file_content = new_file_content(suggestion)
+ def file_update_params(suggestion, diff_file)
+ blob = diff_file.new_blob
+ file_path = suggestion.file_path
+ branch_name = suggestion.branch
+ file_content = new_file_content(suggestion, blob)
commit_message = "Apply suggestion to #{file_path}"
+ file_last_commit =
+ Gitlab::Git::Commit.last_for_path(suggestion.project.repository,
+ blob.commit_id,
+ blob.path)
+
{
file_path: file_path,
branch_name: branch_name,
start_branch: branch_name,
commit_message: commit_message,
- file_content: file_content
+ file_content: file_content,
+ last_commit_sha: file_last_commit&.id
}
end
- def new_file_content(suggestion)
+ def new_file_content(suggestion, blob)
range = suggestion.from_line_index..suggestion.to_line_index
- blob = suggestion.diff_file.new_blob
blob.load_all_data!
content = blob.data.lines
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
index 77e958cbe0c..1d3338c1b45 100644
--- a/app/services/suggestions/create_service.rb
+++ b/app/services/suggestions/create_service.rb
@@ -9,48 +9,24 @@ module Suggestions
def execute
return unless @note.supports_suggestion?
- suggestions = Banzai::SuggestionsParser.parse(@note.note)
-
- # For single line suggestion we're only looking forward to
- # change the line receiving the comment. Though, in
- # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
- # we'll introduce a ```suggestion:L<x>-<y>, so this will
- # slightly change.
- comment_line = @note.position.new_line
+ suggestions = Gitlab::Diff::SuggestionsParser.parse(@note.note,
+ project: @note.project,
+ position: @note.position)
rows =
suggestions.map.with_index do |suggestion, index|
- from_content = changing_lines(comment_line, comment_line)
-
- # The parsed suggestion doesn't have information about the correct
- # ending characters (we may have a line break, or not), so we take
- # this information from the last line being changed (last
- # characters).
- endline_chars = line_break_chars(from_content.lines.last)
- to_content = "#{suggestion}#{endline_chars}"
+ creation_params =
+ suggestion.to_hash.slice(:from_content,
+ :to_content,
+ :lines_above,
+ :lines_below)
- {
- note_id: @note.id,
- from_content: from_content,
- to_content: to_content,
- relative_order: index
- }
+ creation_params.merge!(note_id: @note.id, relative_order: index)
end
rows.in_groups_of(100, false) do |rows|
Gitlab::Database.bulk_insert('suggestions', rows)
end
end
-
- private
-
- def changing_lines(from_line, to_line)
- @note.diff_file.new_blob_lines_between(from_line, to_line).join
- end
-
- def line_break_chars(line)
- match = /\r\n|\r|\n/.match(line)
- match[0] if match
- end
end
end
diff --git a/app/services/suggestions/outdate_service.rb b/app/services/suggestions/outdate_service.rb
new file mode 100644
index 00000000000..a33aac9f6b5
--- /dev/null
+++ b/app/services/suggestions/outdate_service.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class OutdateService
+ def execute(merge_request)
+ # rubocop: disable CodeReuse/ActiveRecord
+ suggestions = merge_request.suggestions.active.includes(:note)
+
+ suggestions.find_in_batches(batch_size: 100) do |group|
+ outdatable_suggestion_ids = group.select do |suggestion|
+ suggestion.outdated?(cached: false)
+ end.map(&:id)
+
+ Suggestion.where(id: outdatable_suggestion_ids).update_all(outdated: true)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+ end
+end
diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb
index bd3907cdf8e..858e04f43b2 100644
--- a/app/services/system_hooks_service.rb
+++ b/app/services/system_hooks_service.rb
@@ -47,7 +47,7 @@ class SystemHooksService
case event
when :rename
- data[:old_username] = model.username_was
+ data[:old_username] = model.username_before_last_save
when :failed_login
data[:state] = model.state
end
@@ -58,8 +58,8 @@ class SystemHooksService
if event == :rename
data.merge!(
- old_path: model.path_was,
- old_full_path: model.full_path_was
+ old_path: model.path_before_last_save,
+ old_full_path: model.full_path_before_last_save
)
end
when GroupMember
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index ec6c306227b..1390f7cdf46 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -25,7 +25,7 @@ module SystemNoteService
text_parts = ["added #{commits_text}"]
text_parts << commits_list(noteable, new_commits, existing_commits, oldrev)
- text_parts << "[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})"
+ text_parts << "[Compare with previous version](#{diff_comparison_path(noteable, project, oldrev)})"
body = text_parts.join("\n\n")
@@ -41,7 +41,7 @@ module SystemNoteService
#
# Returns the created Note object
def tag_commit(noteable, project, author, tag_name)
- link = url_helpers.project_tag_url(project, id: tag_name)
+ link = url_helpers.project_tag_path(project, id: tag_name)
body = "tagged commit #{noteable.sha} to [`#{tag_name}`](#{link})"
create_note(NoteSummary.new(noteable, project, author, body, action: 'tag'))
@@ -69,7 +69,7 @@ module SystemNoteService
# Called when the assignees of an Issue is changed or removed
#
- # issue - Issue object
+ # issuable - Issuable object (responds to assignees)
# project - Project owning noteable
# author - User performing the change
# assignees - Users being assigned, or nil
@@ -85,9 +85,9 @@ module SystemNoteService
# "assigned to @user1 and @user2"
#
# Returns the created Note object
- def change_issue_assignees(issue, project, author, old_assignees)
- unassigned_users = old_assignees - issue.assignees
- added_users = issue.assignees.to_a - old_assignees
+ def change_issuable_assignees(issuable, project, author, old_assignees)
+ unassigned_users = old_assignees - issuable.assignees
+ added_users = issuable.assignees.to_a - old_assignees
text_parts = []
text_parts << "assigned to #{added_users.map(&:to_reference).to_sentence}" if added_users.any?
@@ -95,7 +95,7 @@ module SystemNoteService
body = text_parts.join(' and ')
- create_note(NoteSummary.new(issue, project, author, body, action: 'assignee'))
+ create_note(NoteSummary.new(issuable, project, author, body, action: 'assignee'))
end
# Called when the milestone of a Noteable is changed
@@ -258,7 +258,7 @@ module SystemNoteService
body = "created #{issue.to_reference} to continue this discussion"
note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body)
- note = Note.create(note_attributes.merge(system: true))
+ note = Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp))
note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion')
note
@@ -272,7 +272,7 @@ module SystemNoteService
text_parts = ["changed this line in"]
if version_params = merge_request.version_params_for(diff_refs)
line_code = change_position.line_code(project.repository)
- url = url_helpers.diffs_project_merge_request_url(project, merge_request, version_params.merge(anchor: line_code))
+ url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: line_code))
text_parts << "[version #{version_index} of the diff](#{url})"
else
@@ -360,7 +360,7 @@ module SystemNoteService
# author - User performing the change
# branch_type - 'source' or 'target'
# old_branch - old branch name
- # new_branch - new branch nmae
+ # new_branch - new branch name
#
# Example Note text:
#
@@ -405,7 +405,7 @@ module SystemNoteService
#
# "created branch `201-issue-branch-button`"
def new_issue_branch(issue, project, author, branch)
- link = url_helpers.project_compare_url(project, from: project.default_branch, to: branch)
+ link = url_helpers.project_compare_path(project, from: project.default_branch, to: branch)
body = "created branch [`#{branch}`](#{link}) to address this issue"
@@ -668,10 +668,10 @@ module SystemNoteService
@url_helpers ||= Gitlab::Routing.url_helpers
end
- def diff_comparison_url(merge_request, project, oldrev)
+ def diff_comparison_path(merge_request, project, oldrev)
diff_id = merge_request.merge_request_diff.id
- url_helpers.diffs_project_merge_request_url(
+ url_helpers.diffs_project_merge_request_path(
project,
merge_request,
diff_id: diff_id,
diff --git a/app/services/tags/create_service.rb b/app/services/tags/create_service.rb
index 6bb9bb3988e..4de6b2d2774 100644
--- a/app/services/tags/create_service.rb
+++ b/app/services/tags/create_service.rb
@@ -2,7 +2,7 @@
module Tags
class CreateService < BaseService
- def execute(tag_name, target, message, release_description = nil)
+ def execute(tag_name, target, message)
valid_tag = Gitlab::GitRefValidator.validate(tag_name)
return error('Tag name invalid') unless valid_tag
@@ -20,10 +20,7 @@ module Tags
end
if new_tag
- if release_description.present?
- CreateReleaseService.new(@project, @current_user)
- .execute(tag_name, release_description)
- end
+ repository.expire_tags_cache
success.merge(tag: new_tag)
else
diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb
index 6bfef09ac54..4f6ae07be7d 100644
--- a/app/services/tags/destroy_service.rb
+++ b/app/services/tags/destroy_service.rb
@@ -2,7 +2,6 @@
module Tags
class DestroyService < BaseService
- # rubocop: disable CodeReuse/ActiveRecord
def execute(tag_name)
repository = project.repository
tag = repository.find_tag(tag_name)
@@ -12,8 +11,12 @@ module Tags
end
if repository.rm_tag(current_user, tag_name)
- release = project.releases.find_by(tag: tag_name)
- release&.destroy
+ ##
+ # When a tag in a repository is destroyed,
+ # release assets will be destroyed too.
+ Releases::DestroyService
+ .new(project, current_user, tag: tag_name)
+ .execute
push_data = build_push_data(tag)
EventCreateService.new.push(project, current_user, push_data)
@@ -27,7 +30,6 @@ module Tags
rescue Gitlab::Git::PreReceiveError => ex
error(ex.message)
end
- # rubocop: enable CodeReuse/ActiveRecord
def error(message, return_code = 400)
super(message).merge(return_code: return_code)
@@ -39,12 +41,11 @@ module Tags
def build_push_data(tag)
Gitlab::DataBuilder::Push.build(
- project,
- current_user,
- tag.dereferenced_target.sha,
- Gitlab::Git::BLANK_SHA,
- "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}",
- [])
+ project: project,
+ user: current_user,
+ oldrev: tag.dereferenced_target.sha,
+ newrev: Gitlab::Git::BLANK_SHA,
+ ref: "#{Gitlab::Git::TAG_REF_PREFIX}#{tag.name}")
end
end
end
diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb
new file mode 100644
index 00000000000..f6602a35033
--- /dev/null
+++ b/app/services/task_list_toggle_service.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+# Finds the correct checkbox in the passed in markdown/html and toggles it's state,
+# returning the updated markdown/html.
+# We don't care if the text has changed above or below the specific checkbox, as long
+# the checkbox still exists at exactly the same line number and the text is equal.
+# If successful, new values are available in `updated_markdown` and `updated_markdown_html`
+class TaskListToggleService
+ attr_reader :updated_markdown, :updated_markdown_html
+
+ def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:)
+ @markdown, @markdown_html = markdown, markdown_html
+ @line_source, @line_number = line_source, line_number
+ @toggle_as_checked = toggle_as_checked
+
+ @updated_markdown, @updated_markdown_html = nil
+ end
+
+ def execute
+ return false unless markdown && markdown_html
+
+ toggle_markdown && toggle_markdown_html
+ end
+
+ private
+
+ attr_reader :markdown, :markdown_html, :toggle_as_checked
+ attr_reader :line_source, :line_number
+
+ def toggle_markdown
+ source_lines = markdown.split("\n")
+ source_line_index = line_number - 1
+ markdown_task = source_lines[source_line_index]
+
+ # The source in the DB could be using either \n or \r\n line endings
+ return unless markdown_task.chomp == line_source
+ return unless source_checkbox = Taskable::ITEM_PATTERN.match(markdown_task)
+
+ currently_checked = TaskList::Item.new(source_checkbox[1]).complete?
+
+ # Check `toggle_as_checked` to make sure we don't accidentally replace
+ # any `[ ]` or `[x]` in the middle of the text
+ if currently_checked
+ markdown_task.sub!(Taskable::COMPLETE_PATTERN, '[ ]') unless toggle_as_checked
+ else
+ markdown_task.sub!(Taskable::INCOMPLETE_PATTERN, '[x]') if toggle_as_checked
+ end
+
+ source_lines[source_line_index] = markdown_task
+ @updated_markdown = source_lines.join("\n")
+ end
+
+ def toggle_markdown_html
+ html = Nokogiri::HTML.fragment(markdown_html)
+ html_checkbox = get_html_checkbox(html)
+ return unless html_checkbox
+
+ if toggle_as_checked
+ html_checkbox[:checked] = 'checked'
+ else
+ html_checkbox.remove_attribute('checked')
+ end
+
+ @updated_markdown_html = html.to_html
+ end
+
+ # When using CommonMark, we should be able to use the embedded `sourcepos` attribute to
+ # target the exact line in the DOM.
+ def get_html_checkbox(html)
+ html.css(".task-list-item[data-sourcepos^='#{line_number}:'] input.task-list-item-checkbox").first
+ end
+end
diff --git a/app/services/test_hooks/project_service.rb b/app/services/test_hooks/project_service.rb
index 7e14ddcd017..a71278e8b8b 100644
--- a/app/services/test_hooks/project_service.rb
+++ b/app/services/test_hooks/project_service.rb
@@ -11,7 +11,7 @@ module TestHooks
private
def push_events_data
- throw(:validation_error, 'Ensure the project has at least one commit.') if project.empty_repo?
+ throw(:validation_error, s_('TestHooks|Ensure the project has at least one commit.')) if project.empty_repo?
Gitlab::DataBuilder::Push.build_sample(project, current_user)
end
@@ -20,14 +20,14 @@ module TestHooks
def note_events_data
note = project.notes.first
- throw(:validation_error, 'Ensure the project has notes.') unless note.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has notes.')) unless note.present?
Gitlab::DataBuilder::Note.build(note, current_user)
end
def issues_events_data
issue = project.issues.first
- throw(:validation_error, 'Ensure the project has issues.') unless issue.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has issues.')) unless issue.present?
issue.to_hook_data(current_user)
end
@@ -36,29 +36,29 @@ module TestHooks
def merge_requests_events_data
merge_request = project.merge_requests.first
- throw(:validation_error, 'Ensure the project has merge requests.') unless merge_request.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has merge requests.')) unless merge_request.present?
merge_request.to_hook_data(current_user)
end
def job_events_data
build = project.builds.first
- throw(:validation_error, 'Ensure the project has CI jobs.') unless build.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has CI jobs.')) unless build.present?
Gitlab::DataBuilder::Build.build(build)
end
def pipeline_events_data
pipeline = project.ci_pipelines.first
- throw(:validation_error, 'Ensure the project has CI pipelines.') unless pipeline.present?
+ throw(:validation_error, s_('TestHooks|Ensure the project has CI pipelines.')) unless pipeline.present?
Gitlab::DataBuilder::Pipeline.build(pipeline)
end
def wiki_page_events_data
- page = project.wiki.pages.first
+ page = project.wiki.list_pages(limit: 1).first
if !project.wiki_enabled? || page.blank?
- throw(:validation_error, 'Ensure the wiki is enabled and has pages.')
+ throw(:validation_error, s_('TestHooks|Ensure the wiki is enabled and has pages.'))
end
Gitlab::DataBuilder::WikiPage.build(page, current_user, 'create')
diff --git a/app/services/test_hooks/system_service.rb b/app/services/test_hooks/system_service.rb
index 082830c5538..fedf9c6799b 100644
--- a/app/services/test_hooks/system_service.rb
+++ b/app/services/test_hooks/system_service.rb
@@ -18,7 +18,7 @@ module TestHooks
def merge_requests_events_data
merge_request = MergeRequest.of_projects(current_user.projects.select(:id)).first
- throw(:validation_error, 'Ensure one of your projects has merge requests.') unless merge_request.present?
+ throw(:validation_error, s_('TestHooks|Ensure one of your projects has merge requests.')) unless merge_request.present?
merge_request.to_hook_data(current_user)
end
diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb
index f357dc37fe7..0ea230a44a1 100644
--- a/app/services/todo_service.rb
+++ b/app/services/todo_service.rb
@@ -49,12 +49,12 @@ class TodoService
todo_users.each(&:update_todos_count_cache)
end
- # When we reassign an issue we should:
+ # When we reassign an issuable we should:
#
- # * create a pending todo for new assignee if issue is assigned
+ # * create a pending todo for new assignee if issuable is assigned
#
- def reassigned_issue(issue, current_user, old_assignees = [])
- create_assignment_todo(issue, current_user, old_assignees)
+ def reassigned_issuable(issuable, current_user, old_assignees = [])
+ create_assignment_todo(issuable, current_user, old_assignees)
end
# When create a merge request we should:
@@ -82,14 +82,6 @@ class TodoService
mark_pending_todos_as_done(merge_request, current_user)
end
- # When we reassign a merge request we should:
- #
- # * creates a pending todo for new assignee if merge request is assigned
- #
- def reassigned_merge_request(merge_request, current_user)
- create_assignment_todo(merge_request, current_user)
- end
-
# When merge a merge request we should:
#
# * mark all pending todos related to the target for the current user as done
diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb
index ebfb20132d0..4743e9b02ce 100644
--- a/app/services/todos/destroy/entity_leave_service.rb
+++ b/app/services/todos/destroy/entity_leave_service.rb
@@ -37,8 +37,8 @@ module Todos
private
def enqueue_private_features_worker
- project_ids.each do |project_id|
- TodosDestroyer::PrivateFeaturesWorker.perform_async(project_id, user.id)
+ projects.each do |project|
+ TodosDestroyer::PrivateFeaturesWorker.perform_async(project.id, user.id)
end
end
@@ -62,9 +62,8 @@ module Todos
end
# rubocop: enable CodeReuse/ActiveRecord
- override :project_ids
# rubocop: disable CodeReuse/ActiveRecord
- def project_ids
+ def projects
condition = case entity
when Project
{ id: entity.id }
@@ -72,13 +71,13 @@ module Todos
{ namespace_id: non_member_groups }
end
- Project.where(condition).select(:id)
+ Project.where(condition)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def non_authorized_projects
- project_ids.where('id NOT IN (?)', user.authorized_projects.select(:id))
+ projects.where('id NOT IN (?)', user.authorized_projects.select(:id))
end
# rubocop: enable CodeReuse/ActiveRecord
@@ -110,7 +109,7 @@ module Todos
authorized_reporter_projects = user
.authorized_projects(Gitlab::Access::REPORTER).select(:id)
- Issue.where(project_id: project_ids, confidential: true)
+ Issue.where(project_id: projects, confidential: true)
.where('project_id NOT IN(?)', authorized_reporter_projects)
.where('author_id != ?', user.id)
.where('id NOT IN (?)', assigned_ids)
diff --git a/app/services/update_deployment_service.rb b/app/services/update_deployment_service.rb
index aa7fcca1e2a..49a7d0178f4 100644
--- a/app/services/update_deployment_service.rb
+++ b/app/services/update_deployment_service.rb
@@ -27,6 +27,8 @@ class UpdateDeploymentService
deployment.tap(&:update_merge_request_metrics!)
end
+
+ deployment
end
private
diff --git a/app/services/update_release_service.rb b/app/services/update_release_service.rb
deleted file mode 100644
index e2228ca026c..00000000000
--- a/app/services/update_release_service.rb
+++ /dev/null
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-class UpdateReleaseService < BaseService
- # rubocop: disable CodeReuse/ActiveRecord
- def execute(tag_name, release_description)
- repository = project.repository
- existing_tag = repository.find_tag(tag_name)
-
- if existing_tag
- release = project.releases.find_by(tag: tag_name)
-
- if release
- release.update(description: release_description)
-
- success(release)
- else
- error('Release does not exist', 404)
- end
- else
- error('Tag does not exist', 404)
- end
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def success(release)
- super().merge(release: release)
- end
-end
diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb
index 39909ee4f82..403944557a2 100644
--- a/app/services/upload_service.rb
+++ b/app/services/upload_service.rb
@@ -6,12 +6,12 @@ class UploadService
end
def execute
- return nil unless @file && @file.size <= max_attachment_size
+ return unless @file && @file.size <= max_attachment_size
uploader = @uploader_class.new(@model, nil, @uploader_context)
uploader.store!(@file)
- uploader.to_h
+ uploader
end
private
diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb
index db03ba8756f..33444c2a7dc 100644
--- a/app/services/users/activity_service.rb
+++ b/app/services/users/activity_service.rb
@@ -26,12 +26,15 @@ module Users
def record_activity
return if Gitlab::Database.read_only?
- lease = Gitlab::ExclusiveLease.new("acitvity_service:#{@user.id}",
+ today = Date.today
+
+ return if @user.last_activity_on == today
+
+ lease = Gitlab::ExclusiveLease.new("activity_service:#{@user.id}",
timeout: LEASE_TIMEOUT)
return unless lease.try_obtain
- @user.update_attribute(:last_activity_on, Date.today)
- Rails.logger.debug("Recorded activity: #{@activity} for User ID: #{@user.id} (username: #{@user.username})")
+ @user.update_attribute(:last_activity_on, today)
end
end
end
diff --git a/app/services/users/build_service.rb b/app/services/users/build_service.rb
index 3f503f3da28..30f7743c56e 100644
--- a/app/services/users/build_service.rb
+++ b/app/services/users/build_service.rb
@@ -26,7 +26,7 @@ module Users
end
end
- identity_attrs = params.slice(:extern_uid, :provider)
+ identity_attrs = params.slice(*identity_params)
unless identity_attrs.empty?
user.identities.build(identity_attrs)
@@ -37,6 +37,10 @@ module Users
private
+ def identity_params
+ [:extern_uid, :provider]
+ end
+
def can_create_user?
(current_user.nil? && Gitlab::CurrentSettings.allow_signup?) || current_user&.admin?
end
diff --git a/app/services/users/migrate_to_ghost_user_service.rb b/app/services/users/migrate_to_ghost_user_service.rb
index 04fd6e37501..a66b6627e40 100644
--- a/app/services/users/migrate_to_ghost_user_service.rb
+++ b/app/services/users/migrate_to_ghost_user_service.rb
@@ -33,7 +33,7 @@ module Users
end
end
- user.reload
+ user.reset
end
private
diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb
index 23b63aaabdf..4a26d2be2af 100644
--- a/app/services/users/refresh_authorized_projects_service.rb
+++ b/app/services/users/refresh_authorized_projects_service.rb
@@ -25,7 +25,7 @@ module Users
# We need an up to date User object that has access to all relations that
# may have been created earlier. The only way to ensure this is to reload
# the User object.
- user.reload
+ user.reset
end
def execute
@@ -84,7 +84,7 @@ module Users
# Since we batch insert authorization rows, Rails' associations may get
# out of sync. As such we force a reload of the User object.
- user.reload
+ user.reset
end
def fresh_access_levels_per_project
@@ -102,7 +102,7 @@ module Users
end
def fresh_authorizations
- klass = if Group.supports_nested_groups?
+ klass = if Group.supports_nested_objects?
Gitlab::ProjectAuthorizations::WithNestedGroups
else
Gitlab::ProjectAuthorizations::WithoutNestedGroups
diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb
index a897e4bd56a..0b00bd135eb 100644
--- a/app/services/users/update_service.rb
+++ b/app/services/users/update_service.rb
@@ -16,7 +16,7 @@ module Users
user_exists = @user.persisted?
- assign_attributes(&block)
+ assign_attributes
if @user.save(validate: validate) && update_status
notify_success(user_exists)
@@ -48,12 +48,14 @@ module Users
success
end
- def assign_attributes(&block)
- if @user.user_synced_attributes_metadata
- params.except!(*@user.user_synced_attributes_metadata.read_only_attributes)
+ def assign_attributes
+ if (metadata = @user.user_synced_attributes_metadata)
+ read_only = metadata.read_only_attributes
+
+ params.reject! { |key, _| read_only.include?(key.to_sym) }
end
- @user.assign_attributes(params) if params.any?
+ @user.assign_attributes(params) unless params.empty?
end
end
end
diff --git a/app/services/validate_new_branch_service.rb b/app/services/validate_new_branch_service.rb
index c19e2ec2043..3f4a59e5cee 100644
--- a/app/services/validate_new_branch_service.rb
+++ b/app/services/validate_new_branch_service.rb
@@ -3,14 +3,14 @@
require_relative 'base_service'
class ValidateNewBranchService < BaseService
- def execute(branch_name)
+ def execute(branch_name, force: false)
valid_branch = Gitlab::GitRefValidator.validate(branch_name)
unless valid_branch
return error('Branch name is invalid')
end
- if project.repository.branch_exists?(branch_name)
+ if project.repository.branch_exists?(branch_name) && !force
return error('Branch already exists')
end
diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb
index 07f7391f877..b53c3145caf 100644
--- a/app/services/verify_pages_domain_service.rb
+++ b/app/services/verify_pages_domain_service.rb
@@ -8,6 +8,7 @@ class VerifyPagesDomainService < BaseService
# How long verification lasts for
VERIFICATION_PERIOD = 7.days
+ REMOVAL_DELAY = 1.week.freeze
attr_reader :domain
@@ -36,7 +37,7 @@ class VerifyPagesDomainService < BaseService
# Prevent any pre-existing grace period from being truncated
reverify = [domain.enabled_until, VERIFICATION_PERIOD.from_now].compact.max
- domain.assign_attributes(verified_at: Time.now, enabled_until: reverify)
+ domain.assign_attributes(verified_at: Time.now, enabled_until: reverify, remove_at: nil)
domain.save!(validate: false)
if was_disabled
@@ -49,18 +50,20 @@ class VerifyPagesDomainService < BaseService
end
def unverify_domain!
- if domain.verified?
- domain.assign_attributes(verified_at: nil)
- domain.save!(validate: false)
+ was_verified = domain.verified?
- notify(:verification_failed)
- end
+ domain.assign_attributes(verified_at: nil)
+ domain.remove_at ||= REMOVAL_DELAY.from_now unless domain.enabled?
+ domain.save!(validate: false)
+
+ notify(:verification_failed) if was_verified
error("Couldn't verify #{domain.domain}")
end
def disable_domain!
domain.assign_attributes(verified_at: nil, enabled_until: nil)
+ domain.remove_at ||= REMOVAL_DELAY.from_now
domain.save!(validate: false)
notify(:disabled)
diff --git a/app/uploaders/attachment_uploader.rb b/app/uploaders/attachment_uploader.rb
index 0a166335b4e..b488bba00e9 100644
--- a/app/uploaders/attachment_uploader.rb
+++ b/app/uploaders/attachment_uploader.rb
@@ -9,6 +9,6 @@ class AttachmentUploader < GitlabUploader
private
def dynamic_segment
- File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb
index c0165759203..9af59b0aceb 100644
--- a/app/uploaders/avatar_uploader.rb
+++ b/app/uploaders/avatar_uploader.rb
@@ -25,6 +25,6 @@ class AvatarUploader < GitlabUploader
private
def dynamic_segment
- File.join(model.class.to_s.underscore, mounted_as.to_s, model.id.to_s)
+ File.join(model.class.underscore, mounted_as.to_s, model.id.to_s)
end
end
diff --git a/app/uploaders/external_diff_uploader.rb b/app/uploaders/external_diff_uploader.rb
new file mode 100644
index 00000000000..d2707cd0777
--- /dev/null
+++ b/app/uploaders/external_diff_uploader.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class ExternalDiffUploader < GitlabUploader
+ include ObjectStorage::Concern
+
+ storage_options Gitlab.config.external_diffs
+
+ alias_method :upload, :model
+
+ def filename
+ "diff-#{model.id}"
+ end
+
+ def store_dir
+ dynamic_segment
+ end
+
+ private
+
+ def dynamic_segment
+ File.join(model.model_name.plural, "mr-#{model.merge_request_id}")
+ end
+end
diff --git a/app/uploaders/file_mover.rb b/app/uploaders/file_mover.rb
index a7f8615e9ba..236b7ed2b3d 100644
--- a/app/uploaders/file_mover.rb
+++ b/app/uploaders/file_mover.rb
@@ -11,6 +11,8 @@ class FileMover
end
def execute
+ return unless valid?
+
move
if update_markdown
@@ -21,6 +23,12 @@ class FileMover
private
+ def valid?
+ Pathname.new(temp_file_path).realpath.to_path.start_with?(
+ (Pathname(temp_file_uploader.root) + temp_file_uploader.base_dir).to_path
+ )
+ end
+
def move
FileUtils.mkdir_p(File.dirname(file_path))
FileUtils.move(temp_file_path, file_path)
diff --git a/app/uploaders/file_uploader.rb b/app/uploaders/file_uploader.rb
index e90599f2505..1c7582533ad 100644
--- a/app/uploaders/file_uploader.rb
+++ b/app/uploaders/file_uploader.rb
@@ -14,8 +14,8 @@ class FileUploader < GitlabUploader
include ObjectStorage::Concern
prepend ObjectStorage::Extension::RecordsUploads
- MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}
- DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}
+ MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}.freeze
+ DYNAMIC_PATH_PATTERN = %r{.*(?<secret>\h{32})/(?<identifier>.*)}.freeze
after :remove, :prune_store_dir
@@ -109,12 +109,20 @@ class FileUploader < GitlabUploader
def upload_path
if file_storage?
# Legacy path relative to project.full_path
- File.join(dynamic_segment, identifier)
+ local_storage_path(identifier)
else
- File.join(store_dir, identifier)
+ remote_storage_path(identifier)
end
end
+ def local_storage_path(file_identifier)
+ File.join(dynamic_segment, file_identifier)
+ end
+
+ def remote_storage_path(file_identifier)
+ File.join(store_dir, file_identifier)
+ end
+
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb
index 716922bc017..104d5d3b3dd 100644
--- a/app/uploaders/import_export_uploader.rb
+++ b/app/uploaders/import_export_uploader.rb
@@ -7,10 +7,6 @@ class ImportExportUploader < AttachmentUploader
EXTENSION_WHITELIST
end
- def move_to_store
- true
- end
-
def move_to_cache
false
end
diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb
index a9afc104ed1..fac3c3dcb8f 100644
--- a/app/uploaders/legacy_artifact_uploader.rb
+++ b/app/uploaders/legacy_artifact_uploader.rb
@@ -1,5 +1,8 @@
# frozen_string_literal: true
+##
+# TODO: Remove this uploader when we remove :ci_enable_legacy_artifacts feature flag
+# See https://gitlab.com/gitlab-org/gitlab-ce/issues/58595
class LegacyArtifactUploader < GitlabUploader
extend Workhorse::UploadPath
include ObjectStorage::Concern
diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb
index dad6e85fb56..0a44d60778d 100644
--- a/app/uploaders/object_storage.rb
+++ b/app/uploaders/object_storage.rb
@@ -117,7 +117,7 @@ module ObjectStorage
next unless uploader
next unless uploader.exists?
- next unless send(:"#{mounted_as}_changed?") # rubocop:disable GitlabSecurity/PublicSend
+ next unless send(:"saved_change_to_#{mounted_as}?") # rubocop:disable GitlabSecurity/PublicSend
mount
end.keys
@@ -278,8 +278,12 @@ module ObjectStorage
self.class.object_store_credentials
end
+ # Set ACL of uploaded objects to not-public (fog-aws)[1] or no ACL at all
+ # (fog-google). Value is ignored by other supported backends (fog-aliyun,
+ # fog-openstack, fog-rackspace)
+ # [1]: https://github.com/fog/fog-aws/blob/daa50bb3717a462baf4d04d0e0cbfc18baacb541/lib/fog/aws/models/storage/file.rb#L152-L159
def fog_public
- false
+ nil
end
def delete_migrated_file(migrated_file)
diff --git a/app/uploaders/personal_file_uploader.rb b/app/uploaders/personal_file_uploader.rb
index 25474b494ff..b43162f0935 100644
--- a/app/uploaders/personal_file_uploader.rb
+++ b/app/uploaders/personal_file_uploader.rb
@@ -7,13 +7,17 @@ class PersonalFileUploader < FileUploader
end
def self.base_dir(model, _store = nil)
+ # base_dir is the path seen by the user when rendering Markdown, so
+ # it should be the same for both local and object storage. It is
+ # typically prefaced with uploads/-/system, but that prefix
+ # is omitted in the path stored on disk.
File.join(options.base_dir, model_path_segment(model))
end
def self.model_path_segment(model)
return 'temp/' unless model
- File.join(model.class.to_s.underscore, model.id.to_s)
+ File.join(model.class.underscore, model.id.to_s)
end
def object_store
@@ -33,15 +37,61 @@ class PersonalFileUploader < FileUploader
store_dirs[object_store]
end
+ # A personal snippet path is stored using FileUploader#upload_path.
+ #
+ # The format for the path:
+ #
+ # Local storage: :random_hex/:filename.
+ # Object storage: personal_snippet/:id/:random_hex/:filename.
+ #
+ # upload_paths represent the possible paths for a given identifier,
+ # which will vary depending on whether the file is stored in local or
+ # object storage. upload_path should match an element in upload_paths.
+ #
+ # base_dir represents the path seen by the user in Markdown, and it
+ # should always be prefixed with uploads/-/system.
+ #
+ # store_dirs represent the paths that are actually used on disk. For
+ # object storage, this should omit the prefix /uploads/-/system.
+ #
+ # For example, consider the requested path /uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png.
+ #
+ # For local storage:
+ #
+ # File on disk: /opt/gitlab/embedded/service/gitlab-rails/public/uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png.
+ #
+ # base_dir: uploads/-/system/personal_snippet/172
+ # upload_path: ff4ad5c2e40b39ae57cda51577317d20/file.png
+ # upload_paths: ["ff4ad5c2e40b39ae57cda51577317d20/file.png", "personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png"].
+ # store_dirs:
+ # => {1=>"uploads/-/system/personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20", 2=>"personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20"}
+ #
+ # For object storage:
+ #
+ # upload_path: personal_snippet/172/ff4ad5c2e40b39ae57cda51577317d20/file.png
+ def upload_paths(identifier)
+ [
+ local_storage_path(identifier),
+ File.join(remote_storage_base_path, identifier)
+ ]
+ end
+
def store_dirs
{
Store::LOCAL => File.join(base_dir, dynamic_segment),
- Store::REMOTE => File.join(self.class.model_path_segment(model), dynamic_segment)
+ Store::REMOTE => remote_storage_base_path
}
end
private
+ # To avoid prefacing the remote storage path with `/uploads/-/system`,
+ # we just drop that part so that the destination path will be
+ # personal_snippet/:id/:random_hex/:filename.
+ def remote_storage_base_path
+ File.join(self.class.model_path_segment(model), dynamic_segment)
+ end
+
def secure_url
File.join('/', base_dir, secret, file.filename)
end
diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb
index 0efca895a50..00b51f92b12 100644
--- a/app/uploaders/records_uploads.rb
+++ b/app/uploaders/records_uploads.rb
@@ -23,19 +23,33 @@ module RecordsUploads
return unless model
return unless file && file.exists?
- Upload.transaction do
- uploads.where(path: upload_path).delete_all
- upload.delete if upload
-
- self.upload = build_upload.tap(&:save!)
+ # MySQL InnoDB may encounter a deadlock if a deletion and an
+ # insert is in the same transaction due to its next-key locking
+ # algorithm, so we need to skip the transaction.
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/55161#note_131556351
+ if Gitlab::Database.mysql?
+ readd_upload
+ else
+ Upload.transaction { readd_upload }
end
end
+
+ def readd_upload
+ uploads.where(path: upload_path).delete_all
+ upload.delete if upload
+
+ self.upload = build_upload.tap(&:save!)
+ end
# rubocop: enable CodeReuse/ActiveRecord
def upload_path
File.join(store_dir, filename.to_s)
end
+ def filename
+ upload&.path ? File.basename(upload.path) : super
+ end
+
private
# rubocop: disable CodeReuse/ActiveRecord
diff --git a/app/validators/addressable_url_validator.rb b/app/validators/addressable_url_validator.rb
new file mode 100644
index 00000000000..273e15ef925
--- /dev/null
+++ b/app/validators/addressable_url_validator.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+# AddressableUrlValidator
+#
+# Custom validator for URLs. This is a stricter version of UrlValidator - it also checks
+# for using the right protocol, but it actually parses the URL checking for any syntax errors.
+# The regex is also different from `URI` as we use `Addressable::URI` here.
+#
+# By default, only URLs for the HTTP(S) schemes will be considered valid.
+# Provide a `:schemes` option to configure accepted schemes.
+#
+# Example:
+#
+# class User < ActiveRecord::Base
+# validates :personal_url, addressable_url: true
+#
+# validates :ftp_url, addressable_url: { schemes: %w(ftp) }
+#
+# validates :git_url, addressable_url: { schemes: %w(http https ssh git) }
+# end
+#
+# This validator can also block urls pointing to localhost or the local network to
+# protect against Server-side Request Forgery (SSRF), or check for the right port.
+#
+# Configuration options:
+# * <tt>message</tt> - A custom error message (default is: "must be a valid URL").
+# * <tt>schemes</tt> - Array of URI schemes. Default: +['http', 'https']+
+# * <tt>allow_localhost</tt> - Allow urls pointing to +localhost+. Default: +true+
+# * <tt>allow_local_network</tt> - Allow urls pointing to private network addresses. Default: +true+
+# * <tt>allow_blank</tt> - Allow urls to be +blank+. Default: +false+
+# * <tt>allow_nil</tt> - Allow urls to be +nil+. Default: +false+
+# * <tt>ports</tt> - Allowed ports. Default: +all+.
+# * <tt>enforce_user</tt> - Validate user format. Default: +false+
+# * <tt>enforce_sanitization</tt> - Validate that there are no html/css/js tags. Default: +false+
+#
+# Example:
+# class User < ActiveRecord::Base
+# validates :personal_url, addressable_url: { allow_localhost: false, allow_local_network: false}
+#
+# validates :web_url, addressable_url: { ports: [80, 443] }
+# end
+class AddressableUrlValidator < ActiveModel::EachValidator
+ attr_reader :record
+
+ BLOCKER_VALIDATE_OPTIONS = {
+ schemes: %w(http https),
+ ports: [],
+ allow_localhost: true,
+ allow_local_network: true,
+ ascii_only: false,
+ enforce_user: false,
+ enforce_sanitization: false
+ }.freeze
+
+ DEFAULT_OPTIONS = BLOCKER_VALIDATE_OPTIONS.merge({
+ message: 'must be a valid URL'
+ }).freeze
+
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ super(options)
+ end
+
+ def validate_each(record, attribute, value)
+ @record = record
+
+ unless value.present?
+ record.errors.add(attribute, options.fetch(:message))
+ return
+ end
+
+ value = strip_value!(record, attribute, value)
+
+ Gitlab::UrlBlocker.validate!(value, blocker_args)
+ rescue Gitlab::UrlBlocker::BlockedUrlError => e
+ record.errors.add(attribute, "is blocked: #{e.message}")
+ end
+
+ private
+
+ def strip_value!(record, attribute, value)
+ new_value = value.strip
+ return value if new_value == value
+
+ record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend
+ end
+
+ def current_options
+ options.map do |option, value|
+ [option, value.is_a?(Proc) ? value.call(record) : value]
+ end.to_h
+ end
+
+ def blocker_args
+ current_options.slice(*BLOCKER_VALIDATE_OPTIONS.keys).tap do |args|
+ if self.class.allow_setting_local_requests?
+ args[:allow_localhost] = args[:allow_local_network] = true
+ end
+ end
+ end
+
+ def self.allow_setting_local_requests?
+ # We cannot use Gitlab::CurrentSettings as ApplicationSetting itself
+ # uses UrlValidator to validate urls. This ends up in a cycle
+ # when Gitlab::CurrentSettings creates an ApplicationSetting which then
+ # calls this validator.
+ #
+ # See https://gitlab.com/gitlab-org/gitlab-ee/issues/9833
+ ApplicationSetting.current&.allow_local_requests_from_hooks_and_services?
+ end
+end
diff --git a/app/validators/cluster_name_validator.rb b/app/validators/cluster_name_validator.rb
index 85fd63f08e5..79c9c67ae58 100644
--- a/app/validators/cluster_name_validator.rb
+++ b/app/validators/cluster_name_validator.rb
@@ -5,7 +5,9 @@
# Custom validator for ClusterName.
class ClusterNameValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
- if record.managed?
+ if record.provided_by_user?
+ record.errors.add(attribute, " has to be present") unless value.present?
+ else
if record.persisted? && record.name_changed?
record.errors.add(attribute, " can not be changed because it's synchronized with provider")
end
@@ -17,10 +19,6 @@ class ClusterNameValidator < ActiveModel::EachValidator
unless value =~ Gitlab::Regex.kubernetes_namespace_regex
record.errors.add(attribute, Gitlab::Regex.kubernetes_namespace_regex_message)
end
- else
- unless value.present?
- record.errors.add(attribute, " has to be present")
- end
end
end
end
diff --git a/app/validators/devise_email_validator.rb b/app/validators/devise_email_validator.rb
new file mode 100644
index 00000000000..6ca921ca7fa
--- /dev/null
+++ b/app/validators/devise_email_validator.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+# DeviseEmailValidator
+#
+# Custom validator for email formats. It asserts that there are no
+# @ symbols or whitespaces in either the localpart or the domain, and that
+# there is a single @ symbol separating the localpart and the domain.
+#
+# The available options are:
+# - regexp: Email regular expression used to validate email formats as instance of Regexp class.
+# If provided value has different type then a new Rexexp class instance is created using the value.
+# Default: +Devise.email_regexp+
+#
+# Example:
+# class User < ActiveRecord::Base
+# validates :personal_email, devise_email: true
+#
+# validates :public_email, devise_email: { regexp: Devise.email_regexp }
+# end
+class DeviseEmailValidator < ActiveModel::EachValidator
+ DEFAULT_OPTIONS = {
+ regexp: Devise.email_regexp
+ }.freeze
+
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ raise ArgumentError, "Expected 'regexp' argument of type class Regexp" unless options[:regexp].is_a?(Regexp)
+
+ super(options)
+ end
+
+ def validate_each(record, attribute, value)
+ record.errors.add(attribute, :invalid) unless value =~ options[:regexp]
+ end
+end
diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb
deleted file mode 100644
index 9459edb7515..00000000000
--- a/app/validators/email_validator.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-# frozen_string_literal: true
-
-class EmailValidator < ActiveModel::EachValidator
- def validate_each(record, attribute, value)
- record.errors.add(attribute, :invalid) unless value =~ Devise.email_regexp
- end
-end
diff --git a/app/validators/public_url_validator.rb b/app/validators/public_url_validator.rb
index 3ff880deedd..91847c5d866 100644
--- a/app/validators/public_url_validator.rb
+++ b/app/validators/public_url_validator.rb
@@ -2,7 +2,7 @@
# PublicUrlValidator
#
-# Custom validator for URLs. This validator works like UrlValidator but
+# Custom validator for URLs. This validator works like AddressableUrlValidator but
# it blocks by default urls pointing to localhost or the local network.
#
# This validator accepts the same params UrlValidator does.
@@ -12,17 +12,20 @@
# class User < ActiveRecord::Base
# validates :personal_url, public_url: true
#
-# validates :ftp_url, public_url: { protocols: %w(ftp) }
+# validates :ftp_url, public_url: { schemes: %w(ftp) }
#
# validates :git_url, public_url: { allow_localhost: true, allow_local_network: true}
# end
#
-class PublicUrlValidator < UrlValidator
- private
+class PublicUrlValidator < AddressableUrlValidator
+ DEFAULT_OPTIONS = {
+ allow_localhost: false,
+ allow_local_network: false
+ }.freeze
- def default_options
- # By default block all urls pointing to localhost or the local network
- super.merge(allow_localhost: false,
- allow_local_network: false)
+ def initialize(options)
+ options.reverse_merge!(DEFAULT_OPTIONS)
+
+ super(options)
end
end
diff --git a/app/validators/sha_validator.rb b/app/validators/sha_validator.rb
new file mode 100644
index 00000000000..77e7cfa4f6b
--- /dev/null
+++ b/app/validators/sha_validator.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class ShaValidator < ActiveModel::EachValidator
+ def validate_each(record, attribute, value)
+ return if value.blank? || Commit.valid_hash?(value)
+
+ record.errors.add(attribute, 'is not a valid SHA')
+ end
+end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
deleted file mode 100644
index 5feb0b0f05b..00000000000
--- a/app/validators/url_validator.rb
+++ /dev/null
@@ -1,96 +0,0 @@
-# frozen_string_literal: true
-
-# UrlValidator
-#
-# Custom validator for URLs.
-#
-# By default, only URLs for the HTTP(S) protocols will be considered valid.
-# Provide a `:protocols` option to configure accepted protocols.
-#
-# Example:
-#
-# class User < ActiveRecord::Base
-# validates :personal_url, url: true
-#
-# validates :ftp_url, url: { protocols: %w(ftp) }
-#
-# validates :git_url, url: { protocols: %w(http https ssh git) }
-# end
-#
-# This validator can also block urls pointing to localhost or the local network to
-# protect against Server-side Request Forgery (SSRF), or check for the right port.
-#
-# The available options are:
-# - protocols: Allowed protocols. Default: http and https
-# - allow_localhost: Allow urls pointing to localhost. Default: true
-# - allow_local_network: Allow urls pointing to private network addresses. Default: true
-# - ports: Allowed ports. Default: all.
-# - enforce_user: Validate user format. Default: false
-#
-# Example:
-# class User < ActiveRecord::Base
-# validates :personal_url, url: { allow_localhost: false, allow_local_network: false}
-#
-# validates :web_url, url: { ports: [80, 443] }
-# end
-class UrlValidator < ActiveModel::EachValidator
- DEFAULT_PROTOCOLS = %w(http https).freeze
-
- attr_reader :record
-
- def validate_each(record, attribute, value)
- @record = record
-
- unless value.present?
- record.errors.add(attribute, 'must be a valid URL')
- return
- end
-
- value = strip_value!(record, attribute, value)
-
- Gitlab::UrlBlocker.validate!(value, blocker_args)
- rescue Gitlab::UrlBlocker::BlockedUrlError => e
- record.errors.add(attribute, "is blocked: #{e.message}")
- end
-
- private
-
- def strip_value!(record, attribute, value)
- new_value = value.strip
- return value if new_value == value
-
- record.public_send("#{attribute}=", new_value) # rubocop:disable GitlabSecurity/PublicSend
- end
-
- def default_options
- # By default the validator doesn't block any url based on the ip address
- {
- protocols: DEFAULT_PROTOCOLS,
- ports: [],
- allow_localhost: true,
- allow_local_network: true,
- ascii_only: false,
- enforce_user: false
- }
- end
-
- def current_options
- options = self.options.map do |option, value|
- [option, value.is_a?(Proc) ? value.call(record) : value]
- end.to_h
-
- default_options.merge(options)
- end
-
- def blocker_args
- current_options.slice(*default_options.keys).tap do |args|
- if allow_setting_local_requests?
- args[:allow_localhost] = args[:allow_local_network] = true
- end
- end
- end
-
- def allow_setting_local_requests?
- ApplicationSetting.current&.allow_local_requests_from_hooks_and_services?
- end
-end
diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb
new file mode 100644
index 00000000000..d2f18e956c3
--- /dev/null
+++ b/app/validators/x509_certificate_credentials_validator.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+# X509CertificateCredentialsValidator
+#
+# Custom validator to check if certificate-attribute was signed using the
+# private key stored in an attrebute.
+#
+# This can be used as an `ActiveModel::Validator` as follows:
+#
+# validates_with X509CertificateCredentialsValidator,
+# certificate: :client_certificate,
+# pkey: :decrypted_private_key,
+# pass: :decrypted_passphrase
+#
+#
+# Required attributes:
+# - certificate: The name of the accessor that returns the certificate to check
+# - pkey: The name of the accessor that returns the private key
+# Optional:
+# - pass: The name of the accessor that returns the passphrase to decrypt the
+# private key
+class X509CertificateCredentialsValidator < ActiveModel::Validator
+ def initialize(*args)
+ super
+
+ # We can't validate if we don't have a private key or certificate attributes
+ # in which case this validator is useless.
+ if options[:pkey].nil? || options[:certificate].nil?
+ raise 'Provide at least `certificate` and `pkey` attribute names'
+ end
+ end
+
+ def validate(record)
+ unless certificate = read_certificate(record)
+ record.errors.add(options[:certificate], _('is not a valid X509 certificate.'))
+ end
+
+ unless private_key = read_private_key(record)
+ record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?'))
+ end
+
+ return if private_key.nil? || certificate.nil?
+
+ unless certificate.public_key.fingerprint == private_key.public_key.fingerprint
+ record.errors.add(options[:pkey], _('private key does not match certificate.'))
+ end
+ end
+
+ private
+
+ def read_private_key(record)
+ OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s)
+ rescue OpenSSL::PKey::PKeyError, ArgumentError
+ # When the primary key could not be read, an ArgumentError is raised.
+ # This hapens when the passed key is not valid or the passphrase is incorrect
+ nil
+ end
+
+ def read_certificate(record)
+ OpenSSL::X509::Certificate.new(certificate(record).to_s)
+ rescue OpenSSL::X509::CertificateError
+ nil
+ end
+
+ # rubocop:disable GitlabSecurity/PublicSend
+ #
+ # Allowing `#public_send` here because we don't want the validator to really
+ # care about the names of the attributes or where they come from.
+ #
+ # The credentials are mostly stored encrypted so we need to go through the
+ # accessors to get the values, `read_attribute` bypasses those.
+ def certificate(record)
+ record.public_send(options[:certificate])
+ end
+
+ def pkey(record)
+ record.public_send(options[:pkey])
+ end
+
+ def pass(record)
+ return unless options[:pass]
+
+ record.public_send(options[:pass])
+ end
+ # rubocop:enable GitlabSecurity/PublicSend
+end
diff --git a/app/views/abuse_reports/new.html.haml b/app/views/abuse_reports/new.html.haml
index 92ae40512c5..a161fbd064e 100644
--- a/app/views/abuse_reports/new.html.haml
+++ b/app/views/abuse_reports/new.html.haml
@@ -11,12 +11,14 @@
= f.hidden_field :user_id
.form-group.row
- = f.label :user_id, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :user_id
.col-sm-10
- name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})"
= text_field_tag :user_name, name, class: "form-control", readonly: true
.form-group.row
- = f.label :message, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :message
.col-sm-10
= f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url)
.form-text.text-muted
diff --git a/app/views/admin/appearances/_form.html.haml b/app/views/admin/appearances/_form.html.haml
index cb67079853e..a5f34d0dab2 100644
--- a/app/views/admin/appearances/_form.html.haml
+++ b/app/views/admin/appearances/_form.html.haml
@@ -1,86 +1,97 @@
-= form_for @appearance, url: admin_appearances_path do |f|
+= form_for @appearance, url: admin_appearances_path, html: { class: 'prepend-top-default' } do |f|
= form_errors(@appearance)
- %fieldset.app_logo
- %legend
- Navigation bar:
- .form-group.row
- = f.label :header_logo, 'Header logo', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.header_logo?
- = image_tag @appearance.header_logo_url, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :header_logo_cache
- = f.file_field :header_logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
- %fieldset.app_logo
- %legend
- Favicon:
- .form-group.row
- = f.label :favicon, 'Favicon', class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.favicon?
- = image_tag @appearance.favicon_url, class: 'appearance-light-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :favicon_cache
- = f.file_field :favicon, class: ''
- .hint
- Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
- %br
- Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Navigation bar
- %fieldset.sign-in
- %legend
- Sign in/Sign up pages:
- .form-group.row
- = f.label :title, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_field :title, class: "form-control"
- .form-group.row
- = f.label :description, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :description, class: "form-control", rows: 10
- .hint
- Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
- .form-group.row
- = f.label :logo, class: 'col-sm-2 col-form-label pt-0'
- .col-sm-10
- - if @appearance.logo?
- = image_tag @appearance.logo_url, class: 'appearance-logo-preview'
- - if @appearance.persisted?
- %br
- = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
- %hr
- = f.hidden_field :logo_cache
- = f.file_field :logo, class: ""
- .hint
- Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
+ .col-lg-8
+ .form-group
+ = f.label :header_logo, 'Header logo', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.header_logo?
+ = image_tag @appearance.header_logo_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove header logo', header_logos_admin_appearances_path, data: { confirm: "Header logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :header_logo_cache
+ = f.file_field :header_logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 28px tall header logo
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Favicon
+
+ .col-lg-8
+ .form-group
+ = f.label :favicon, 'Favicon', class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.favicon?
+ = image_tag @appearance.favicon_path, class: 'appearance-light-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove favicon', favicon_admin_appearances_path, data: { confirm: "Favicon will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :favicon_cache
+ = f.file_field :favicon, class: ''
+ .hint
+ Maximum file size is 1MB. Image size must be 32x32px. Allowed image formats are #{favicon_extension_whitelist}.
+ %br
+ Images with incorrect dimensions are not resized automatically, and may result in unexpected behavior.
+
+ = render partial: 'admin/appearances/system_header_footer_form', locals: { form: f }
- %fieldset
- %legend
- New project pages:
- .form-group.row
- = f.label :new_project_guidelines, class: 'col-sm-2 col-form-label'
- .col-sm-10
- = f.text_area :new_project_guidelines, class: "form-control", rows: 10
- .hint
- Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 Sign in/Sign up pages
- .form-actions
- = f.submit 'Save', class: 'btn btn-success append-right-10'
- - if @appearance.persisted?
- Preview last save:
- = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ .col-lg-8
+ .form-group
+ = f.label :title, class: 'col-form-label label-bold'
+ = f.text_field :title, class: "form-control"
+ .form-group
+ = f.label :description, class: 'col-form-label label-bold'
+ = f.text_area :description, class: "form-control", rows: 10
+ .hint
+ Description parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+ .form-group
+ = f.label :logo, class: 'col-form-label label-bold pt-0'
+ %p
+ - if @appearance.logo?
+ = image_tag @appearance.logo_path, class: 'appearance-logo-preview'
+ - if @appearance.persisted?
+ %br
+ = link_to 'Remove logo', logo_admin_appearances_path, data: { confirm: "Logo will be removed. Are you sure?"}, method: :delete, class: "btn btn-inverted btn-remove btn-sm remove-logo"
+ %hr
+ = f.hidden_field :logo_cache
+ = f.file_field :logo, class: ""
+ .hint
+ Maximum file size is 1MB. Pages are optimized for a 640x360 px logo.
+
+ %hr
+ .row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0 New project pages
+
+ .col-lg-8
+ .form-group
+ = f.label :new_project_guidelines, class: 'col-form-label label-bold'
+ %p
+ = f.text_area :new_project_guidelines, class: "form-control", rows: 10
+ .hint
+ Guidelines parsed with #{link_to "GitLab Flavored Markdown", help_page_path('user/markdown'), target: '_blank'}.
+
+ .prepend-top-default.append-bottom-default
+ = f.submit 'Update appearance settings', class: 'btn btn-success'
+ - if @appearance.persisted?
+ Preview last save:
+ = link_to 'Sign-in page', preview_sign_in_admin_appearances_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
+ = link_to 'New project page', new_project_path, class: 'btn', target: '_blank', rel: 'noopener noreferrer'
- - if @appearance.updated_at
- %span.float-right
- Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
+ - if @appearance.updated_at
+ %span.float-right
+ Last edit #{time_ago_with_tooltip(@appearance.updated_at)}
diff --git a/app/views/admin/appearances/_system_header_footer_form.html.haml b/app/views/admin/appearances/_system_header_footer_form.html.haml
new file mode 100644
index 00000000000..4301ebd05af
--- /dev/null
+++ b/app/views/admin/appearances/_system_header_footer_form.html.haml
@@ -0,0 +1,33 @@
+- form = local_assigns.fetch(:form)
+
+%hr
+.row
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = _('System header and footer')
+
+ .col-lg-8
+ .form-group
+ = form.label :header_message, _('Header message'), class: 'col-form-label label-bold'
+ = form.text_area :header_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
+ .form-group
+ = form.label :footer_message, _('Footer message'), class: 'col-form-label label-bold'
+ = form.text_area :footer_message, placeholder: _('State your message to activate'), class: "form-control js-autosize"
+ .form-group
+ .form-check
+ = form.check_box :email_header_and_footer_enabled, class: 'form-check-input'
+ = form.label :email_header_and_footer_enabled, class: 'label-bold' do
+ = _('Enable header and footer in emails')
+
+ .hint
+ = _('Add header and footer to emails. Please note that color settings will only be applied within the application interface')
+
+ .form-group.js-toggle-colors-container
+ %button.btn.btn-link.js-toggle-colors-link{ type: 'button' }
+ = _('Customize colors')
+ .form-group.js-toggle-colors-container.hide
+ = form.label :message_background_color, _('Background Color'), class: 'col-form-label label-bold'
+ = form.color_field :message_background_color, class: "form-control"
+ .form-group.js-toggle-colors-container.hide
+ = form.label :message_font_color, _('Font Color'), class: 'col-form-label label-bold'
+ = form.color_field :message_font_color, class: "form-control"
diff --git a/app/views/admin/appearances/show.html.haml b/app/views/admin/appearances/show.html.haml
index 454b779842c..ccf6f960cf2 100644
--- a/app/views/admin/appearances/show.html.haml
+++ b/app/views/admin/appearances/show.html.haml
@@ -1,9 +1,4 @@
- page_title "Appearance"
-
-%h3.page-title
- Appearance settings
-%p.light
- You can modify the look and feel of GitLab here
-%hr
+- @content_class = "limit-container-width" unless fluid_layout
= render 'form'
diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml
index 10bc3452d8b..9ed4bc44aae 100644
--- a/app/views/admin/application_settings/_account_and_limit.html.haml
+++ b/app/views/admin/application_settings/_account_and_limit.html.haml
@@ -6,32 +6,35 @@
.form-check
= f.check_box :gravatar_enabled, class: 'form-check-input'
= f.label :gravatar_enabled, class: 'form-check-label' do
- Gravatar enabled
+ = _('Gravatar enabled')
.form-group
= f.label :default_projects_limit, class: 'label-bold'
= f.number_field :default_projects_limit, class: 'form-control'
.form-group
- = f.label :max_attachment_size, 'Maximum attachment size (MB)', class: 'label-bold'
+ = f.label :max_attachment_size, _('Maximum attachment size (MB)'), class: 'label-bold'
= f.number_field :max_attachment_size, class: 'form-control'
+
+ = render_if_exists 'admin/application_settings/repository_size_limit_setting', form: f
+
.form-group
- = f.label :receive_max_input_size, 'Maximum push size (MB)', class: 'label-light'
- = f.number_field :receive_max_input_size, class: 'form-control'
+ = f.label :receive_max_input_size, _('Maximum push size (MB)'), class: 'label-light'
+ = f.number_field :receive_max_input_size, class: 'form-control qa-receive-max-input-size-field'
.form-group
- = f.label :session_expire_delay, 'Session duration (minutes)', class: 'label-light'
+ = f.label :session_expire_delay, _('Session duration (minutes)'), class: 'label-light'
= f.number_field :session_expire_delay, class: 'form-control'
- %span.form-text.text-muted#session_expire_delay_help_block GitLab restart is required to apply changes
+ %span.form-text.text-muted#session_expire_delay_help_block= _('GitLab restart is required to apply changes')
.form-group
- = f.label :user_oauth_applications, 'User OAuth applications', class: 'label-bold'
+ = f.label :user_oauth_applications, _('User OAuth applications'), class: 'label-bold'
.form-check
= f.check_box :user_oauth_applications, class: 'form-check-input'
= f.label :user_oauth_applications, class: 'form-check-label' do
- Allow users to register any application to use GitLab as an OAuth provider
+ = _('Allow users to register any application to use GitLab as an OAuth provider')
.form-group
- = f.label :user_default_external, 'New users set to external', class: 'label-bold'
+ = f.label :user_default_external, _('New users set to external'), class: 'label-bold'
.form-check
= f.check_box :user_default_external, class: 'form-check-input'
= f.label :user_default_external, class: 'form-check-label' do
- Newly registered users will by default be external
+ = _('Newly registered users will by default be external')
.prepend-top-10
= _('Internal users')
= f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control prepend-top-5'
@@ -40,10 +43,12 @@
= link_to _('More information'), help_page_path('user/permissions', anchor: 'external-users-permissions'),
target: '_blank'
.form-group
- = f.label :user_show_add_ssh_key_message, 'Prompt users to upload SSH keys', class: 'label-bold'
+ = f.label :user_show_add_ssh_key_message, _('Prompt users to upload SSH keys'), class: 'label-bold'
.form-check
= f.check_box :user_show_add_ssh_key_message, class: 'form-check-input'
= f.label :user_show_add_ssh_key_message, class: 'form-check-label' do
- Inform users without uploaded SSH keys that they can't push over SSH until one is added
+ = _("Inform users without uploaded SSH keys that they can't push over SSH until one is added")
+
+ = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml
index 0d42094fc89..b8c481df0d2 100644
--- a/app/views/admin/application_settings/_ci_cd.html.haml
+++ b/app/views/admin/application_settings/_ci_cd.html.haml
@@ -8,12 +8,12 @@
.form-check
= f.check_box :auto_devops_enabled, class: 'form-check-input'
= f.label :auto_devops_enabled, class: 'form-check-label' do
- Default to Auto DevOps pipeline for all projects
+ = s_('CICD|Default to Auto DevOps pipeline for all projects')
.form-text.text-muted
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
.form-group
- = f.label :auto_devops_domain, class: 'label-bold'
+ = f.label :auto_devops_domain, s_('AdminSettings|Auto DevOps domain'), class: 'label-bold'
= f.text_field :auto_devops_domain, class: 'form-control', placeholder: 'domain.com'
.form-text.text-muted
= s_("AdminSettings|Specify a domain to use by default for every project's Auto Review Apps and Auto Deploy stages.")
@@ -21,33 +21,37 @@
.form-check
= f.check_box :shared_runners_enabled, class: 'form-check-input'
= f.label :shared_runners_enabled, class: 'form-check-label' do
- Enable shared runners for new projects
+ = s_("AdminSettings|Enable shared runners for new projects")
+
+ = render_if_exists 'admin/application_settings/shared_runners_minutes_setting', form: f
+
.form-group
= f.label :shared_runners_text, class: 'label-bold'
= f.text_area :shared_runners_text, class: 'form-control', rows: 4
- .form-text.text-muted Markdown enabled
+ .form-text.text-muted= _("Markdown enabled")
.form-group
- = f.label :max_artifacts_size, 'Maximum artifacts size (MB)', class: 'label-bold'
+ = f.label :max_artifacts_size, _('Maximum artifacts size (MB)'), class: 'label-bold'
= f.number_field :max_artifacts_size, class: 'form-control'
.form-text.text-muted
- Set the maximum file size for each job's artifacts
+ = _("Set the maximum file size for each job's artifacts")
= link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'maximum-artifacts-size')
.form-group
- = f.label :default_artifacts_expire_in, 'Default artifacts expiration', class: 'label-bold'
+ = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold'
= f.text_field :default_artifacts_expire_in, class: 'form-control'
.form-text.text-muted
- Set the default expiration time for each job's artifacts.
- 0 for unlimited.
- The default unit is in seconds, but you can define an alternative. For example:
- <code>4 mins 2 sec</code>, <code>2h42min</code>.
+ = _("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe
= link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration')
.form-group
- = f.label :archive_builds_in_human_readable, 'Archive jobs', class: 'label-bold'
+ = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold'
= f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never'
.form-text.text-muted
- Set the duration for which the jobs will be considered as old and expired.
- Once that time passes, the jobs will be archived and no longer able to be
- retried. Make it empty to never expire jobs. It has to be no less than 1 day,
- for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.
+ = _("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.").html_safe
+ .form-group
+ .form-check
+ = f.check_box :protected_ci_variables, class: 'form-check-input'
+ = f.label :protected_ci_variables, class: 'form-check-label' do
+ = s_('AdminSettings|Environment variables are protected by default')
+ .form-text.text-muted
+ = s_('AdminSettings|When creating a new environment variable it will be protected by default.')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml
index 60a6be731ea..3f30c75fbb6 100644
--- a/app/views/admin/application_settings/_email.html.haml
+++ b/app/views/admin/application_settings/_email.html.haml
@@ -6,20 +6,16 @@
.form-check
= f.check_box :email_author_in_body, class: 'form-check-input'
= f.label :email_author_in_body, class: 'form-check-label' do
- Include author name in notification email body
+ = _('Include author name in notification email body')
.form-text.text-muted
- Some email servers do not support overriding the email sender name.
- Enable this option to include the name of the author of the issue,
- merge request or comment in the email body instead.
+ = _('Some email servers do not support overriding the email sender name. Enable this option to include the name of the author of the issue, merge request or comment in the email body instead.')
.form-group
.form-check
= f.check_box :html_emails_enabled, class: 'form-check-input'
= f.label :html_emails_enabled, class: 'form-check-label' do
- Enable HTML emails
+ = _('Enable HTML emails')
.form-text.text-muted
- By default GitLab sends emails in HTML and plain text formats so mail
- clients can choose what format to use. Disable this option if you only
- want to send emails in plain text format.
+ = _('By default GitLab sends emails in HTML and plain text formats so mail clients can choose what format to use. Disable this option if you only want to send emails in plain text format.')
.form-group
= f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold'
= f.text_field :commit_email_hostname, class: 'form-control'
@@ -27,4 +23,6 @@
- commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('user/admin_area/settings/email', anchor: 'custom-private-commit-email-hostname'), target: '_blank'
= _("This setting will update the hostname that is used to generate private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link }
- = f.submit 'Save changes', class: "btn btn-success"
+ = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
new file mode 100644
index 00000000000..01f6c7afe61
--- /dev/null
+++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml
@@ -0,0 +1,51 @@
+%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('External authentication')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = expanded ? 'Collapse' : 'Expand'
+ %p
+ = _('External Classification Policy Authorization')
+ .settings-content
+
+ = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ .form-check
+ = f.check_box :external_authorization_service_enabled, class: 'form-check-input'
+ = f.label :external_authorization_service_enabled, class: 'form-check-label' do
+ = _('Enable classification control using an external service')
+ %span.form-text.text-muted
+ = external_authorization_description
+ = link_to icon('question-circle'), help_page_path('user/admin_area/settings/external_authorization')
+ .form-group
+ = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold'
+ = f.text_field :external_authorization_service_url, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_url_help_text
+ .form-group
+ = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold'
+ = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001
+ %span.form-text.text-muted
+ = external_authorization_timeout_help_text
+ = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold'
+ = f.text_area :external_auth_client_cert, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_certificate_help_text
+ .form-group
+ = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold'
+ = f.text_area :external_auth_client_key, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_key_help_text
+ .form-group
+ = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold'
+ = f.password_field :external_auth_client_key_pass, class: 'form-control'
+ %span.form-text.text-muted
+ = external_authorization_client_pass_help_text
+ .form-group
+ = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold'
+ = f.text_field :external_authorization_service_default_label, class: 'form-control'
+
+ = f.submit 'Save changes', class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_help_page.html.haml b/app/views/admin/application_settings/_help_page.html.haml
index 70c8c74cc5d..aa491c735d1 100644
--- a/app/views/admin/application_settings/_help_page.html.haml
+++ b/app/views/admin/application_settings/_help_page.html.haml
@@ -2,18 +2,20 @@
= form_errors(@application_setting)
%fieldset
+ = render_if_exists 'admin/application_settings/help_text_setting', form: f
+
.form-group
= f.label :help_page_text, class: 'label-bold'
= f.text_area :help_page_text, class: 'form-control', rows: 4
- .form-text.text-muted Markdown enabled
+ .form-text.text-muted= _('Markdown enabled')
.form-group
.form-check
= f.check_box :help_page_hide_commercial_content, class: 'form-check-input'
= f.label :help_page_hide_commercial_content, class: 'form-check-label' do
- Hide marketing-related entries from help
+ = _('Hide marketing-related entries from help')
.form-group
- = f.label :help_page_support_url, 'Support page URL', class: 'label-bold'
+ = f.label :help_page_support_url, _('Support page URL'), class: 'label-bold'
= f.text_field :help_page_support_url, class: 'form-control', placeholder: 'http://company.example.com/getting-help', :'aria-describedby' => 'support_help_block'
- %span.form-text.text-muted#support_help_block Alternate support URL for help page
+ %span.form-text.text-muted#support_help_block= _('Alternate support URL for help page')
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml
new file mode 100644
index 00000000000..95d016a94a5
--- /dev/null
+++ b/app/views/admin/application_settings/_localization.html.haml
@@ -0,0 +1,11 @@
+= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-localization-settings'), html: { class: 'fieldset-form' } do |f|
+ = form_errors(@application_setting)
+
+ %fieldset
+ .form-group
+ = f.label :first_day_of_week, _('Default first day of the week'), class: 'label-bold'
+ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control'
+ .form-text.text-muted
+ = _('Default first day of the week in calendars and date pickers.')
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_logging.html.haml b/app/views/admin/application_settings/_logging.html.haml
index 41b787515b5..1da5f6fccd6 100644
--- a/app/views/admin/application_settings/_logging.html.haml
+++ b/app/views/admin/application_settings/_logging.html.haml
@@ -1,6 +1,12 @@
= form_for @application_setting, url: admin_application_settings_path(anchor: 'js-logging-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
+ %p
+ %strong
+ NOTE:
+ These settings will be removed from the UI in a GitLab 12.0 release and made available within gitlab.yml.
+ In addition, you will be able to define a Sentry Environment to differentiate between multiple deployments. For example, development, staging, and production.
+
%fieldset
.form-group
.form-check
diff --git a/app/views/admin/application_settings/_pages.html.haml b/app/views/admin/application_settings/_pages.html.haml
index ad5c8d4da22..77795dbf913 100644
--- a/app/views/admin/application_settings/_pages.html.haml
+++ b/app/views/admin/application_settings/_pages.html.haml
@@ -5,16 +5,32 @@
.form-group
= f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'label-bold'
= f.number_field :max_pages_size, class: 'form-control'
- .form-text.text-muted 0 for unlimited
+ .form-text.text-muted
+ = _("0 for unlimited")
.form-group
.form-check
= f.check_box :pages_domain_verification_enabled, class: 'form-check-input'
= f.label :pages_domain_verification_enabled, class: 'form-check-label' do
- Require users to prove ownership of custom domains
+ = _("Require users to prove ownership of custom domains")
.form-text.text-muted
- Domain verification is an essential security measure for public GitLab
- sites. Users are required to demonstrate they control a domain before
- it is enabled
+ = _("Domain verification is an essential security measure for public GitLab sites. Users are required to demonstrate they control a domain before it is enabled")
= link_to icon('question-circle'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
+ - if Feature.enabled?(:pages_auto_ssl)
+ %h5
+ = _("Configure Let's Encrypt")
+ %p
+ - lets_encrypt_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: "https://letsencrypt.org/" }
+ = _("%{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end} is a free, automated, and open certificate authority (CA), that give digital certificates in order to enable HTTPS (SSL/TLS) for websites.").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: '</a>'.html_safe }
+ .form-group
+ = f.label :lets_encrypt_notification_email, _("Email"), class: 'label-bold'
+ = f.text_field :lets_encrypt_notification_email, class: 'form-control'
+ .form-text.text-muted
+ = _("A Let's Encrypt account will be configured for this GitLab installation using your email address. You will receive emails to warn of expiring certificates.")
+ .form-group
+ .form-check
+ = f.check_box :lets_encrypt_terms_of_service_accepted, class: 'form-check-input'
+ = f.label :lets_encrypt_terms_of_service_accepted, class: 'form-check-label' do
+ - terms_of_service_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: lets_encrypt_terms_of_service_admin_application_settings_path }
+ = _("I have read and agree to the Let's Encrypt %{link_start}Terms of Service%{link_end}").html_safe % { link_start: terms_of_service_link_start, link_end: '</a>'.html_safe }
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_performance_bar.html.haml b/app/views/admin/application_settings/_performance_bar.html.haml
index 44ac8d94764..f992d531ea5 100644
--- a/app/views/admin/application_settings/_performance_bar.html.haml
+++ b/app/views/admin/application_settings/_performance_bar.html.haml
@@ -5,10 +5,10 @@
.form-group
.form-check
= f.check_box :performance_bar_enabled, class: 'form-check-input'
- = f.label :performance_bar_enabled, class: 'form-check-label' do
+ = f.label :performance_bar_enabled, class: 'form-check-label qa-enable-performance-bar-checkbox' do
Enable the Performance Bar
.form-group
= f.label :performance_bar_allowed_group_path, 'Allowed group', class: 'label-bold'
= f.text_field :performance_bar_allowed_group_path, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit 'Save changes', class: 'btn btn-success qa-save-changes-button'
diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
index 615aa6317b0..f2f2cd1282a 100644
--- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml
+++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml
@@ -3,13 +3,15 @@
%fieldset
.form-group
- = f.label :mirror_available, 'Enable mirror configuration', class: 'label-bold'
+ = f.label :mirror_available, _('Enable mirror configuration'), class: 'label-bold'
.form-check
= f.check_box :mirror_available, class: 'form-check-input'
= f.label :mirror_available, class: 'form-check-label' do
- Allow mirrors to be set up for projects
+ = _('Allow mirrors to be set up for projects')
%span.form-text.text-muted
- If disabled, only admins will be able to set up mirrors in projects.
+ = _('If disabled, only admins will be able to set up mirrors in projects.')
= link_to icon('question-circle'), help_page_path('workflow/repository_mirroring')
- = f.submit 'Save changes', class: "btn btn-success"
+ = render_if_exists 'admin/application_settings/mirror_settings', form: f
+
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml
index c6c29ed1f21..7a2bbfcdc4d 100644
--- a/app/views/admin/application_settings/_repository_storage.html.haml
+++ b/app/views/admin/application_settings/_repository_storage.html.haml
@@ -11,7 +11,6 @@
.form-text.text-muted
Enable immutable, hash-based paths and repository names to store repositories on disk. This prevents
repositories from having to be moved or renamed when the Project URL changes and may improve disk I/O performance.
- %em (EXPERIMENTAL)
.form-group
= f.label :repository_storages, 'Storage paths for new projects', class: 'label-bold'
= f.select :repository_storages, repository_storages_options_for_select(@application_setting.repository_storages),
diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml
index 0725ffb7f6c..03ef2924617 100644
--- a/app/views/admin/application_settings/_visibility_and_access.html.haml
+++ b/app/views/admin/application_settings/_visibility_and_access.html.haml
@@ -5,6 +5,9 @@
.form-group
= f.label :default_branch_protection, class: 'label-bold'
= f.select :default_branch_protection, options_for_select(Gitlab::Access.protection_options, @application_setting.default_branch_protection), {}, class: 'form-control'
+ .form-group
+ = f.label s_('ProjectCreationLevel|Default project creation protection'), class: 'label-bold'
+ = f.select :default_project_creation, options_for_select(Gitlab::Access.project_creation_options, @application_setting.default_project_creation), {}, class: 'form-control'
.form-group.visibility-level-setting
= f.label :default_project_visibility, class: 'label-bold'
= render('shared/visibility_radios', model_method: :default_project_visibility, form: f, selected_level: @application_setting.default_project_visibility, form_model: Project.new)
@@ -22,32 +25,33 @@
.form-check
= level
%span.form-text.text-muted#restricted-visibility-help
- Selected levels cannot be used by non-admin users for groups, projects or snippets.
- If the public level is restricted, user profiles are only visible to logged in users.
+ = _('Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users.')
.form-group
= f.label :import_sources, class: 'label-bold'
= hidden_field_tag 'application_setting[import_sources][]'
- import_sources_checkboxes('import-sources-help', class: 'form-check-input').each do |source|
.form-check= source
%span.form-text.text-muted#import-sources-help
- Enabled sources for code import during project creation. OmniAuth must be configured for GitHub
+ = _('Enabled sources for code import during project creation. OmniAuth must be configured for GitHub')
= link_to "(?)", help_page_path("integration/github")
, Bitbucket
= link_to "(?)", help_page_path("integration/bitbucket")
and GitLab.com
= link_to "(?)", help_page_path("integration/gitlab")
+ = render_if_exists 'admin/application_settings/ldap_access_setting', form: f
+
.form-group
.form-check
= f.check_box :project_export_enabled, class: 'form-check-input'
= f.label :project_export_enabled, class: 'form-check-label' do
- Project export enabled
+ = _('Project export enabled')
.form-group
- %label.label-bold Enabled Git access protocols
+ %label.label-bold= _('Enabled Git access protocols')
= select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control')
%span.form-text.text-muted#clone-protocol-help
- Allow only the selected protocols to be used for Git access.
+ = _('Allow only the selected protocols to be used for Git access.')
- ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type|
- field_name = :"#{type}_key_restriction"
@@ -55,4 +59,4 @@
= f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold'
= f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control'
- = f.submit 'Save changes', class: "btn btn-success"
+ = f.submit _('Save changes'), class: "btn btn-success"
diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml
index f50aca32bdf..d5ba6abe7af 100644
--- a/app/views/admin/application_settings/metrics_and_profiling.html.haml
+++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml
@@ -24,7 +24,7 @@
.settings-content
= render 'prometheus'
-%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Profiling - Performance bar')
diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml
index 00000b86ab7..c468d69d7b8 100644
--- a/app/views/admin/application_settings/preferences.html.haml
+++ b/app/views/admin/application_settings/preferences.html.haml
@@ -56,3 +56,14 @@
= _('Configure Gitaly timeouts.')
.settings-content
= render 'gitaly'
+
+%section.settings.as-localization.no-animate#js-localization-settings{ class: ('expanded' if expanded_by_default?) }
+ .settings-header
+ %h4
+ = _('Localization')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded_by_default? ? _('Collapse') : _('Expand')
+ %p
+ = _('Various localization settings.')
+ .settings-content
+ = render 'localization'
diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml
index 65e4723afe6..31f18ba0d56 100644
--- a/app/views/admin/application_settings/show.html.haml
+++ b/app/views/admin/application_settings/show.html.haml
@@ -13,7 +13,7 @@
.settings-content
= render 'visibility_and_access'
-%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
+%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
%h4
= _('Account and limit')
@@ -68,7 +68,7 @@
.settings-content
= render 'terms'
-= render_if_exists 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
+= render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default?
%section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) }
.settings-header
diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml
index 12690343f6e..21e84016c66 100644
--- a/app/views/admin/applications/_form.html.haml
+++ b/app/views/admin/applications/_form.html.haml
@@ -2,13 +2,15 @@
= form_errors(application)
= content_tag :div, class: 'form-group row' do
- = f.label :name, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :name
.col-sm-10
= f.text_field :name, class: 'form-control'
= doorkeeper_errors_for application, :name
= content_tag :div, class: 'form-group row' do
- = f.label :redirect_uri, class: 'col-sm-2 col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :redirect_uri
.col-sm-10
= f.text_area :redirect_uri, class: 'form-control'
= doorkeeper_errors_for application, :redirect_uri
@@ -21,14 +23,16 @@
for local tests
= content_tag :div, class: 'form-group row' do
- = f.label :trusted, class: 'col-sm-2 col-form-label pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :trusted
.col-sm-10
= f.check_box :trusted
%span.form-text.text-muted
Trusted applications are automatically authorized on GitLab OAuth flow.
.form-group.row
- = f.label :scopes, class: 'col-sm-2 col-form-label pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :scopes
.col-sm-10
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml
index df3eeba907c..180066723f1 100644
--- a/app/views/admin/applications/show.html.haml
+++ b/app/views/admin/applications/show.html.haml
@@ -11,7 +11,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
+ %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr
@@ -20,7 +20,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
+ %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr
diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml
index c465d9f51d6..c8ee87c6212 100644
--- a/app/views/admin/broadcast_messages/_form.html.haml
+++ b/app/views/admin/broadcast_messages/_form.html.haml
@@ -10,28 +10,34 @@
= form_errors(@broadcast_message)
.form-group.row
- = f.label :message, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :message
.col-sm-10
= f.text_area :message, class: "form-control js-autosize",
required: true,
+ dir: 'auto',
data: { preview_path: preview_admin_broadcast_messages_path }
.form-group.row.js-toggle-colors-container
.col-sm-10.offset-sm-2
= link_to 'Customize colors', '#', class: 'js-toggle-colors-link'
.form-group.row.js-toggle-colors-container.toggle-colors.hide
- = f.label :color, "Background Color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, "Background Color"
.col-sm-10
= f.color_field :color, class: "form-control"
.form-group.row.js-toggle-colors-container.toggle-colors.hide
- = f.label :font, "Font Color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :font, "Font Color"
.col-sm-10
= f.color_field :font, class: "form-control"
.form-group.row
- = f.label :starts_at, _("Starts at (UTC)"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :starts_at, _("Starts at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :starts_at, {}, class: 'form-control form-control-inline'
.form-group.row
- = f.label :ends_at, _("Ends at (UTC)"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :ends_at, _("Ends at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml
index 9ef58faf8cc..eb4dfdf2858 100644
--- a/app/views/admin/broadcast_messages/index.html.haml
+++ b/app/views/admin/broadcast_messages/index.html.haml
@@ -32,7 +32,7 @@
%td
= message.ends_at
%td
- = link_to icon('pencil-square-o'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-sm'
- = link_to icon('times'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-sm btn-danger'
+ = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn'
+ = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger'
= paginate @broadcast_messages, theme: 'gitlab'
diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml
index 6756299cf43..581f6ae0714 100644
--- a/app/views/admin/dashboard/index.html.haml
+++ b/app/views/admin/dashboard/index.html.haml
@@ -22,9 +22,10 @@
%h3.text-center
Users:
= approximate_count_with_delimiters(@counts, User)
- = render_if_exists 'admin/dashboard/users_statistics'
%hr
- = link_to 'New user', new_admin_user_path, class: "btn btn-success"
+ .btn-group.d-flex{ role: 'group' }
+ = link_to 'New user', new_admin_user_path, class: "btn btn-success"
+ = render_if_exists 'admin/dashboard/users_statistics'
.col-sm-4
.info-well.dark-well
.well-segment.well-centered
@@ -162,7 +163,7 @@
%span.float-right
#{Rails::VERSION::STRING}
%p
- = Gitlab::Database.adapter_name
+ = Gitlab::Database.human_adapter_name
%span.float-right
= Gitlab::Database.version
%p
diff --git a/app/views/admin/deploy_keys/edit.html.haml b/app/views/admin/deploy_keys/edit.html.haml
index 7c04ef03947..99d8af65068 100644
--- a/app/views/admin/deploy_keys/edit.html.haml
+++ b/app/views/admin/deploy_keys/edit.html.haml
@@ -1,10 +1,10 @@
-- page_title 'Edit Deploy Key'
-%h3.page-title Edit public deploy key
+- page_title _('Edit Deploy Key')
+%h3.page-title= _('Edit public deploy key')
%hr
%div
= form_for [:admin, @deploy_key], html: { class: 'deploy-key-form' } do |f|
= render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key }
.form-actions
- = f.submit 'Save changes', class: 'btn-success btn'
- = link_to 'Cancel', admin_deploy_keys_path, class: 'btn btn-cancel'
+ = f.submit _('Save changes'), class: 'btn-success btn'
+ = link_to _('Cancel'), admin_deploy_keys_path, class: 'btn btn-cancel'
diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml
index 01013be06d6..9fffa97f969 100644
--- a/app/views/admin/deploy_keys/index.html.haml
+++ b/app/views/admin/deploy_keys/index.html.haml
@@ -1,19 +1,19 @@
-- page_title "Deploy Keys"
+- page_title _('Deploy Keys')
%h3.page-title.deploy-keys-title
- Public deploy keys (#{@deploy_keys.count})
+ = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.count }
.float-right
- = link_to 'New deploy key', new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted'
+ = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted'
- if @deploy_keys.any?
.table-holder.deploy-keys-list
%table.table
%thead
%tr
- %th.col-sm-2 Title
- %th.col-sm-4 Fingerprint
- %th.col-sm-2 Projects with write access
- %th.col-sm-2 Added at
+ %th.col-sm-2= _('Title')
+ %th.col-sm-4= _('Fingerprint')
+ %th.col-sm-2= _('Projects with write access')
+ %th.col-sm-2= _('Added at')
%th.col-sm-2
%tbody
- @deploy_keys.each do |deploy_key|
@@ -27,8 +27,8 @@
= link_to project.full_name, admin_project_path(project), class: 'label deploy-project-label'
%td
%span.cgray
- added #{time_ago_with_tooltip(deploy_key.created_at)}
+ = _('added %{created_at_timeago}').html_safe % { created_at_timeago: time_ago_with_tooltip(deploy_key.created_at) }
%td
.float-right
- = link_to 'Edit', edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
- = link_to 'Remove', admin_deploy_key_path(deploy_key), data: { confirm: 'Are you sure?'}, method: :delete, class: 'btn btn-sm btn-remove delete-key'
+ = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm'
+ = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm btn-remove delete-key'
diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml
index 5e05568e384..dd01ef8a29f 100644
--- a/app/views/admin/groups/_form.html.haml
+++ b/app/views/admin/groups/_form.html.haml
@@ -2,13 +2,14 @@
= form_errors(@group)
= render 'shared/group_form', f: f
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'shared/old_repository_size_limit_setting', form: f, type: :group
= render_if_exists 'admin/namespace_plan', f: f
.form-group.row.group-description-holder
- = f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :avatar, _("Group avatar")
.col-sm-10
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
= render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false
diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml
index 0a688b90f3a..395c469255e 100644
--- a/app/views/admin/groups/_group.html.haml
+++ b/app/views/admin/groups/_group.html.haml
@@ -22,7 +22,7 @@
%span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
= visibility_level_icon(group.visibility_level, fw: false)
- .avatar-container.s40
+ .avatar-container.rect-avatar.s40
= group_icon(group, class: "avatar s40 d-none d-sm-block")
.title
= link_to [:admin, group], class: 'group-name' do
diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml
index da2ebb08405..f524d35d79e 100644
--- a/app/views/admin/groups/show.html.haml
+++ b/app/views/admin/groups/show.html.haml
@@ -15,7 +15,7 @@
= _('Group info:')
%ul.content-list
%li
- .avatar-container.s60
+ .avatar-container.rect-avatar.s60
= group_icon(@group, class: "avatar s60")
%li
%span.light= _('Name:')
@@ -44,12 +44,10 @@
%li
%span.light= _('Storage:')
- - counter_storage = storage_counter(@group.storage_size)
- - counter_repositories = storage_counter(@group.repository_size)
- - counter_build_artifacts = storage_counter(@group.build_artifacts_size)
- - counter_lfs_objects = storage_counter(@group.lfs_objects_size)
- %strong
- = _("%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)") % { counter_storage: counter_storage, counter_repositories: counter_repositories, counter_build_artifacts: counter_build_artifacts, counter_lfs_objects: counter_lfs_objects }
+ %strong= storage_counter(@group.storage_size)
+ (
+ = storage_counters_details(@group)
+ )
%li
%span.light= _('Group Git LFS status:')
@@ -104,7 +102,7 @@
- link_to_help = link_to(_("here"), help_page_path("user/permissions"))
= _('Read more about project permissions <strong>%{link_to_help}</strong>').html_safe % { link_to_help: link_to_help }
- = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do
+ = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do
%div
= users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all)
.prepend-top-10
diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml
index 0f5e97e288a..ac56e354a4d 100644
--- a/app/views/admin/health_check/show.html.haml
+++ b/app/views/admin/health_check/show.html.haml
@@ -23,7 +23,7 @@
%code= liveness_url(token: Gitlab::CurrentSettings.health_check_access_token)
%li
%code= metrics_url(token: Gitlab::CurrentSettings.health_check_access_token)
-
+ = render_if_exists 'admin/health_check/health_check_url'
%hr
.card
.card-header
diff --git a/app/views/admin/identities/_form.html.haml b/app/views/admin/identities/_form.html.haml
index 3ab7990d9e2..40a7014e143 100644
--- a/app/views/admin/identities/_form.html.haml
+++ b/app/views/admin/identities/_form.html.haml
@@ -2,12 +2,14 @@
= form_errors(@identity)
.form-group.row
- = f.label :provider, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :provider
.col-sm-10
- values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] }
= f.select :provider, values, { allow_blank: false }, class: 'form-control'
.form-group.row
- = f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :extern_uid, _("Identifier")
.col-sm-10
= f.text_field :extern_uid, class: 'form-control', required: true
diff --git a/app/views/admin/labels/_form.html.haml b/app/views/admin/labels/_form.html.haml
index 5e7b4817461..49aa62a5408 100644
--- a/app/views/admin/labels/_form.html.haml
+++ b/app/views/admin/labels/_form.html.haml
@@ -2,15 +2,18 @@
= form_errors(@label)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
= f.text_field :title, class: "form-control", required: true
.form-group.row
- = f.label :description, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit"
.form-group.row
- = f.label :color, _("Background color"), class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, _("Background color")
.col-sm-10
.input-group
.input-group-prepend
diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml
index dbb7224f5f9..6d934654c5d 100644
--- a/app/views/admin/labels/_label.html.haml
+++ b/app/views/admin/labels/_label.html.haml
@@ -1,5 +1,5 @@
%li.label-list-item{ id: dom_id(label) }
- = render "shared/label_row", label: label
+ = render "shared/label_row", label: label.present(issuable_subject: nil)
.label-actions-list
= link_to edit_admin_label_path(label), class: 'btn btn-transparent label-action has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml
index 50296a2afe7..9117f63f939 100644
--- a/app/views/admin/projects/_projects.html.haml
+++ b/app/views/admin/projects/_projects.html.haml
@@ -13,13 +13,13 @@
.stats
%span.badge.badge-pill
- = storage_counter(project.statistics.storage_size)
+ = storage_counter(project.statistics&.storage_size)
- if project.archived
%span.badge.badge-warning archived
.title
= link_to(admin_namespace_project_path(project.namespace, project)) do
.dash-project-avatar
- .avatar-container.s40
+ .avatar-container.rect-avatar.s40
= project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40)
%span.project-full-name
%span.namespace-name
diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml
index 46bb57c78a8..b88b760536d 100644
--- a/app/views/admin/projects/index.html.haml
+++ b/app/views/admin/projects/index.html.haml
@@ -7,7 +7,7 @@
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.prepend-top-default
.search-holder
- = render 'shared/projects/search_form', autofocus: true, icon: true
+ = render 'shared/projects/search_form', autofocus: true, icon: true, admin_view: true
.dropdown
- toggle_text = 'Namespace'
- if params[:namespace_id].present?
diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml
index 03cce4745aa..e23accc1ea9 100644
--- a/app/views/admin/projects/show.html.haml
+++ b/app/views/admin/projects/show.html.haml
@@ -73,16 +73,11 @@
= @project.repository.relative_path
%li
- %span.light Storage used:
- %strong= storage_counter(@project.statistics.storage_size)
- (
- = storage_counter(@project.statistics.repository_size)
- repository,
- = storage_counter(@project.statistics.build_artifacts_size)
- build artifacts,
- = storage_counter(@project.statistics.lfs_objects_size)
- LFS
- )
+ %span.light= _('Storage:')
+ %strong= storage_counter(@project.statistics&.storage_size)
+ - if @project.statistics
+ = surround '(', ')' do
+ = storage_counters_details(@project.statistics)
%li
%span.light last commit:
@@ -105,6 +100,8 @@
%span.light archived:
%strong project is read-only
+ = render_if_exists "shared_runner_status", project: @project
+
%li
%span.light access:
%strong
@@ -120,7 +117,8 @@
.card-body
= form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
- = f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3'
+ .col-sm-3.col-form-label
+ = f.label :new_namespace_id, "Namespace"
.col-sm-9
.dropdown
= dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml
index e4fc2985087..423472324fe 100644
--- a/app/views/admin/runners/_runner.html.haml
+++ b/app/views/admin/runners/_runner.html.haml
@@ -18,19 +18,19 @@
.table-mobile-content
= link_to runner.short_sha, admin_runner_path(runner)
- .table-section.section-15
+ .table-section.section-20
.table-mobile-header{ role: 'rowheader' }= _('Description')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.description }
= runner.description
- .table-section.section-15
+ .table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Version')
.table-mobile-content.str-truncated.has-tooltip{ title: runner.version }
= runner.version
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('IP Address')
- .table-mobile-content
+ .table-mobile-content.str-truncated.has-tooltip{ title: runner.ip_address }
= runner.ip_address
.table-section.section-5
@@ -44,20 +44,21 @@
.table-section.section-5
.table-mobile-header{ role: 'rowheader' }= _('Jobs')
.table-mobile-content
- = runner.builds.count(:all)
+ = limited_counter_with_delimiter(runner.builds)
.table-section.section-10.section-wrap
.table-mobile-header{ role: 'rowheader' }= _('Tags')
.table-mobile-content
- - runner.tag_list.sort.each do |tag|
- %span.badge.badge-primary
+ - runner.tags.map(&:name).sort.each do |tag|
+ %span.badge.badge-primary.str-truncated.has-tooltip{ title: tag }
= tag
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= _('Last contact')
.table-mobile-content
- - if runner.contacted_at
- = time_ago_with_tooltip runner.contacted_at
+ - contacted_at = runner_contacted_at(runner)
+ - if contacted_at
+ = time_ago_with_tooltip contacted_at
- else
= _('Never')
diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml
index 19c2a50ebd9..4f4f0a543e0 100644
--- a/app/views/admin/runners/_sort_dropdown.html.haml
+++ b/app/views/admin/runners/_sort_dropdown.html.haml
@@ -6,6 +6,6 @@
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
- = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date, label: true), sorted_by)
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sorted_by)
+ = sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date), sorted_by)
diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml
index e9e4e0847d3..2e23b748edb 100644
--- a/app/views/admin/runners/index.html.haml
+++ b/app/views/admin/runners/index.html.haml
@@ -92,6 +92,25 @@
= button_tag class: %w[btn btn-link] do
= runner_type.titleize
+ #js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ - Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
+ %li.filter-dropdown-item{ data: { value: runner_type } }
+ = button_tag class: %w[btn btn-link] do
+ = runner_type.titleize
+
+ #js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'none' } }
+ %button.btn.btn-link
+ = _('No Tag')
+ %li.divider.droplab-item-ignore
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value
+ %span.dropdown-light-content
+ {{name}}
+
= button_tag class: %w[clear-search hidden] do
= icon('times')
.filter-dropdown-container
@@ -106,8 +125,8 @@
.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-10{ role: 'rowheader' }= _('Type')
.table-section.section-10{ role: 'rowheader' }= _('Runner token')
- .table-section.section-15{ role: 'rowheader' }= _('Description')
- .table-section.section-15{ role: 'rowheader' }= _('Version')
+ .table-section.section-20{ role: 'rowheader' }= _('Description')
+ .table-section.section-10{ role: 'rowheader' }= _('Version')
.table-section.section-10{ role: 'rowheader' }= _('IP Address')
.table-section.section-5{ role: 'rowheader' }= _('Projects')
.table-section.section-5{ role: 'rowheader' }= _('Jobs')
diff --git a/app/views/admin/users/_access_levels.html.haml b/app/views/admin/users/_access_levels.html.haml
index 12e24ddef02..77729636f9d 100644
--- a/app/views/admin/users/_access_levels.html.haml
+++ b/app/views/admin/users/_access_levels.html.haml
@@ -1,18 +1,20 @@
%fieldset
%legend Access
.form-group.row
- .col-sm-2.text-right
- = f.label :projects_limit, class: 'col-form-label'
- .col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :projects_limit
+ .col-sm-10
+ = f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :can_create_group, class: 'col-form-label'
- .col-sm-10= f.check_box :can_create_group
+ .col-sm-2.col-form-label
+ = f.label :can_create_group
+ .col-sm-10
+ = f.check_box :can_create_group
.form-group.row
- .col-sm-2.text-right
- = f.label :access_level, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :access_level
.col-sm-10
- editing_current_user = (current_user == @user)
@@ -22,6 +24,8 @@
%p.light
Regular users have access to their groups and projects
+ = render_if_exists 'admin/users/auditor_access_level_radio', f: f, disabled: editing_current_user
+
= f.radio_button :access_level, :admin, disabled: editing_current_user
= label_tag :admin, class: 'font-weight-bold' do
Admin
@@ -32,8 +36,8 @@
You cannot remove your own admin rights.
.form-group.row
- .col-sm-2.text-right
- = f.label :external, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :external
.hidden{ data: user_internal_regex_data }
.col-sm-10
= f.check_box :external do
diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml
index 296ef073144..3281718071c 100644
--- a/app/views/admin/users/_form.html.haml
+++ b/app/views/admin/users/_form.html.haml
@@ -5,20 +5,20 @@
%fieldset
%legend Account
.form-group.row
- .col-sm-2.text-right
- = f.label :name, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :name
.col-sm-10
= f.text_field :name, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
.form-group.row
- .col-sm-2.text-right
- = f.label :username, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :username
.col-sm-10
= f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control'
%span.help-inline * required
.form-group.row
- .col-sm-2.text-right
- = f.label :email, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :email
.col-sm-10
= f.text_field :email, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
@@ -27,8 +27,8 @@
%fieldset
%legend Password
.form-group.row
- .col-sm-2.text-right
- = f.label :password, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :password
.col-sm-10
%strong
Reset link will be generated and sent to the user.
@@ -38,40 +38,52 @@
%fieldset
%legend Password
.form-group.row
- .col-sm-2.text-right
- = f.label :password, class: 'col-form-label'
- .col-sm-10= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password
+ .col-sm-10
+ = f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :password_confirmation, class: 'col-form-label'
- .col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password_confirmation
+ .col-sm-10
+ = f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
= render partial: 'access_levels', locals: { f: f }
+ = render_if_exists 'admin/users/namespace_plan_fieldset', f: f
+
+ = render_if_exists 'admin/users/limits', f: f
+
%fieldset
%legend Profile
.form-group.row
- .col-sm-2.text-right
- = f.label :avatar, class: 'col-form-label'
+ .col-sm-2.col-form-label
+ = f.label :avatar
.col-sm-10
= f.file_field :avatar
.form-group.row
- .col-sm-2.text-right
- = f.label :skype, class: 'col-form-label'
- .col-sm-10= f.text_field :skype, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :skype
+ .col-sm-10
+ = f.text_field :skype, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :linkedin, class: 'col-form-label'
- .col-sm-10= f.text_field :linkedin, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :linkedin
+ .col-sm-10
+ = f.text_field :linkedin, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :twitter, class: 'col-form-label'
- .col-sm-10= f.text_field :twitter, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :twitter
+ .col-sm-10
+ = f.text_field :twitter, class: 'form-control'
.form-group.row
- .col-sm-2.text-right
- = f.label :website_url, 'Website', class: 'col-form-label'
- .col-sm-10= f.text_field :website_url, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :website_url
+ .col-sm-10
+ = f.text_field :website_url, class: 'form-control'
+
+ = render_if_exists 'admin/users/admin_notes', f: f
.form-actions
- if @user.new_record?
diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml
index a733f420d11..e7dde7985fd 100644
--- a/app/views/admin/users/_head.html.haml
+++ b/app/views/admin/users/_head.html.haml
@@ -6,6 +6,7 @@
%span.cred (Internal)
- if @user.admin
%span.cred (Admin)
+ = render_if_exists 'admin/users/audtior_user_badge'
.float-right
- if impersonation_enabled? && @user != current_user && @user.can?(:log_in)
diff --git a/app/views/admin/users/_user.html.haml b/app/views/admin/users/_user.html.haml
index b2163ee85fa..be7bfa958b2 100644
--- a/app/views/admin/users/_user.html.haml
+++ b/app/views/admin/users/_user.html.haml
@@ -1,36 +1,37 @@
-%li.flex-row
- .user-avatar
- = image_tag avatar_icon_for_user(user), class: "avatar", alt: ''
- .row-main-content
- .user-name.row-title.str-truncated-100
- = link_to user.name, [:admin, user]
- - if user.blocked?
- %span.badge.badge-danger blocked
- - if user.admin?
- %span.badge.badge-success Admin
- - if user.external?
- %span.badge.badge-secondary External
- - if user == current_user
- %span It's you!
- .row-second-line.str-truncated-100
- = mail_to user.email, user.email
- .controls
- = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn'
- - unless user == current_user
- .dropdown.inline
- %a.dropdown-new.btn.btn-default#project-settings-button{ href: '#', data: { toggle: 'dropdown' } }
+.gl-responsive-table-row{ role: 'row' }
+ .table-section.section-40
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Name')
+ .table-mobile-content
+ = render 'user_detail', user: user
+ .table-section.section-25
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Created on')
+ .table-mobile-content
+ = l(user.created_at.to_date, format: :admin)
+ .table-section.section-15
+ .table-mobile-header{ role: 'rowheader' }
+ = _('Last activity')
+ .table-mobile-content
+ = user.last_activity_on.nil? ? _('Never') : l(user.last_activity_on, format: :admin)
+ .table-section.section-20.table-button-footer
+ .table-action-buttons
+ = link_to _('Edit'), edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn btn-default'
+ - unless user == current_user
+ %button.dropdown-new.btn.btn-default{ type: 'button', data: { toggle: 'dropdown' } }
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
- Settings
+ = _('Settings')
%li
- if user.ldap_blocked?
- %span.small Cannot unblock LDAP blocked users
+ %span.small
+ = s_('AdminUsers|Cannot unblock LDAP blocked users')
- elsif user.blocked?
- = link_to 'Unblock', unblock_admin_user_path(user), method: :put
+ = link_to _('Unblock'), unblock_admin_user_path(user), method: :put
- else
- = link_to 'Block', block_admin_user_path(user), data: { confirm: 'USER WILL BE BLOCKED! Are you sure?' }, method: :put
+ = link_to _('Block'), block_admin_user_path(user), data: { confirm: "#{s_('AdminUsers|User will be blocked').upcase}! #{_('Are you sure')}?" }, method: :put
- if user.access_locked?
%li
= link_to _('Unlock'), unlock_admin_user_path(user), method: :put, data: { confirm: _('Are you sure?') }
@@ -42,7 +43,7 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: false }, type: 'button' }
= s_('AdminUsers|Delete user')
@@ -51,6 +52,6 @@
target: '#delete-user-modal',
delete_user_url: admin_user_path(user, hard_delete: true),
block_user_url: block_admin_user_path(user),
- username: user.name,
+ username: sanitize_name(user.name),
delete_contributions: true }, type: 'button' }
= s_('AdminUsers|Delete user and contributions')
diff --git a/app/views/admin/users/_user_detail.html.haml b/app/views/admin/users/_user_detail.html.haml
new file mode 100644
index 00000000000..13d10dcd625
--- /dev/null
+++ b/app/views/admin/users/_user_detail.html.haml
@@ -0,0 +1,17 @@
+.flex-list
+ .flex-row
+ = image_tag avatar_icon_for_user(user), class: 'avatar s32 d-none d-md-flex', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ .row-main-content
+ .row-title.str-truncated-100
+ = image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 prepend-top-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
+ = link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id }
+
+ = render_if_exists 'admin/users/user_listing_note', user: user
+
+ - user_badges_in_admin_section(user).each do |badge|
+ - css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
+ %span{ class: css_badge }
+ = badge[:text]
+
+ .row-second-line.str-truncated-100
+ = mail_to user.email, user.email, class: 'text-secondary'
diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml
index 600120c4f05..c3d5ce0fe70 100644
--- a/app/views/admin/users/index.html.haml
+++ b/app/views/admin/users/index.html.haml
@@ -2,72 +2,78 @@
- page_title "Users"
%div{ class: container_class }
- .prepend-top-default
+ .top-area.scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left
+ = icon('angle-left')
+ .fade-right
+ = icon('angle-right')
+ %ul.nav-links.nav.nav-tabs.scrolling-tabs
+ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
+ = link_to admin_users_path do
+ = s_('AdminUsers|Active')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
+ = link_to admin_users_path(filter: "admins") do
+ = s_('AdminUsers|Admins')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_enabled') do
+ = s_('AdminUsers|2FA Enabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
+ = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
+ = link_to admin_users_path(filter: 'two_factor_disabled') do
+ = s_('AdminUsers|2FA Disabled')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
+ = link_to admin_users_path(filter: 'external') do
+ = s_('AdminUsers|External')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
+ = link_to admin_users_path(filter: "blocked") do
+ = s_('AdminUsers|Blocked')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
+ = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
+ = link_to admin_users_path(filter: "wop") do
+ = s_('AdminUsers|Without projects')
+ %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ .nav-controls
+ = render_if_exists 'admin/users/admin_email_users'
+ = link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn btn-success btn-search float-right'
+
+ .filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder
- = search_field_tag :search_query, params[:search_query], placeholder: 'Search by name, email or username', class: 'form-control search-text-input js-search-input', spellcheck: false
+ = search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false
- if @sort.present?
= hidden_field_tag :sort, @sort
= icon("search", class: "search-icon")
- = button_tag 'Search users' if Rails.env.test?
+ = button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
- toggle_text = if @sort.present? then users_sort_options_hash[@sort] else sort_title_name end
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
- Sort by
+ = s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
- = link_to 'New user', new_admin_user_path, class: 'btn btn-success btn-search'
- .top-area.scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left
- = icon('angle-left')
- .fade-right
- = icon('angle-right')
- %ul.nav-links.nav.nav-tabs.scrolling-tabs
- = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
- = link_to admin_users_path do
- Active
- %small.badge.badge-pill= limited_counter_with_delimiter(User.active)
- = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
- = link_to admin_users_path(filter: "admins") do
- Admins
- %small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
- = link_to admin_users_path(filter: 'two_factor_enabled') do
- 2FA Enabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
- = nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
- = link_to admin_users_path(filter: 'two_factor_disabled') do
- 2FA Disabled
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
- = nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
- = link_to admin_users_path(filter: 'external') do
- External
- %small.badge.badge-pill= limited_counter_with_delimiter(User.external)
- = nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
- = link_to admin_users_path(filter: "blocked") do
- Blocked
- %small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
- = nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
- = link_to admin_users_path(filter: "wop") do
- Without projects
- %small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
+ - if @users.empty?
+ .nothing-here-block.border-top-0
+ = s_('AdminUsers|No users found')
+ - else
+ .table-holder
+ .thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
+ .table-section.section-40{ role: 'rowheader' }= _('Name')
+ .table-section.section-25{ role: 'rowheader' }= _('Created on')
+ .table-section.section-15{ role: 'rowheader' }= _('Last activity')
- %ul.flex-list.content-list
- - if @users.empty?
- %li
- .nothing-here-block No users found.
- - else
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
#delete-user-modal
-
diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml
index a74e052707f..c4178296e67 100644
--- a/app/views/admin/users/show.html.haml
+++ b/app/views/admin/users/show.html.haml
@@ -53,6 +53,8 @@
- else
Disabled
+ = render_if_exists 'admin/namespace_plan_info', namespace: @user.namespace
+
%li
%span.light External User:
%strong
@@ -117,6 +119,11 @@
%strong
= @user.sign_in_count
+ %li
+ %span.light= _("Highest role:")
+ %strong
+ = Gitlab::Access.human_access_with_none(@user.highest_role)
+
- if @user.ldap_user?
%li
%span.light LDAP uid:
@@ -129,6 +136,8 @@
%strong
= link_to @user.created_by.name, [:admin, @user.created_by]
+ = render_if_exists partial: "namespaces/shared_runner_status", locals: { namespace: @user.namespace }
+
.col-md-6
- unless @user == current_user
- unless @user.confirmed?
@@ -141,6 +150,9 @@
%p This user has an unconfirmed email address#{email}. You may force a confirmation.
%br
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' }
+
+ = render_if_exists 'admin/users/user_detail_note'
+
- if @user.blocked?
.card.border-info
.card-header.bg-info.text-white
diff --git a/app/views/award_emoji/_awards_block.html.haml b/app/views/award_emoji/_awards_block.html.haml
index a758a63dfb3..60ca7e4e267 100644
--- a/app/views/award_emoji/_awards_block.html.haml
+++ b/app/views/award_emoji/_awards_block.html.haml
@@ -3,7 +3,7 @@
- awards_sort(grouped_emojis).each do |emoji, awards|
%button.btn.award-control.js-emoji-btn.has-tooltip{ type: "button",
class: [(award_state_class(awardable, awards, current_user))],
- data: { placement: "bottom", title: award_user_list(awards, current_user) } }
+ data: { title: award_user_list(awards, current_user) } }
= emoji_icon(emoji)
%span.award-control-text.js-counter
= awards.count
@@ -12,8 +12,8 @@
.award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': _('Add reaction'),
- data: { title: _('Add reaction'), placement: "bottom" } }
- %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
- %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
+ data: { title: _('Add reaction') } }
+ %span{ class: "award-control-icon award-control-icon-neutral" }= sprite_icon('slight-smile')
+ %span{ class: "award-control-icon award-control-icon-positive" }= sprite_icon('smiley')
+ %span{ class: "award-control-icon award-control-icon-super-positive" }= sprite_icon('smile')
= icon('spinner spin', class: "award-control-icon award-control-icon-loading")
diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml
index 9de9143e8b1..369b0f7e62c 100644
--- a/app/views/ci/status/_dropdown_graph_badge.html.haml
+++ b/app/views/ci/status/_dropdown_graph_badge.html.haml
@@ -6,15 +6,15 @@
- tooltip = "#{subject.name} - #{status.status_tooltip}"
- if status.has_details?
- = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
+ = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item d-flex', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- else
- .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
+ .menu-item.mini-pipeline-graph-dropdown-item.d-flex{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } }
%span{ class: klass }= sprite_icon(status.icon)
- %span.ci-build-text= subject.name
+ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1= subject.name
- if status.has_action?
- = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
+ = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do
= sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}")
diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml
new file mode 100644
index 00000000000..f38bdb2e5ed
--- /dev/null
+++ b/app/views/ci/status/_icon.html.haml
@@ -0,0 +1,16 @@
+- status = local_assigns.fetch(:status)
+- size = local_assigns.fetch(:size, 16)
+- type = local_assigns.fetch(:type, 'pipeline')
+- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left")
+- path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil)
+- css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip"
+- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label}
+- if type == 'commit'
+ - title = s_("PipelineStatusTooltip|Commit: %{ci_status}") % {ci_status: status.label}
+
+- if path
+ = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do
+ = sprite_icon(status.icon, size: size)
+- else
+ %span{ class: css_classes, title: title, data: { placement: tooltip_placement } }
+ = sprite_icon(status.icon, size: size)
diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml
index d355e7799df..d07cbe4589c 100644
--- a/app/views/ci/variables/_content.html.haml
+++ b/app/views/ci/variables/_content.html.haml
@@ -1 +1,3 @@
-= _('Variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. You can use variables for passwords, secret keys, or whatever you want.')
+= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they will be masked by default so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.')
+= _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe
+= link_to _('More information'), help_page_path('ci/variables/README', anchor: 'variables')
diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml
new file mode 100644
index 00000000000..dbfa0a9e5a1
--- /dev/null
+++ b/app/views/ci/variables/_header.html.haml
@@ -0,0 +1,11 @@
+- expanded = local_assigns.fetch(:expanded)
+
+%h4
+ = _('Variables')
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
+
+%button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+
+%p.append-bottom-0
+ = render "ci/variables/content"
diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml
index f34305e94fa..464b9faf282 100644
--- a/app/views/ci/variables/_index.html.haml
+++ b/app/views/ci/variables/_index.html.haml
@@ -1,10 +1,16 @@
- save_endpoint = local_assigns.fetch(:save_endpoint, nil)
+- if ci_variable_protected_by_default?
+ %p.settings-message.text-center
+ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path('ci/variables/README', anchor: 'protected-variables') }
+ = s_('Environment variables are configured by your administrator to be %{link_start}protected%{link_end} by default').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
+
.row
.col-lg-12.js-ci-variable-list-section{ data: { save_endpoint: save_endpoint } }
.hide.alert.alert-danger.js-ci-variable-error-box
%ul.ci-variable-list
+ = render 'ci/variables/variable_header'
- @variables.each.each do |variable|
= render 'ci/variables/variable_row', form_field: 'variables', variable: variable
= render 'ci/variables/variable_row', form_field: 'variables'
diff --git a/app/views/ci/variables/_variable_header.html.haml b/app/views/ci/variables/_variable_header.html.haml
new file mode 100644
index 00000000000..d3b7a5ae883
--- /dev/null
+++ b/app/views/ci/variables/_variable_header.html.haml
@@ -0,0 +1,16 @@
+- only_key_value = local_assigns.fetch(:only_key_value, false)
+
+%li.ci-variable-row.m-0.d-none.d-sm-block
+ .d-flex.w-100.align-items-center.pb-2
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Type')
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Key')
+ .bold.table-section.section-15.append-right-10
+ = s_('CiVariables|Value')
+ - unless only_key_value
+ .bold.table-section.section-20
+ = s_('CiVariables|State')
+ .bold.table-section.section-20
+ = s_('CiVariables|Masked')
+ = render_if_exists 'ci/variables/environment_scope_header'
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index 6ee55836dd2..ca2521e9bc6 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -3,34 +3,45 @@
- only_key_value = local_assigns.fetch(:only_key_value, false)
- id = variable&.id
+- variable_type = variable&.variable_type
- key = variable&.key
- value = variable&.value
-- is_protected = variable && !only_key_value ? variable.protected : false
+- is_protected_default = ci_variable_protected_by_default?
+- is_protected = ci_variable_protected?(variable, only_key_value)
+- is_masked_default = true
+- is_masked = ci_variable_masked?(variable, only_key_value)
- id_input_name = "#{form_field}[variables_attributes][][id]"
- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]"
+- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]"
- key_input_name = "#{form_field}[variables_attributes][][key]"
- value_input_name = "#{form_field}[variables_attributes][][secret_value]"
- protected_input_name = "#{form_field}[variables_attributes][][protected]"
+- masked_input_name = "#{form_field}[variables_attributes][][masked]"
%li.js-row.ci-variable-row{ data: { is_persisted: "#{!id.nil?}" } }
- .ci-variable-row-body
+ .ci-variable-row-body.border-bottom
%input.js-ci-variable-input-id{ type: "hidden", name: id_input_name, value: id }
%input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name }
- %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control{ type: "text",
+ %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name }
+ = options_for_select(ci_variable_type_options, variable_type)
+ %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text",
name: key_input_name,
value: key,
placeholder: s_('CiVariables|Input variable key') }
- .ci-variable-body-item
+ .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0
.form-control.js-secret-value-placeholder.qa-ci-variable-input-value{ class: ('hide' unless id) }
- = '*' * 20
+ = '*' * 17
%textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ class: ('hide' if id),
rows: 1,
name: value_input_name,
placeholder: s_('CiVariables|Input variable value') }
= value
+ %p.masking-validation-error.gl-field-error.hide
+ = s_("CiVariables|Cannot use Masked Variable with current value")
+ = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'masked-variables'), target: '_blank', rel: 'noopener noreferrer'
- unless only_key_value
- .ci-variable-body-item.ci-variable-protected-item
+ .ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
.append-right-default
= s_("CiVariable|Protected")
%button{ type: 'button',
@@ -39,10 +50,25 @@
%input{ type: "hidden",
class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
name: protected_input_name,
- value: is_protected }
+ value: is_protected,
+ data: { default: is_protected_default.to_s } }
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+ .ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0
+ .append-right-default
+ = s_("CiVariable|Masked")
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}",
+ "aria-label": s_("CiVariable|Toggle masked") }
+ %input{ type: "hidden",
+ class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
+ name: masked_input_name,
+ value: is_masked,
+ data: { default: is_masked_default.to_s } }
%span.toggle-icon
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
- %button.js-row-remove-button.ci-variable-row-remove-button{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
- = icon('minus-circle')
+ %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
+ = icon('minus-circle')
diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml
index 7037c80aa6b..8005dcbf65f 100644
--- a/app/views/clusters/clusters/_advanced_settings.html.haml
+++ b/app/views/clusters/clusters/_advanced_settings.html.haml
@@ -1,5 +1,5 @@
- if can?(current_user, :admin_cluster, @cluster)
- - if @cluster.managed?
+ - unless @cluster.provided_by_user?
.append-bottom-20
%label.append-bottom-10
= s_('ClusterIntegration|Google Kubernetes Engine')
diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml
index 160c5f009a7..a5de67be96b 100644
--- a/app/views/clusters/clusters/_banner.html.haml
+++ b/app/views/clusters/clusters/_banner.html.haml
@@ -5,5 +5,17 @@
.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' }
= s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...')
+.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
+ .col-11
+ = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
+ .col-1.p-0
+ %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
+
+.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
+ .col-11
+ = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
+ .col-1.p-0
+ %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
+
.hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' }
= s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details")
diff --git a/app/views/clusters/clusters/_buttons.html.haml b/app/views/clusters/clusters/_buttons.html.haml
index db2e247e341..c81d1d5b05a 100644
--- a/app/views/clusters/clusters/_buttons.html.haml
+++ b/app/views/clusters/clusters/_buttons.html.haml
@@ -1,4 +1,6 @@
--# This partial is overridden in EE
.nav-controls
- %span.btn.btn-add-cluster.disabled.js-add-cluster
- = s_("ClusterIntegration|Add Kubernetes cluster")
+ - if clusterable.can_add_cluster?
+ = link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success js-add-cluster'
+ - else
+ %span.btn.btn-add-cluster.disabled.js-add-cluster
+ = s_("ClusterIntegration|Add Kubernetes cluster")
diff --git a/app/views/clusters/clusters/_cluster.html.haml b/app/views/clusters/clusters/_cluster.html.haml
index e15de3d504d..b89789e9915 100644
--- a/app/views/clusters/clusters/_cluster.html.haml
+++ b/app/views/clusters/clusters/_cluster.html.haml
@@ -3,7 +3,7 @@
.table-section.section-60
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Kubernetes cluster")
.table-mobile-content
- = link_to cluster.name, cluster.show_path
+ = cluster.item_link(clusterable)
- unless cluster.enabled?
%span.badge.badge-danger Connection disabled
.table-section.section-25
diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml
index c926ec258f0..cfdbfe2dea1 100644
--- a/app/views/clusters/clusters/_empty_state.html.haml
+++ b/app/views/clusters/clusters/_empty_state.html.haml
@@ -9,6 +9,6 @@
= clusterable.empty_state_help_text
= clusterable.learn_more_link
- - if clusterable.can_create_cluster?
+ - if clusterable.can_add_cluster?
.text-center
= link_to s_('ClusterIntegration|Add Kubernetes cluster'), clusterable.new_path, class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
new file mode 100644
index 00000000000..455322b2089
--- /dev/null
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -0,0 +1,46 @@
+= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
+ = form_errors(@cluster)
+ .form-group
+ %h5= s_('ClusterIntegration|Integration status')
+ %label.append-bottom-0.js-cluster-enable-toggle-area
+ %button{ type: 'button',
+ class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
+ "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
+ disabled: !can?(current_user, :update_cluster, @cluster) }
+ = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
+ .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.')
+
+ .form-group
+ %h5= s_('ClusterIntegration|Environment scope')
+ - if has_multiple_clusters?
+ = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope')
+ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
+ - else
+ = text_field_tag :environment_scope, '*', class: 'col-md-6 form-control disabled', placeholder: s_('ClusterIntegration|Environment scope'), disabled: true
+ - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain')
+ - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url }
+ .form-text.text-muted
+ %code *
+ = s_("ClusterIntegration| is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe }
+
+ .form-group
+ %h5= s_('ClusterIntegration|Base domain')
+ = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus qa-base-domain'
+ .form-text.text-muted
+ - auto_devops_url = help_page_path('topics/autodevops/index')
+ - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
+ = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
+ %span{ :class => ["js-ingress-domain-help-text", ("hide" unless @cluster.application_ingress_external_ip.present?)] }
+ = s_('ClusterIntegration|Alternatively')
+ %code{ :class => "js-ingress-domain-snippet" } #{@cluster.application_ingress_external_ip}.nip.io
+ = s_('ClusterIntegration| can be used instead of a custom domain.')
+ - custom_domain_url = help_page_path('user/project/clusters/index', anchor: 'pointing-your-dns-at-the-external-endpoint')
+ - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url }
+ = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe }
+
+ - if can?(current_user, :update_cluster, @cluster)
+ .form-group
+ = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain'
diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
index 73b11d509d3..a9299af8d78 100644
--- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
+++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml
@@ -1,6 +1,6 @@
- link = link_to(s_('ClusterIntegration|sign up'), 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', target: '_blank', rel: 'noopener noreferrer')
-.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert' }
- %button.close{ type: "button", data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } } &times;
+.bs-callout.gcp-signup-offer.alert.alert-block.alert-dismissable.prepend-top-default.append-bottom-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::GCP_SIGNUP_OFFER, dismiss_endpoint: user_callouts_path } }
+ %button.close.js-close{ type: "button" } &times;
.gcp-signup-offer--content
.gcp-signup-offer--icon.append-right-8
= sprite_icon("information", size: 16)
@@ -8,5 +8,5 @@
%h4= s_('ClusterIntegration|Did you know?')
%p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link }
%a.btn.btn-default{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' }
- Apply for credit
+ = s_("ClusterIntegration|Apply for credit")
diff --git a/app/views/clusters/clusters/_integration_form.html.haml b/app/views/clusters/clusters/_integration_form.html.haml
deleted file mode 100644
index 5e451f60c9d..00000000000
--- a/app/views/clusters/clusters/_integration_form.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
- = form_errors(@cluster)
- .form-group
- %h5= s_('ClusterIntegration|Integration status')
- %label.append-bottom-0.js-cluster-enable-toggle-area
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
- "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
- disabled: !can?(current_user, :update_cluster, @cluster) }
- = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
- .form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.')
-
- - if has_multiple_clusters?
- .form-group
- %h5= s_('ClusterIntegration|Environment scope')
- = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope')
- .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
-
- - if can?(current_user, :update_cluster, @cluster)
- .form-group
- = field.submit _('Save changes'), class: 'btn btn-success'
-
- - unless has_multiple_clusters?
- %h5= s_('ClusterIntegration|Environment scope')
- %p
- %code *
- is the default environment scope for this cluster. This means that all jobs, regardless of their environment, will use this cluster.
- = link_to 'More information', ('https://docs.gitlab.com/ee/user/project/clusters/#setting-the-environment-scope')
diff --git a/app/views/clusters/clusters/_sidebar.html.haml b/app/views/clusters/clusters/_sidebar.html.haml
index 6e4415c21a9..60ccad5b943 100644
--- a/app/views/clusters/clusters/_sidebar.html.haml
+++ b/app/views/clusters/clusters/_sidebar.html.haml
@@ -4,3 +4,5 @@
= clusterable.sidebar_text
%p
= clusterable.learn_more_link
+
+= render_if_exists 'clusters/multiple_clusters_message'
diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml
index 8ed4666e79a..70e2eaeaf3b 100644
--- a/app/views/clusters/clusters/gcp/_form.html.haml
+++ b/app/views/clusters/clusters/gcp/_form.html.haml
@@ -7,23 +7,27 @@
- help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon }
%p
- - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
- = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page}
+ - link_to_help_page = link_to(s_('ClusterIntegration|help page'),
+ help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer')
+ = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page }
%p= link_to('Select a different Google account', @authorize_url)
-= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
- = form_errors(@gcp_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', readonly: !has_multiple_clusters?, placeholder: s_('ClusterIntegration|Environment scope')
+= bootstrap_form_for @gcp_cluster, html: { class: 'gl-show-field-errors js-gke-cluster-creation prepend-top-20',
+ data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
+ - if has_multiple_clusters?
+ = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'),
+ class: 'label-bold' } do
+ = field.text_field :environment_scope, required: true, class: 'form-control',
+ title: 'Environment scope is required.', wrapper: false
+ .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field|
.form-group
- = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-bold'
+ = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'),
+ class: 'label-bold'
.js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } }
= provider_gcp_field.hidden_field :gcp_project_id
.dropdown
@@ -45,9 +49,9 @@
%p.form-text.text-muted
= s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end }
- .form-group
- = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-bold'
- = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3'
+ = provider_gcp_field.number_field :num_nodes, required: true, placeholder: '3',
+ title: s_('ClusterIntegration|Number of nodes must be a numerical value.'),
+ label: s_('ClusterIntegration|Number of nodes'), label_class: 'label-bold'
.form-group
= provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-bold'
@@ -62,13 +66,21 @@
= s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end }
.form-group
- .form-check
- = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true
- = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+ = provider_gcp_field.check_box :legacy_abac, { label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold' }, false, true
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
- .form-group
- = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
+ .form-group
+ = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Create Kubernetes cluster'),
+ class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true
diff --git a/app/views/clusters/clusters/gcp/_show.html.haml b/app/views/clusters/clusters/gcp/_show.html.haml
deleted file mode 100644
index e9f05eaf453..00000000000
--- a/app/views/clusters/clusters/gcp/_show.html.haml
+++ /dev/null
@@ -1,50 +0,0 @@
-.form-group
- %label.append-bottom-10{ for: 'cluster-name' }
- = s_('ClusterIntegration|Kubernetes cluster name')
- .input-group
- %input.form-control.cluster-name.js-select-on-focus{ value: @cluster.name, readonly: true }
- %span.input-group-append
- = clipboard_button(text: @cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default')
-
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
- = form_errors(@cluster)
-
- = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
- .form-group
- = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL')
- .input-group
- = platform_kubernetes_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: true
- %span.input-group-append
- = clipboard_button(text: @cluster.platform_kubernetes.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default')
-
- .form-group
- = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate')
- .input-group
- = platform_kubernetes_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: true
- %span.input-group-append.clipboard-addon
- = clipboard_button(text: @cluster.platform_kubernetes.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank')
-
- .form-group
- = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token')
- .input-group
- = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: true
- %span.input-group-append
- %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' }
- = s_('ClusterIntegration|Show')
- = clipboard_button(text: @cluster.platform_kubernetes.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default')
-
- - if @cluster.allow_user_defined_namespace?
- .form-group
- = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)')
- = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
-
- .form-group
- .form-check
- = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac'
- = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
-
- .form-group
- = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml
index ad6d1d856d6..9bab3bf56aa 100644
--- a/app/views/clusters/clusters/index.html.haml
+++ b/app/views/clusters/clusters/index.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title 'Kubernetes'
-- page_title "Kubernetes Clusters"
+- breadcrumb_title _('Kubernetes')
+- page_title _('Kubernetes Clusters')
= render_gcp_signup_offer
@@ -9,8 +9,15 @@
- else
.top-area.adjust
.nav-text
- = s_("ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project")
+ = s_('ClusterIntegration|Kubernetes clusters can be used to deploy applications and to provide Review Apps for this project')
= render 'clusters/clusters/buttons'
+
+ - if @has_ancestor_clusters
+ .bs-callout.bs-callout-info
+ = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.')
+ %strong
+ = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence')
+
.clusters-table.js-clusters-list
.gl-responsive-table-row.table-row-header{ role: "row" }
.table-section.section-60{ role: "rowheader" }
diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml
index eeeef6bd824..6a8af23e5e8 100644
--- a/app/views/clusters/clusters/new.html.haml
+++ b/app/views/clusters/clusters/new.html.haml
@@ -1,5 +1,5 @@
-- breadcrumb_title 'Kubernetes'
-- page_title _("Kubernetes Cluster")
+- breadcrumb_title _('Kubernetes')
+- page_title _('Kubernetes Cluster')
- active_tab = local_assigns.fetch(:active_tab, 'gcp')
= javascript_include_tag 'https://apis.google.com/js/api.js'
diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml
index b1aa8e5d477..4dfbb310142 100644
--- a/app/views/clusters/clusters/show.html.haml
+++ b/app/views/clusters/clusters/show.html.haml
@@ -1,10 +1,10 @@
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs "Kubernetes Clusters", clusterable.index_path
+- add_to_breadcrumbs _('Kubernetes Clusters'), clusterable.index_path
- breadcrumb_title @cluster.name
-- page_title _("Kubernetes Cluster")
+- page_title _('Kubernetes Cluster')
- manage_prometheus_path = edit_project_service_path(@cluster.project, 'prometheus') if @project
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- status_path = clusterable.cluster_status_cluster_path(@cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster)
.edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path,
@@ -15,14 +15,17 @@
install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner),
install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter),
install_knative_path: clusterable.install_applications_cluster_path(@cluster, :knative),
+ update_knative_path: clusterable.update_applications_cluster_path(@cluster, :knative),
toggle_status: @cluster.enabled? ? 'true': 'false',
+ has_rbac: has_rbac_enabled?(@cluster) ? 'true': 'false',
cluster_type: @cluster.cluster_type,
cluster_status: @cluster.status_name,
cluster_status_reason: @cluster.status_reason,
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
- ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-ip-address'),
- ingress_dns_help_path: help_page_path('topics/autodevops/quick_start_guide.md', anchor: 'point-dns-at-cluster-ip'),
- manage_prometheus_path: manage_prometheus_path } }
+ ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
+ ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
+ manage_prometheus_path: manage_prometheus_path,
+ cluster_id: @cluster.id } }
.js-cluster-application-notice
.flash-container
@@ -30,7 +33,9 @@
%section#cluster-integration
%h4= @cluster.name
= render 'banner'
- = render 'integration_form'
+ = render 'form'
+
+ = render_if_exists 'projects/clusters/prometheus_graphs'
.cluster-applications-table#js-cluster-applications
@@ -38,19 +43,16 @@
.settings-header
%h4= s_('ClusterIntegration|Kubernetes cluster details')
%button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
+ = expanded ? _('Collapse') : _('Expand')
%p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
.settings-content
- - if @cluster.managed?
- = render 'clusters/clusters/gcp/show'
- - else
- = render 'clusters/clusters/user/show'
+ = render 'clusters/platforms/kubernetes/form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster)
%section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
%h4= _('Advanced settings')
%button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
+ = expanded ? _('Collapse') : _('Expand')
%p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
- .settings-content
+ .settings-content#advanced-settings-section
= render 'advanced_settings'
diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml
index 9793c77fc2b..f2fc5ac93fb 100644
--- a/app/views/clusters/clusters/user/_form.html.haml
+++ b/app/views/clusters/clusters/user/_form.html.haml
@@ -1,39 +1,55 @@
-= form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field|
- = form_errors(@user_cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
+- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'adding-an-existing-kubernetes-cluster'), target: '_blank'
+- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md',
+ anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
+
+- api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.')
+- ca_cert_help_text = s_('ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster.')
+- token_help_text = s_('ClusterIntegration|A service token scoped to %{code}kube-system%{end_code} with %{code}cluster-admin%{end_code} privileges.').html_safe % { code: '<code>'.html_safe, end_code: '</code>'.html_safe }
+- rbac_help_text = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + ' '
+- rbac_help_text << s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+
+= bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' },
+ url: clusterable.create_user_clusters_path, as: :cluster do |field|
+ = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'),
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold'
- if has_multiple_clusters?
- .form-group
- = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold'
- = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope')
+ = field.text_field :environment_scope, required: true, title: 'Environment scope is required.',
+ label: s_('ClusterIntegration|Environment scope'), label_class: 'label-bold',
+ help: s_("ClusterIntegration|Choose which of your environments will use this cluster.")
= field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field|
- .form-group
- = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold'
- = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
+ = platform_kubernetes_field.url_field :api_url, required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: api_url_help_text, help_link: more_info_link }
- .form-group
- = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold'
- = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
+ = platform_kubernetes_field.text_area :ca_cert,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: ca_cert_help_text, help_link: more_info_link }
- .form-group
- = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold'
- = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off'
+ = platform_kubernetes_field.text_field :token, required: true,
+ title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'),
+ autocomplete: 'off', label_class: 'label-bold',
+ help: '%{help_text} %{help_link}'.html_safe % { help_text: token_help_text, help_link: more_info_link }
- if @user_cluster.allow_user_defined_namespace?
- .form-group
- = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold'
- = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
-
- .form-group
- .form-check
- = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac'
- = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
- = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank'
-
- .form-group
- = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
+ = platform_kubernetes_field.text_field :namespace,
+ label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold'
+
+ = platform_kubernetes_field.form_group :authorization_type,
+ { help: '%{help_text} %{help_link}'.html_safe % { help_text: rbac_help_text, help_link: rbac_help_link } } do
+ = platform_kubernetes_field.check_box :authorization_type,
+ { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+
+ .form-group
+ = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success'
diff --git a/app/views/clusters/clusters/user/_show.html.haml b/app/views/clusters/clusters/user/_show.html.haml
deleted file mode 100644
index cac8e72edd3..00000000000
--- a/app/views/clusters/clusters/user/_show.html.haml
+++ /dev/null
@@ -1,39 +0,0 @@
-= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster do |field|
- = form_errors(@cluster)
- .form-group
- = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold'
- = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name')
-
- = field.fields_for :platform_kubernetes, @cluster.platform_kubernetes do |platform_kubernetes_field|
- .form-group
- = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold'
- = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL')
-
- .form-group
- = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold'
- = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)')
-
- .form-group
- = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold'
- .input-group
- = platform_kubernetes_field.text_field :token, class: 'form-control js-cluster-token', type: 'password', placeholder: s_('ClusterIntegration|Token'), autocomplete: 'off'
- %span.input-group-append.clipboard-addon
- .input-group-text
- %button.js-show-cluster-token.btn-blank{ type: 'button' }
- = s_('ClusterIntegration|Show')
-
- - if @cluster.allow_user_defined_namespace?
- .form-group
- = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold'
- = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace')
-
- .form-group
- .form-check
- = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac'
- = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold'
- .form-text.text-muted
- = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
- = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
-
- .form-group
- = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml
new file mode 100644
index 00000000000..c1727cf9079
--- /dev/null
+++ b/app/views/clusters/platforms/kubernetes/_form.html.haml
@@ -0,0 +1,58 @@
+= bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'gl-show-field-errors' },
+ as: :cluster do |field|
+ - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true,
+ title: s_('ClusterIntegration|Cluster name is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_name_btn
+
+ = field.fields_for :platform_kubernetes, platform do |platform_field|
+ - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true,
+ title: s_('ClusterIntegration|API URL should be a valid http/https url.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|API URL'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_api_url
+
+ - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+ = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '5',
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'),
+ label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn
+
+ - show_token_btn = (platform_field.button s_('ClusterIntegration|Show'),
+ type: 'button', class: 'js-show-cluster-token btn btn-default')
+ - copy_token_btn = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Service Token'),
+ class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields?
+
+ = platform_field.text_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token',
+ required: true, title: s_('ClusterIntegration|Service token is required.'),
+ readonly: cluster.read_only_kubernetes_platform_fields?,
+ label: s_('ClusterIntegration|Service Token'), label_class: 'label-bold',
+ input_group_class: 'gl-field-error-anchor', append: show_token_btn + copy_token_btn
+
+ - if cluster.allow_user_defined_namespace?
+ = platform_field.text_field :namespace, label: s_('ClusterIntegration|Project namespace (optional, unique)'),
+ label_class: 'label-bold'
+
+ = platform_field.form_group :authorization_type do
+ = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'),
+ label_class: 'label-bold', inline: true }, 'rbac', 'abac'
+ .form-text.text-muted
+ = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).')
+ = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.')
+
+ .form-group
+ = field.check_box :managed, { disabled: true, label: s_('ClusterIntegration|GitLab-managed cluster'),
+ label_class: 'label-bold' }
+ .form-text.text-muted
+ = s_('ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster.')
+ = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), target: '_blank'
+
+ .form-group
+ = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success'
diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml
index 9c246e19faa..4359a2c3c2b 100644
--- a/app/views/dashboard/_activities.html.haml
+++ b/app/views/dashboard/_activities.html.haml
@@ -1,7 +1,7 @@
.nav-block.activities
= render 'shared/event_filter'
.controls
- = link_to dashboard_projects_path(rss_url_options), class: 'btn rss-btn has-tooltip', title: 'Subscribe' do
+ = link_to dashboard_projects_path(rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip', title: 'Subscribe' do
%i.fa.fa-rss
.content_list
diff --git a/app/views/dashboard/_activity_head.html.haml b/app/views/dashboard/_activity_head.html.haml
index ec1a3fef435..3f39555a1d4 100644
--- a/app/views/dashboard/_activity_head.html.haml
+++ b/app/views/dashboard/_activity_head.html.haml
@@ -1,4 +1,4 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Activity')
.top-area
diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml
index 8ab5dc37f34..b2fadb77418 100644
--- a/app/views/dashboard/_groups_head.html.haml
+++ b/app/views/dashboard/_groups_head.html.haml
@@ -1,4 +1,4 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Groups')
- if current_user.can_create_group?
diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml
index 1050945b15a..97a446dbeec 100644
--- a/app/views/dashboard/_projects_head.html.haml
+++ b/app/views/dashboard/_projects_head.html.haml
@@ -1,27 +1,35 @@
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
= content_for :flash_message do
= render 'shared/project_limit'
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Projects')
- if current_user.can_create_project?
.page-title-controls
- = link_to "New project", new_project_path, class: "btn btn-success"
+ = link_to _("New project"), new_project_path, class: "btn btn-success"
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs{ class: ('border-0' if feature_project_list_filter_bar) }
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
- Your projects
+ = _("Your projects")
+ %span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count)
= nav_link(page: starred_dashboard_projects_path) do
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do
- Starred projects
+ = _("Starred projects")
+ %span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count)
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do
- Explore projects
-
- .nav-controls
- = render 'shared/projects/search_form'
- = render 'shared/projects/dropdown'
+ = _("Explore projects")
+ - unless feature_project_list_filter_bar
+ .nav-controls
+ = render 'shared/projects/search_form'
+ = render 'shared/projects/dropdown'
+- if feature_project_list_filter_bar
+ .project-filters
+ = render 'shared/projects/search_bar', project_tab_filter: project_tab_filter
diff --git a/app/views/dashboard/_snippets_head.html.haml b/app/views/dashboard/_snippets_head.html.haml
index 8d99f84755a..34aca40d0d1 100644
--- a/app/views/dashboard/_snippets_head.html.haml
+++ b/app/views/dashboard/_snippets_head.html.haml
@@ -1,9 +1,9 @@
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Snippets')
- - if current_user
+ - if current_user && current_user.snippets.any? || @snippets.any?
.page-title-controls
- = link_to "New snippet", new_snippet_path, class: "btn btn-success", title: "New snippet"
+ = link_to _("New snippet"), new_snippet_path, class: "btn btn-success", title: _("New snippet")
.top-area
%ul.nav-links.nav.nav-tabs
diff --git a/app/views/dashboard/activity.html.haml b/app/views/dashboard/activity.html.haml
index 31d4b3da4f1..b1c192d7bad 100644
--- a/app/views/dashboard/activity.html.haml
+++ b/app/views/dashboard/activity.html.haml
@@ -4,6 +4,9 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
+
+= render_dashboard_gold_trial(current_user)
+
- page_title "Activity"
- header_title "Activity", activity_dashboard_path
diff --git a/app/views/dashboard/groups/_groups.html.haml b/app/views/dashboard/groups/_groups.html.haml
index db856ef7d7b..2f9dbf87d95 100644
--- a/app/views/dashboard/groups/_groups.html.haml
+++ b/app/views/dashboard/groups/_groups.html.haml
@@ -1,4 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: dashboard_groups_path(format: :json), path: dashboard_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/dashboard/groups/index.html.haml b/app/views/dashboard/groups/index.html.haml
index 50f39f93283..d1d8d970b59 100644
--- a/app/views/dashboard/groups/index.html.haml
+++ b/app/views/dashboard/groups/index.html.haml
@@ -1,6 +1,8 @@
- @hide_top_links = true
- page_title "Groups"
-- header_title "Groups", dashboard_groups_path
+- header_title "Groups", dashboard_groups_path
+
+= render_dashboard_gold_trial(current_user)
= render 'dashboard/groups_head'
- if params[:filter].blank? && @groups.empty?
diff --git a/app/views/dashboard/issues.html.haml b/app/views/dashboard/issues.html.haml
index fdd5c19d562..b3ee5034204 100644
--- a/app/views/dashboard/issues.html.haml
+++ b/app/views/dashboard/issues.html.haml
@@ -4,7 +4,9 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{current_user.name} issues")
-.page-title-holder
+= render_dashboard_gold_trial(current_user)
+
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Issues')
- if current_user
diff --git a/app/views/dashboard/merge_requests.html.haml b/app/views/dashboard/merge_requests.html.haml
index 77cfa1271df..3956f03a3c8 100644
--- a/app/views/dashboard/merge_requests.html.haml
+++ b/app/views/dashboard/merge_requests.html.haml
@@ -2,7 +2,9 @@
- page_title _("Merge Requests")
- @breadcrumb_link = merge_requests_dashboard_path(assignee_username: current_user.username)
-.page-title-holder
+= render_dashboard_gold_trial(current_user)
+
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Merge Requests')
- if current_user
diff --git a/app/views/dashboard/milestones/_milestone.html.haml b/app/views/dashboard/milestones/_milestone.html.haml
index b876d6fd1f3..89212eb6bf9 100644
--- a/app/views/dashboard/milestones/_milestone.html.haml
+++ b/app/views/dashboard/milestones/_milestone.html.haml
@@ -1,5 +1,5 @@
= render 'shared/milestones/milestone',
- milestone_path: group_or_dashboard_milestone_path(milestone),
+ milestone_path: group_or_project_milestone_path(milestone),
issues_path: issues_dashboard_path(milestone_title: milestone.title),
merge_requests_path: merge_requests_dashboard_path(milestone_title: milestone.title),
milestone: milestone,
diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml
index ae0e38bf0ee..37ba2143eba 100644
--- a/app/views/dashboard/milestones/index.html.haml
+++ b/app/views/dashboard/milestones/index.html.haml
@@ -2,7 +2,7 @@
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Milestones')
- if current_user
@@ -13,6 +13,8 @@
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
+ .nav-controls
+ = render 'shared/milestones/search_form'
.milestones
%ul.content-list
diff --git a/app/views/dashboard/projects/_nav.html.haml b/app/views/dashboard/projects/_nav.html.haml
index da3cf5807b0..f9b61bf1f3e 100644
--- a/app/views/dashboard/projects/_nav.html.haml
+++ b/app/views/dashboard/projects/_nav.html.haml
@@ -1,6 +1,21 @@
-.nav-block
- %ul.nav-links.mobile-separator.nav.nav-tabs
- = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
- = link_to s_('DashboardProjects|All'), dashboard_projects_path
- = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
- = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true)
+- inactive_class = 'btn p-2'
+- active_class = 'btn p-2 active'
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- is_explore_trending = project_tab_filter == :explore_trending
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
+.nav-block{ class: ("w-100" if feature_project_list_filter_bar) }
+ - if feature_project_list_filter_bar
+ .btn-group.button-filter-group.d-flex.m-0.p-0
+ - if project_tab_filter == :explore || is_explore_trending
+ = link_to s_('DashboardProjects|Trending'), trending_explore_projects_path, class: is_explore_trending ? active_class : inactive_class
+ = link_to s_('DashboardProjects|All'), explore_projects_path, class: is_explore_trending ? inactive_class : active_class
+ - else
+ = link_to s_('DashboardProjects|All'), dashboard_projects_path, class: params[:personal].present? ? inactive_class : active_class
+ = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true), class: params[:personal].present? ? active_class : inactive_class
+ - else
+ %ul.nav-links.mobile-separator.nav.nav-tabs
+ = nav_link(html_options: { class: ("active" unless params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|All'), dashboard_projects_path
+ = nav_link(html_options: { class: ("active" if params[:personal].present?) }) do
+ = link_to s_('DashboardProjects|Personal'), filter_projects_path(personal: true)
diff --git a/app/views/dashboard/projects/_starred_empty_state.html.haml b/app/views/dashboard/projects/_starred_empty_state.html.haml
new file mode 100644
index 00000000000..bea27f1a456
--- /dev/null
+++ b/app/views/dashboard/projects/_starred_empty_state.html.haml
@@ -0,0 +1,9 @@
+.row.empty-state
+ .col-12
+ .svg-content.svg-250
+ = image_tag 'illustrations/starred_empty.svg'
+ .text-content
+ %h4.text-center
+ = s_("StarredProjectsEmptyState|You don't have starred projects yet.")
+ %p.text-secondary
+ = s_("StarredProjectsEmptyState|Visit a project page and press on a star icon. Then, you can find the project on this page.")
diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
index 18a82feb189..8933c5d7227 100644
--- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml
+++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml
@@ -1,4 +1,4 @@
-.blank-state-parent-container
+.blank-state-parent-container{ class: ('has-start-trial-container' if has_start_trial?) }
.section-container.section-welcome{ class: "#{ 'section-admin-welcome' if current_user.admin? }" }
.container.section-body
.row
@@ -7,7 +7,12 @@
Welcome to GitLab
%p.blank-state-text
Code, test, and deploy together
- - if current_user.admin?
- = render "blank_state_admin_welcome"
- - else
- = render "blank_state_welcome"
+ .blank-state-row
+ %div{ class: ('column-large' if has_start_trial?) }
+ - if current_user.admin?
+ = render "blank_state_admin_welcome"
+ - else
+ = render "blank_state_welcome"
+ - if has_start_trial?
+ .column-small
+ = render_if_exists "blank_state_ee_trial"
diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml
index deed774a4a5..0298f539b4b 100644
--- a/app/views/dashboard/projects/index.html.haml
+++ b/app/views/dashboard/projects/index.html.haml
@@ -4,6 +4,8 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity")
+= render_dashboard_gold_trial(current_user)
+
- page_title "Projects"
- header_title "Projects", dashboard_projects_path
@@ -11,7 +13,7 @@
= render "projects/last_push"
- if show_projects?(@projects, params)
= render 'dashboard/projects_head'
- = render 'nav'
+ = render 'nav' unless Feature.enabled?(:project_list_filter_bar)
= render 'projects'
- else
= render "zero_authorized_projects"
diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml
index 8933d9e31ff..0fcc6894b68 100644
--- a/app/views/dashboard/projects/starred.html.haml
+++ b/app/views/dashboard/projects/starred.html.haml
@@ -1,15 +1,16 @@
- @hide_top_links = true
- @no_container = true
-- breadcrumb_title "Projects"
-- page_title "Starred Projects"
-- header_title "Projects", dashboard_projects_path
+- breadcrumb_title _("Projects")
+- page_title _("Starred Projects")
+- header_title _("Projects"), dashboard_projects_path
+
+= render_dashboard_gold_trial(current_user)
%div{ class: container_class }
= render "projects/last_push"
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :starred
- if params[:filter_projects] || any_projects?(@projects)
= render 'projects'
- else
- %h3.page-title You don't have starred projects yet
- %p.slead Visit project page and press on star icon and it will appear on this page.
+ = render 'starred_empty_state'
diff --git a/app/views/dashboard/snippets/index.html.haml b/app/views/dashboard/snippets/index.html.haml
index 6eb067da95c..b649fe91c24 100644
--- a/app/views/dashboard/snippets/index.html.haml
+++ b/app/views/dashboard/snippets/index.html.haml
@@ -3,6 +3,14 @@
- header_title "Snippets", dashboard_snippets_path
= render 'dashboard/snippets_head'
-= render partial: 'snippets/snippets_scope_menu', locals: { include_private: true }
+- if current_user.snippets.exists?
+ = render partial: 'snippets/snippets_scope_menu', locals: { include_private: true }
-= render partial: 'snippets/snippets', locals: { link_project: true }
+.d-block.d-sm-none
+ &nbsp;
+ = link_to _("New snippet"), new_snippet_path, class: "btn btn-success btn-block", title: _("New snippet")
+
+- if current_user.snippets.exists?
+ = render partial: 'shared/snippets/list', locals: { link_project: true }
+- else
+ = render 'shared/empty_states/snippets', button_path: new_snippet_path
diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml
index efe1fb99efc..db6e40a6fd0 100644
--- a/app/views/dashboard/todos/_todo.html.haml
+++ b/app/views/dashboard/todos/_todo.html.haml
@@ -34,7 +34,7 @@
= todo_due_date(todo)
.todo-body
- .todo-note
+ .todo-note.break-word
.md
= first_line_in_markdown(todo, :body, 150, project: todo.project)
diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml
index d2593179f17..214630d245a 100644
--- a/app/views/dashboard/todos/index.html.haml
+++ b/app/views/dashboard/todos/index.html.haml
@@ -2,7 +2,9 @@
- page_title "Todos"
- header_title "Todos", dashboard_todos_path
-.page-title-holder
+= render_dashboard_gold_trial(current_user)
+
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('Todos')
- if current_user.todos.any?
diff --git a/app/views/devise/confirmations/new.html.haml b/app/views/devise/confirmations/new.html.haml
index 73e70dc63e5..f8aa3cf98dc 100644
--- a/app/views/devise/confirmations/new.html.haml
+++ b/app/views/devise/confirmations/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group
= f.label :email
= f.email_field :email, class: "form-control", required: true, title: 'Please provide a valid email address.'
diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
index 3d0a1f622a5..ccc3e734276 100644
--- a/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
+++ b/app/views/devise/mailer/_confirmation_instructions_secondary.html.haml
@@ -1,5 +1,5 @@
#content
- = email_default_heading("#{@resource.user.name}, you've added an additional email!")
+ = email_default_heading("#{sanitize_name(@resource.user.name)}, you've added an additional email!")
%p Click the link below to confirm your email address (#{@resource.email})
#cta
= link_to 'Confirm your email address', confirmation_url(@resource, confirmation_token: @token)
diff --git a/app/views/devise/mailer/email_changed.html.haml b/app/views/devise/mailer/email_changed.html.haml
index 5398430fdfd..3689e9c5f61 100644
--- a/app/views/devise/mailer/email_changed.html.haml
+++ b/app/views/devise/mailer/email_changed.html.haml
@@ -2,7 +2,7 @@
- if @resource.try(:unconfirmed_email?)
%p
- We're contacting you to notify you that your email is being changed to #{@resource.reload.unconfirmed_email}.
+ We're contacting you to notify you that your email is being changed to #{@resource.reset.unconfirmed_email}.
- else
%p
We're contacting you to notify you that your email has been changed to #{@resource.email}.
diff --git a/app/views/devise/mailer/email_changed.text.erb b/app/views/devise/mailer/email_changed.text.erb
index 18137389e7b..69155db7246 100644
--- a/app/views/devise/mailer/email_changed.text.erb
+++ b/app/views/devise/mailer/email_changed.text.erb
@@ -1,7 +1,7 @@
Hello, <%= @resource.name %>!
<% if @resource.try(:unconfirmed_email?) %>
-We're contacting you to notify you that your email is being changed to <%= @resource.reload.unconfirmed_email %>.
+We're contacting you to notify you that your email is being changed to <%= @resource.reset.unconfirmed_email %>.
<% else %>
We're contacting you to notify you that your email has been changed to <%= @resource.email %>.
<% end %>
diff --git a/app/views/devise/passwords/edit.html.haml b/app/views/devise/passwords/edit.html.haml
index 4b8ad5acd5b..09ea7716a47 100644
--- a/app/views/devise/passwords/edit.html.haml
+++ b/app/views/devise/passwords/edit.html.haml
@@ -3,11 +3,11 @@
.login-body
= form_for(resource, as: resource_name, url: password_path(:user), html: { method: :put, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
= f.hidden_field :reset_password_token
.form-group
= f.label 'New password', for: "user_password"
- = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required'
+ = f.password_field :password, class: "form-control top qa-password-field", required: true, title: 'This field is required'
.form-group
= f.label 'Confirm new password', for: "user_password_confirmation"
= f.password_field :password_confirmation, class: "form-control bottom qa-password-confirmation", title: 'This field is required', required: true
diff --git a/app/views/devise/passwords/new.html.haml b/app/views/devise/passwords/new.html.haml
index 99ce13adf74..fe999851605 100644
--- a/app/views/devise/passwords/new.html.haml
+++ b/app/views/devise/passwords/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group
= f.label :email
= f.email_field :email, class: "form-control", required: true, value: params[:user_email], autofocus: true, title: 'Please provide a valid email address.'
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb
index f379e71ae5b..5a1388ac7a1 100644
--- a/app/views/devise/registrations/edit.html.erb
+++ b/app/views/devise/registrations/edit.html.erb
@@ -1,7 +1,7 @@
<h2>Edit <%= resource_name.to_s.humanize %></h2>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
- <%= devise_error_messages! %>
+ <%= render "devise/shared/error_messages", resource: resource %>
<div><%= f.label :email %><br />
<%= f.email_field :email %></div>
diff --git a/app/views/devise/sessions/_new_ldap.html.haml b/app/views/devise/sessions/_new_ldap.html.haml
index 796c0cadda8..f856773526d 100644
--- a/app/views/devise/sessions/_new_ldap.html.haml
+++ b/app/views/devise/sessions/_new_ldap.html.haml
@@ -1,3 +1,5 @@
+- server = local_assigns.fetch(:server)
+
= form_tag(omniauth_callback_path(:user, server['provider_name']), id: 'new_ldap_user', class: "gl-show-field-errors") do
.form-group
= label_tag :username, "#{server['label']} Username"
diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml
index 34d4293bd45..30ed7ed6b29 100644
--- a/app/views/devise/sessions/new.html.haml
+++ b/app/views/devise/sessions/new.html.haml
@@ -1,6 +1,6 @@
- page_title "Sign in"
-%div
+#signin-container
- if form_based_providers.any?
= render 'devise/shared/tabs_ldap'
- else
diff --git a/app/views/devise/sessions/two_factor.html.haml b/app/views/devise/sessions/two_factor.html.haml
index fefdf5f9531..f49cdfbf8da 100644
--- a/app/views/devise/sessions/two_factor.html.haml
+++ b/app/views/devise/sessions/two_factor.html.haml
@@ -7,7 +7,7 @@
- resource_params = params[resource_name].presence || params
= f.hidden_field :remember_me, value: resource_params.fetch(:remember_me, 0)
%div
- = f.label 'Two-Factor Authentication code', name: :otp_attempt
+ = f.label 'Two-Factor Authentication code', name: :otp_attempt
= f.text_field :otp_attempt, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: 'This field is required.'
%p.form-text.text-muted.hint Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.
.prepend-top-20
diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml
index 12271ee5adb..1b583ea85d6 100644
--- a/app/views/devise/shared/_omniauth_box.html.haml
+++ b/app/views/devise/shared/_omniauth_box.html.haml
@@ -5,7 +5,7 @@
.d-flex.justify-content-between.flex-wrap
- providers.each do |provider|
- has_icon = provider_has_icon?(provider)
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'btn d-flex align-items-center omniauth-btn text-left oauth-login qa-saml-login-button', id: "oauth-login-#{provider}" do
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: "btn d-flex align-items-center omniauth-btn text-left oauth-login #{qa_class_for_provider(provider)}", id: "oauth-login-#{provider}" do
- if has_icon
= provider_image_tag(provider)
%span
diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml
index ec968e435cd..f8f36a8bfff 100644
--- a/app/views/devise/shared/_signin_box.html.haml
+++ b/app/views/devise/shared/_signin_box.html.haml
@@ -3,17 +3,21 @@
.login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) }
.login-body
= render 'devise/sessions/new_crowd'
+
+ = render_if_exists 'devise/sessions/new_kerberos_tab'
+
- @ldap_servers.each_with_index do |server, i|
.login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) }
.login-body
= render 'devise/sessions/new_ldap', server: server
+
+ = render_if_exists 'devise/sessions/new_smartcard'
+
- if password_authentication_enabled_for_web?
.login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
- = render_if_exists 'devise/sessions/new_smartcard'
-
- elsif password_authentication_enabled_for_web?
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml
index 004a3528d4b..383fd5130ce 100644
--- a/app/views/devise/shared/_signup_box.html.haml
+++ b/app/views/devise/shared/_signup_box.html.haml
@@ -2,26 +2,26 @@
.login-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors
- = devise_error_messages!
- .form-group
- = f.label :name, 'Full name', class: 'label-bold'
- = f.text_field :name, class: "form-control top qa-new-user-name", required: true, title: "This field is required."
+ = render "devise/shared/error_messages", resource: resource
+ .name.form-group
+ = f.label :name, _('Full name'), class: 'label-bold'
+ = f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
- = f.text_field :username, class: "form-control middle qa-new-user-username", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: 'Please create a username with only alphanumeric characters.'
- %p.validation-error.hide Username is already taken.
- %p.validation-success.hide Username is available.
- %p.validation-pending.hide Checking username availability...
+ = f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
+ %p.validation-error.hide= _('Username is already taken.')
+ %p.validation-success.hide= _('Username is available.')
+ %p.validation-pending.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
- = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: "Please provide a valid email address."
+ = f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: _("Please provide a valid email address.")
.form-group
= f.label :email_confirmation, class: 'label-bold'
- = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: "Please retype the email address."
+ = f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: _("Please retype the email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
- = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters."
- %p.gl-field-hint.text-secondary Minimum length is #{@minimum_password_length} characters
+ = f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
+ %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
- if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
.form-group
= check_box_tag :terms_opt_in, '1', false, required: true, class: 'qa-new-user-accept-terms'
@@ -29,8 +29,9 @@
- terms_link = link_to s_("I accept the|Terms of Service and Privacy Policy"), terms_path, target: "_blank"
- accept_terms_label = _("I accept the %{terms_link}") % { terms_link: terms_link }
= accept_terms_label.html_safe
+ = render_if_exists 'devise/shared/email_opted_in', f: f
%div
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
.submit-container
- = f.submit "Register", class: "btn-register btn qa-new-user-register-button"
+ = f.submit _("Register"), class: "btn-register btn qa-new-user-register-button"
diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml
index aee05b6c81c..b1a9470cf1c 100644
--- a/app/views/devise/shared/_tabs_ldap.html.haml
+++ b/app/views/devise/shared/_tabs_ldap.html.haml
@@ -2,6 +2,7 @@
- if crowd_enabled?
%li.nav-item
= link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab'
+ = render_if_exists "devise/shared/kerberos_tab"
- @ldap_servers.each_with_index do |server, i|
%li.nav-item
= link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))} qa-ldap-tab", 'data-toggle' => 'tab'
diff --git a/app/views/devise/unlocks/new.html.haml b/app/views/devise/unlocks/new.html.haml
index b2f48a4e0bf..1167f1718d6 100644
--- a/app/views/devise/unlocks/new.html.haml
+++ b/app/views/devise/unlocks/new.html.haml
@@ -3,7 +3,7 @@
.login-body
= form_for(resource, as: resource_name, url: unlock_path(resource_name), html: { method: :post, class: 'gl-show-field-errors' }) do |f|
.devise-errors
- = devise_error_messages!
+ = render "devise/shared/error_messages", resource: resource
.form-group.append-bottom-20
= f.label :email
= f.email_field :email, class: 'form-control', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', title: 'Please provide a valid email address.'
diff --git a/app/views/discussions/_diff_discussion.html.haml b/app/views/discussions/_diff_discussion.html.haml
index 6b8dd156874..5a47040874f 100644
--- a/app/views/discussions/_diff_discussion.html.haml
+++ b/app/views/discussions/_diff_discussion.html.haml
@@ -4,6 +4,6 @@
-# Text diff discussions
- expanded = local_assigns.fetch(:expanded, true)
%tr.notes_holder{ class: ('hide' unless expanded) }
- %td.notes_content{ colspan: 3 }
+ %td.notes-content{ colspan: 3 }
.content{ class: ('hide' unless expanded) }
= render partial: "discussions/notes", collection: discussions, as: :discussion, locals: { disable_collapse_class: true }
diff --git a/app/views/discussions/_diff_with_notes.html.haml b/app/views/discussions/_diff_with_notes.html.haml
index 44c898e0fac..8a3c841de0b 100644
--- a/app/views/discussions/_diff_with_notes.html.haml
+++ b/app/views/discussions/_diff_with_notes.html.haml
@@ -11,8 +11,8 @@
= render "projects/diffs/file_header", diff_file: diff_file, url: discussion_path(discussion), show_toggle: false
- if diff_file.text?
- .diff-content.code.js-syntax-highlight
- %table
+ .diff-content
+ %table.code.js-syntax-highlight
- if expanded
- discussions = { discussion.original_line_code => [discussion] }
= render partial: "projects/diffs/line",
diff --git a/app/views/discussions/_parallel_diff_discussion.html.haml b/app/views/discussions/_parallel_diff_discussion.html.haml
index 2e621c4082d..03b428714b9 100644
--- a/app/views/discussions/_parallel_diff_discussion.html.haml
+++ b/app/views/discussions/_parallel_diff_discussion.html.haml
@@ -1,17 +1,17 @@
- expanded = [*discussions_left, *discussions_right].any?(&:expanded?)
%tr.notes_holder{ class: ('hide' unless expanded) }
- if discussions_left
- %td.notes_content.parallel.old{ colspan: 2 }
+ %td.notes-content.parallel.old{ colspan: 2 }
.content{ class: ('hide' unless discussions_left.any?(&:expanded?)) }
= render partial: "discussions/notes", collection: discussions_left, as: :discussion, line_type: 'old', locals: { disable_collapse_class: true }
- else
- %td.notes_content.parallel.old{ colspan: 2 }
+ %td.notes-content.parallel.old{ colspan: 2 }
.content
- if discussions_right
- %td.notes_content.parallel.new{ colspan: 2 }
+ %td.notes-content.parallel.new{ colspan: 2 }
.content{ class: ('hide' unless discussions_right.any?(&:expanded?)) }
= render partial: "discussions/notes", collection: discussions_right, as: :discussion, line_type: 'new', locals: { disable_collapse_class: true }
- else
- %td.notes_content.parallel.new{ colspan: 2 }
+ %td.notes-content.parallel.new{ colspan: 2 }
.content
diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml
index 1f5c70a6c6e..5d85d9e431f 100644
--- a/app/views/doorkeeper/applications/index.html.haml
+++ b/app/views/doorkeeper/applications/index.html.haml
@@ -52,7 +52,7 @@
.oauth-authorized-applications.prepend-top-20.append-bottom-default
- if user_oauth_applications?
%h5
- = _("Authorized applications (%{size})") % { size: @authorized_tokens.size }
+ = _("Authorized applications (%{size})") % { size: @authorized_apps.size + @authorized_anonymous_tokens.size }
- if @authorized_tokens.any?
.table-responsive
diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml
index cac00f9c854..6750732ab67 100644
--- a/app/views/doorkeeper/applications/show.html.haml
+++ b/app/views/doorkeeper/applications/show.html.haml
@@ -14,7 +14,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
+ %input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID to clipboard"), class: "btn btn btn-default")
%tr
@@ -23,7 +23,7 @@
%td
.clipboard-group
.input-group
- %input.label.label-monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
+ %input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#secret', title: _("Copy secret to clipboard"), class: "btn btn btn-default")
%tr
diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml
index 74791b81ccd..dae9a7acf6b 100644
--- a/app/views/doorkeeper/authorizations/new.html.haml
+++ b/app/views/doorkeeper/authorizations/new.html.haml
@@ -31,7 +31,7 @@
%strong= t scope, scope: [:doorkeeper, :scopes]
.text-secondary= t scope, scope: [:doorkeeper, :scope_desc]
.form-actions.text-right
- = form_tag oauth_authorization_path, method: :delete, class: 'inline' do
+ = form_tag oauth_authorization_path, method: :delete, class: 'inline' do
= hidden_field_tag :client_id, @pre_auth.client.uid
= hidden_field_tag :redirect_uri, @pre_auth.redirect_uri
= hidden_field_tag :state, @pre_auth.state
diff --git a/app/views/email_rejection_mailer/rejection.html.haml b/app/views/email_rejection_mailer/rejection.html.haml
index c4ae7befe4e..666dab61f40 100644
--- a/app/views/email_rejection_mailer/rejection.html.haml
+++ b/app/views/email_rejection_mailer/rejection.html.haml
@@ -1,5 +1,5 @@
%p
- Unfortunately, your email message to GitLab could not be processed.
+ = _("Unfortunately, your email message to GitLab could not be processed.")
= markdown @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/email_rejection_mailer/rejection.text.haml b/app/views/email_rejection_mailer/rejection.text.haml
index 0e13b2a6473..8d940ef1293 100644
--- a/app/views/email_rejection_mailer/rejection.text.haml
+++ b/app/views/email_rejection_mailer/rejection.text.haml
@@ -1,4 +1,4 @@
-Unfortunately, your email message to GitLab could not be processed.
+= _("Unfortunately, your email message to GitLab could not be processed.")
\
= @reason
= render_if_exists 'shared/additional_email_text'
diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml
index 2fcb1d1fd2b..222175c818a 100644
--- a/app/views/events/_event.html.haml
+++ b/app/views/events/_event.html.haml
@@ -3,11 +3,11 @@
.event-item-timestamp
#{time_ago_with_tooltip(event.created_at)}
- - if event.created_project?
+ - if event.created_project_action?
= render "events/event/created_project", event: event
- - elsif event.push?
+ - elsif event.push_action?
= render "events/event/push", event: event
- - elsif event.commented?
+ - elsif event.commented_action?
= render "events/event/note", event: event
- else
= render "events/event/common", event: event
diff --git a/app/views/events/_events.html.haml b/app/views/events/_events.html.haml
index 68c19df092d..e1b7804c5a7 100644
--- a/app/views/events/_events.html.haml
+++ b/app/views/events/_events.html.haml
@@ -1 +1,18 @@
-= render partial: 'events/event', collection: @events
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
+- visitor_empty_message = _('No activities found')
+
+- if @events.present?
+ = render partial: 'events/event', collection: @events
+- else
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml
index 96d6553a2ac..b02fdb4b638 100644
--- a/app/views/events/event/_common.html.haml
+++ b/app/views/events/event/_common.html.haml
@@ -11,7 +11,8 @@
= link_to [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip event-target-link append-right-4', title: event.target_title do
= event.target.reference_link_text
- unless event.milestone?
- %span.event-target-title.append-right-4= "&quot;".html_safe + event.target.title + "&quot".html_safe
+ %span.event-target-title.append-right-4{ dir: "auto" }
+ = "&quot;".html_safe + event.target.title + "&quot".html_safe
- else
%span.event-type.d-inline-block.append-right-4{ class: event.action_name }
= event_action_name(event)
diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml
index fb0d2c3b8b0..7e2103287f7 100644
--- a/app/views/events/event/_note.html.haml
+++ b/app/views/events/event/_note.html.haml
@@ -7,7 +7,8 @@
%span.event-type.d-inline-block.append-right-4{ class: event.action_name }
= event.action_name
= event_note_title_html(event)
- %span.event-target-title.append-right-4= "&quot;".html_safe + event.target.title + "&quot".html_safe
+ %span.event-target-title.append-right-4{ dir: "auto" }
+ = "&quot;".html_safe + event.target.title + "&quot".html_safe
= render "events/event_scope", event: event
@@ -21,6 +22,6 @@
= link_to note.attachment.url, target: '_blank' do
= image_tag note.attachment.url, class: 'note-image-attach'
- else
- = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do
+ = link_to note.attachment.url, target: '_blank', class: 'note-file-attach' do
%i.fa.fa-paperclip
= note.attachment_identifier
diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml
index 69914fccc48..21c418cb0e4 100644
--- a/app/views/events/event/_push.html.haml
+++ b/app/views/events/event/_push.html.haml
@@ -32,7 +32,8 @@
- from_label = from
= link_to project_compare_path(project, from: from, to: event.commit_to) do
- Compare #{from_label}...#{truncate_sha(event.commit_to)}
+ %span Compare
+ %span.commit-sha #{from_label}...#{truncate_sha(event.commit_to)}
- if create_mr
%span
diff --git a/app/views/explore/groups/_groups.html.haml b/app/views/explore/groups/_groups.html.haml
index ff57b39e947..a3249275d5e 100644
--- a/app/views/explore/groups/_groups.html.haml
+++ b/app/views/explore/groups/_groups.html.haml
@@ -1,4 +1,4 @@
.js-groups-list-holder
#js-groups-tree{ data: { hide_projects: 'true', endpoint: explore_groups_path(format: :json), path: explore_groups_path, form_sel: 'form#group-filter-form', filter_sel: '.js-groups-list-filter', holder_sel: '.js-groups-list-holder', dropdown_sel: '.js-group-filter-dropdown-wrap' } }
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml
index a3eafc61d0a..fd86d07fc86 100644
--- a/app/views/explore/groups/index.html.haml
+++ b/app/views/explore/groups/index.html.haml
@@ -2,6 +2,8 @@
- page_title _("Groups")
- header_title _("Groups"), dashboard_groups_path
+= render_dashboard_gold_trial(current_user)
+
- if current_user
= render 'dashboard/groups_head'
- else
diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml
index f518205f14c..d00a3d266d8 100644
--- a/app/views/explore/projects/_filter.html.haml
+++ b/app/views/explore/projects/_filter.html.haml
@@ -1,8 +1,12 @@
+- has_label = local_assigns.fetch(:has_label, false)
+- feature_project_list_filter_bar = Feature.enabled?(:project_list_filter_bar)
+
- if current_user
- .dropdown
+ .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) }
%button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' }
- = icon('globe', class: 'mt-1')
- %span.light.ml-3= _("Visibility:")
+ - unless has_label
+ = icon('globe', class: 'mt-1')
+ %span.light.ml-3= _("Visibility:")
- if params[:visibility_level].present?
= visibility_level_label(params[:visibility_level].to_i)
- else
diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml
index 452f390695c..341ad681c7c 100644
--- a/app/views/explore/projects/index.html.haml
+++ b/app/views/explore/projects/index.html.haml
@@ -2,10 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
+= render_dashboard_gold_trial(current_user)
+
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :explore
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/starred.html.haml b/app/views/explore/projects/starred.html.haml
index 452f390695c..ec92852ddde 100644
--- a/app/views/explore/projects/starred.html.haml
+++ b/app/views/explore/projects/starred.html.haml
@@ -2,10 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
+= render_dashboard_gold_trial(current_user)
+
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :starred
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/explore/projects/trending.html.haml b/app/views/explore/projects/trending.html.haml
index 452f390695c..ed508fa2506 100644
--- a/app/views/explore/projects/trending.html.haml
+++ b/app/views/explore/projects/trending.html.haml
@@ -2,10 +2,12 @@
- page_title _("Projects")
- header_title _("Projects"), dashboard_projects_path
+= render_dashboard_gold_trial(current_user)
+
- if current_user
- = render 'dashboard/projects_head'
+ = render 'dashboard/projects_head', project_tab_filter: :explore_trending
- else
= render 'explore/head'
-= render 'explore/projects/nav'
+= render 'explore/projects/nav' unless Feature.enabled?(:project_list_filter_bar) && current_user
= render 'projects', projects: @projects
diff --git a/app/views/explore/snippets/index.html.haml b/app/views/explore/snippets/index.html.haml
index 94fc4ac21d2..d23c8301b10 100644
--- a/app/views/explore/snippets/index.html.haml
+++ b/app/views/explore/snippets/index.html.haml
@@ -7,4 +7,4 @@
- else
= render 'explore/head'
-= render partial: 'snippets/snippets', locals: { link_project: true }
+= render partial: 'shared/snippets/list', locals: { link_project: true }
diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml
index 82a497289f3..13df1e57125 100644
--- a/app/views/groups/_activities.html.haml
+++ b/app/views/groups/_activities.html.haml
@@ -1,7 +1,7 @@
.nav-block.activities
= render 'shared/event_filter'
.controls
- = link_to group_path(@group, rss_url_options), class: 'btn rss-btn has-tooltip' , title: 'Subscribe' do
+ = link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do
%i.fa.fa-rss
.content_list
diff --git a/app/views/groups/_archived_projects.html.haml b/app/views/groups/_archived_projects.html.haml
index ed79f5790f0..48e9f630050 100644
--- a/app/views/groups/_archived_projects.html.haml
+++ b/app/views/groups/_archived_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/_create_chat_team.html.haml b/app/views/groups/_create_chat_team.html.haml
index f950968030f..561e68a9155 100644
--- a/app/views/groups/_create_chat_team.html.haml
+++ b/app/views/groups/_create_chat_team.html.haml
@@ -1,8 +1,9 @@
.form-group
- = f.label :create_chat_team, class: 'col-form-label' do
- %span.mattermost-icon
- = custom_icon('icon_mattermost')
- Mattermost
+ .col-sm-2.col-form-label
+ = f.label :create_chat_team do
+ %span.mattermost-icon
+ = custom_icon('icon_mattermost')
+ Mattermost
.col-sm-10
.form-check.js-toggle-container
.js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false)
diff --git a/app/views/groups/_group_admin_settings.html.haml b/app/views/groups/_group_admin_settings.html.haml
index ff59013ed67..b8f632d11d3 100644
--- a/app/views/groups/_group_admin_settings.html.haml
+++ b/app/views/groups/_group_admin_settings.html.haml
@@ -1,5 +1,6 @@
.form-group.row
- = f.label :lfs_enabled, 'Large File Storage', class: 'col-form-label col-sm-2 pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :lfs_enabled, 'Large File Storage'
.col-sm-10
.form-check
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input'
@@ -9,9 +10,15 @@
= 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.
+.form-group.row
+ .col-sm-2.col-form-label
+ = f.label s_('ProjectCreationLevel|Allowed to create projects')
+ .col-sm-10
+ = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control'
.form-group.row
- = f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2 pt-0'
+ .col-sm-2.col-form-label.pt-0
+ = f.label :require_two_factor_authentication, 'Two-factor authentication'
.col-sm-10
.form-check
= f.check_box :require_two_factor_authentication, class: 'form-check-input'
diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml
index 6219da2c715..4daf3683eaf 100644
--- a/app/views/groups/_home_panel.html.haml
+++ b/app/views/groups/_home_panel.html.haml
@@ -1,17 +1,58 @@
-.group-home-panel.text-center.border-bottom
- %div{ class: container_class }
- .avatar-container.s70.group-avatar
- = group_icon(@group, class: "avatar s70 avatar-tile")
- %h1.group-title
- = @group.name
- %span.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
- = visibility_level_icon(@group.visibility_level, fw: false)
+- can_create_subgroups = can?(current_user, :create_subgroup, @group)
- - if @group.description.present?
- .group-home-desc
- = markdown_field(@group, :description)
+.group-home-panel
+ .row.mb-3
+ .home-panel-title-row.col-md-12.col-lg-6.d-flex
+ .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
+ = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64)
+ .d-flex.flex-column.flex-wrap.align-items-baseline
+ .d-inline-flex.align-items-baseline
+ %h1.home-panel-title.prepend-top-8.append-bottom-5
+ = @group.name
+ %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
+ = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'})
+ .home-panel-metadata.d-flex.align-items-center.text-secondary
+ %span
+ = _("Group ID: %{group_id}") % { group_id: @group.id }
+ - if current_user
+ %span.access-request-links.prepend-left-8
+ = render 'shared/members/access_request_links', source: @group
- - if current_user
- .group-buttons
- = render 'shared/members/access_request_buttons', source: @group
- = render 'shared/notifications/button', notification_setting: @notification_setting
+ .home-panel-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
+ - if current_user
+ .group-buttons
+ = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn'
+ - if can? current_user, :create_projects, @group
+ - new_project_label = _("New project")
+ - new_subgroup_label = _("New subgroup")
+ - if can_create_subgroups
+ .btn-group.new-project-subgroup.droplab-dropdown.home-panel-action-button.prepend-top-default.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
+ %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
+ %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
+ = sprite_icon("arrow-down", css_class: "icon dropdown-btn-icon")
+ %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
+ %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_project_label
+ %span= s_("GroupsTree|Create a project in this group.")
+ %li.divider.droplap-item-ignore
+ %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
+ .menu-item
+ .icon-container
+ = icon("check", class: "list-item-checkmark")
+ .description
+ %strong= new_subgroup_label
+ %span= s_("GroupsTree|Create a subgroup in this group.")
+ - else
+ = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success prepend-top-default"
+
+ - if @group.description.present?
+ .group-home-desc.mt-1
+ .home-panel-description
+ .home-panel-description-markdown.read-more-container
+ = markdown_field(@group, :description)
+ %button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
+ = _("Read more")
diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml
index 4eb8367f633..2769b69add3 100644
--- a/app/views/groups/_shared_projects.html.haml
+++ b/app/views/groups/_shared_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/_subgroups_and_projects.html.haml b/app/views/groups/_subgroups_and_projects.html.haml
index d53c8026df8..784f5ac233e 100644
--- a/app/views/groups/_subgroups_and_projects.html.haml
+++ b/app/views/groups/_subgroups_and_projects.html.haml
@@ -4,5 +4,5 @@
%ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } }
.js-groups-list-holder
- .loading-container.text-center
- = icon('spinner spin 2x', class: 'loading-animation prepend-top-20')
+ .loading-container.text-center.prepend-top-20
+ .spinner.spinner-md
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 39d0f620283..0c8f86c2822 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -1,6 +1,6 @@
- breadcrumb_title "General Settings"
- @content_class = "limit-container-width" unless fluid_layout
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
@@ -25,6 +25,8 @@
.settings-content
= render 'groups/settings/permissions'
+= render_if_exists 'groups/insights', expanded: expanded
+
%section.settings.no-animate#js-badge-settings{ class: ('expanded' if expanded) }
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' }
diff --git a/app/views/groups/group_members/_new_group_member.html.haml b/app/views/groups/group_members/_new_group_member.html.haml
index c8cdc2cc3e4..8b511f6866f 100644
--- a/app/views/groups/group_members/_new_group_member.html.haml
+++ b/app/views/groups/group_members/_new_group_member.html.haml
@@ -1,7 +1,7 @@
= form_for @group_member, url: group_group_members_path(@group), html: { class: 'users-project-form users-group-form' } do |f|
.row
.col-md-4.col-lg-6
- = users_select_tag(:user_ids, multiple: true, class: 'input-clamp', scope: :all, email_user: true)
+ = users_select_tag(:user_ids, group_member_select_options)
.form-text.text-muted.append-bottom-10
Search for members by name, username, or email, or invite new ones using their email address.
diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml
index 13d584f5f1d..021c0b6c429 100644
--- a/app/views/groups/group_members/index.html.haml
+++ b/app/views/groups/group_members/index.html.haml
@@ -14,6 +14,8 @@
= render 'shared/members/requests', membership_source: @group, requesters: @requesters
+ = render_if_exists 'groups/group_members/ldap_sync'
+
.clearfix
%h5.member.existing-title
Existing members
@@ -22,8 +24,8 @@
%span.flex-project-title
Members with access to
%strong= @group.name
- %span.badge= @members.total_count
- = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
+ %span.badge.badge-pill= @members.total_count
+ = form_tag group_group_members_path(@group), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
.position-relative.append-right-8
= search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
diff --git a/app/views/groups/labels/index.html.haml b/app/views/groups/labels/index.html.haml
index 4df3d831942..a8358704b03 100644
--- a/app/views/groups/labels/index.html.haml
+++ b/app/views/groups/labels/index.html.haml
@@ -1,29 +1,23 @@
- @no_container = true
-- page_title "Labels"
+- page_title 'Labels'
- can_admin_label = can?(current_user, :admin_label, @group)
-- issuables = ['issues', 'merge requests']
- search = params[:search]
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || search.present? || subscribed.present?
-- if @labels.present? && can_admin_label
- - content_for(:header_content) do
- .nav-controls
- = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success"
-
- if labels_or_filters
#promote-label-modal
%div{ class: container_class }
- = render 'shared/labels/nav'
+ = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
.labels-container.prepend-top-5
- if @labels.any?
.text-muted
- = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuables.to_sentence }
+ = _('Labels can be applied to %{features}. Group labels are available for any project within the group.') % { features: issuable_types.to_sentence }
.other-labels
%h5= _('Labels')
%ul.content-list.manage-labels-list.js-other-labels
- = render partial: 'shared/label', subject: @group, collection: @labels, as: :label, locals: { use_label_priority: false }
+ = render partial: 'shared/label', collection: @labels, as: :label, locals: { use_label_priority: false, subject: @group }
= paginate @labels, theme: 'gitlab'
- elsif search.present?
.nothing-here-block
diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml
index a9ce2fe5ab0..808bb1309b1 100644
--- a/app/views/groups/merge_requests.html.haml
+++ b/app/views/groups/merge_requests.html.haml
@@ -7,7 +7,7 @@
= render 'shared/issuable/nav', type: :merge_requests
- if current_user
.nav-controls
- = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true
+ = render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests, with_feature_enabled: 'merge_requests', with_shared: false, include_projects_in_subgroups: true
= render 'shared/issuable/search_bar', type: :merge_requests
diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml
index 39e3af5f6d2..b0ba846f204 100644
--- a/app/views/groups/milestones/_form.html.haml
+++ b/app/views/groups/milestones/_form.html.haml
@@ -1,20 +1,20 @@
-= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+= form_for [@group, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
+ = form_errors(@milestone)
.row
- = form_errors(@milestone)
-
.col-md-6
.form-group.row
- = f.label :title, "Title", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :title, "Title"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
.form-group.row.milestone-description
- = f.label :description, "Description", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :description, "Description"
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: group_preview_markdown_path } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...', supports_autocomplete: false
- .clearfix
- .error-alert
-
+ .clearfix
+ .error-alert
= render "shared/milestones/form_dates", f: f
.form-actions
@@ -24,4 +24,3 @@
- else
= f.submit 'Update milestone', class: "btn-success btn"
= link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
-
diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml
index af4fe8f2ef8..b6fb908c8f6 100644
--- a/app/views/groups/milestones/index.html.haml
+++ b/app/views/groups/milestones/index.html.haml
@@ -4,6 +4,7 @@
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls
+ = render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @group)
= link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-success"
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 51dcc9d0cda..06e05d898d6 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -3,7 +3,7 @@
- page_title _('New Group')
- header_title _("Groups"), dashboard_groups_path
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New group')
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
@@ -27,7 +27,7 @@
.form-group.group-description-holder.col-sm-12
= f.label :avatar, _("Group avatar"), class: 'label-bold'
%div
- = render 'shared/choose_group_avatar_button', f: f
+ = render 'shared/choose_avatar_button', f: f
.form-group.col-sm-12
%label.label-bold
diff --git a/app/views/groups/runners/_group_runners.html.haml b/app/views/groups/runners/_group_runners.html.haml
index bcfb6d99716..806a24e2bbc 100644
--- a/app/views/groups/runners/_group_runners.html.haml
+++ b/app/views/groups/runners/_group_runners.html.haml
@@ -21,6 +21,6 @@
- else
%h4.underlined-title
- = _('Available group Runners : %{runners}.').html_safe % { runners: @group.runners.count }
+ = _('Available group Runners: %{runners}').html_safe % { runners: @group.runners.count }
%ul.bordered-list
= render partial: 'groups/runners/runner', collection: @group.runners, as: :runner
diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml
index 0424ece037d..e12748666c8 100644
--- a/app/views/groups/settings/_general.html.haml
+++ b/app/views/groups/settings/_general.html.haml
@@ -17,17 +17,17 @@
= f.label :description, _('Group description (optional)'), class: 'label-bold'
= f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
+ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group
- .form-group.prepend-top-default.append-bottom-20
- .avatar-container.s90
- = group_icon(@group, alt: '', class: 'avatar group-avatar s90')
- = f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
- = render 'shared/choose_group_avatar_button', f: f
- - if @group.avatar?
- %hr
- = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted'
+ .form-group.prepend-top-default.append-bottom-20
+ .avatar-container.rect-avatar.s90
+ = group_icon(@group, alt: '', class: 'avatar group-avatar s90')
+ = f.label :avatar, _('Group avatar'), class: 'label-bold d-block'
+ = render 'shared/choose_avatar_button', f: f
+ - if @group.avatar?
+ %hr
+ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
- = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
+ = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group
= f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit'
diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml
index 6b0a6e7ed99..0a14830c666 100644
--- a/app/views/groups/settings/_permissions.html.haml
+++ b/app/views/groups/settings/_permissions.html.haml
@@ -18,6 +18,7 @@
%span.descr.text-muted= share_with_group_lock_help_text(@group)
= render 'groups/settings/lfs', f: f
+ = render 'groups/settings/project_creation_level', f: f, group: @group
= render 'groups/settings/two_factor_auth', f: f
= render_if_exists 'groups/member_lock_setting', f: f, group: @group
diff --git a/app/views/groups/settings/_project_creation_level.html.haml b/app/views/groups/settings/_project_creation_level.html.haml
new file mode 100644
index 00000000000..9f711e6aade
--- /dev/null
+++ b/app/views/groups/settings/_project_creation_level.html.haml
@@ -0,0 +1,3 @@
+.form-group
+ = f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'label-bold'
+ = f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, group.project_creation_level), {}, class: 'form-control'
diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
new file mode 100644
index 00000000000..e7efc0237c8
--- /dev/null
+++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml
@@ -0,0 +1,15 @@
+= form_for group, url: update_auto_devops_group_settings_ci_cd_path(group), method: :patch do |f|
+ = form_errors(group)
+ %fieldset
+ .form-group
+ .card.auto-devops-card
+ .card-body
+ .form-check
+ = f.check_box :auto_devops_enabled, class: 'form-check-input', checked: group.auto_devops_enabled?
+ = f.label :auto_devops_enabled, class: 'form-check-label' do
+ %strong= s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group')
+ %span.badge.badge-info#auto-devops-badge= badge_for_auto_devops_scope(group)
+ .form-text.text-muted
+ = s_('GroupSettings|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
+ = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
+ = f.submit _('Save changes'), class: 'btn btn-success prepend-top-15'
diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml
index a5e6abdba52..d21496ee0aa 100644
--- a/app/views/groups/settings/ci_cd/show.html.haml
+++ b/app/views/groups/settings/ci_cd/show.html.haml
@@ -1,17 +1,11 @@
- breadcrumb_title "CI / CD Settings"
- page_title "CI / CD"
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings#ci-variables.no-animate{ class: ('expanded' if expanded) }
.settings-header
- %h4
- = _('Variables')
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
- %button.btn.btn-default.js-settings-toggle{ type: "button" }
- = expanded ? _('Collapse') : _('Expand')
- %p.append-bottom-0
- = render "ci/variables/content"
+ = render 'ci/variables/header', expanded: expanded
.settings-content
= render 'ci/variables/index', save_endpoint: group_variables_path
@@ -25,3 +19,17 @@
= _('Register and see your runners for this group.')
.settings-content
= render 'groups/runners/index'
+
+%section.settings#auto-devops-settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4
+ = _('Auto DevOps')
+ %button.btn.btn-default.js-settings-toggle{ type: "button" }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ - auto_devops_url = help_page_path('topics/autodevops/index')
+ - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
+ = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
+
+ .settings-content
+ = render 'groups/settings/ci_cd/auto_devops_form', group: @group
diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml
index cc294f6a931..77fe88dacb7 100644
--- a/app/views/groups/show.html.haml
+++ b/app/views/groups/show.html.haml
@@ -1,66 +1,41 @@
- @no_container = true
- breadcrumb_title _("Details")
-- can_create_subgroups = can?(current_user, :create_subgroup, @group)
+- @content_class = "limit-container-width" unless fluid_layout
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, group_url(@group, rss_url_options), title: "#{@group.name} activity")
-= render 'groups/home_panel'
-
-.groups-listing{ class: container_class, data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
- .top-area.group-nav-container
- .group-search
- = render "shared/groups/search_form"
- - if can? current_user, :create_projects, @group
- - new_project_label = _("New project")
- - new_subgroup_label = _("New subgroup")
- - if can_create_subgroups
- .btn-group.new-project-subgroup.droplab-dropdown.js-new-project-subgroup.qa-new-project-or-subgroup-dropdown{ data: { project_path: new_project_path(namespace_id: @group.id), subgroup_path: new_group_path(parent_id: @group.id) } }
- %input.btn.btn-success.dropdown-primary.js-new-group-child.qa-new-in-group-button{ type: "button", value: new_project_label, data: { action: "new-project" } }
- %button.btn.btn-success.dropdown-toggle.js-dropdown-toggle.qa-new-project-or-subgroup-dropdown-toggle{ type: "button", data: { "dropdown-trigger" => "#new-project-or-subgroup-dropdown", 'display' => 'static' } }
- = icon("caret-down", class: "dropdown-btn-icon")
- %ul#new-project-or-subgroup-dropdown.dropdown-menu.dropdown-menu-right{ data: { dropdown: true } }
- %li.droplab-item-selected.qa-new-project-option{ role: "button", data: { value: "new-project", text: new_project_label } }
- .menu-item
- .icon-container
- = icon("check", class: "list-item-checkmark")
- .description
- %strong= new_project_label
- %span= s_("GroupsTree|Create a project in this group.")
- %li.divider.droplap-item-ignore
- %li.qa-new-subgroup-option{ role: "button", data: { value: "new-subgroup", text: new_subgroup_label } }
- .menu-item
- .icon-container
- = icon("check", class: "list-item-checkmark")
- .description
- %strong= new_subgroup_label
- %span= s_("GroupsTree|Create a subgroup in this group.")
- - else
- = link_to new_project_label, new_project_path(namespace_id: @group.id), class: "btn btn-success"
-
- .scrolling-tabs-container.inner-page-scroll-tabs
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
- %li.js-subgroups_and_projects-tab
- = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
- = _("Subgroups and projects")
- %li.js-shared-tab
- = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do
- = _("Shared projects")
- %li.js-archived-tab
- = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do
- = _("Archived projects")
-
- .nav-controls
- = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash
-
- .tab-content
- #subgroups_and_projects.tab-pane
- = render "subgroups_and_projects", group: @group
-
- #shared.tab-pane
- = render "shared_projects", group: @group
-
- #archived.tab-pane
- = render "archived_projects", group: @group
+%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
+ = render 'groups/home_panel'
+
+ .groups-listing{ data: { endpoints: { default: group_children_path(@group, format: :json), shared: group_shared_projects_path(@group, format: :json) } } }
+ .top-area.group-nav-container
+ .scrolling-tabs-container.inner-page-scroll-tabs
+ .fade-left= icon('angle-left')
+ .fade-right= icon('angle-right')
+ %ul.nav-links.scrolling-tabs.mobile-separator.nav.nav-tabs
+ %li.js-subgroups_and_projects-tab
+ = link_to group_path, data: { target: 'div#subgroups_and_projects', action: 'subgroups_and_projects', toggle: 'tab'} do
+ = _("Subgroups and projects")
+ %li.js-shared-tab
+ = link_to group_shared_path, data: { target: 'div#shared', action: 'shared', toggle: 'tab'} do
+ = _("Shared projects")
+ %li.js-archived-tab
+ = link_to group_archived_path, data: { target: 'div#archived', action: 'archived', toggle: 'tab'} do
+ = _("Archived projects")
+
+ .nav-controls.d-block.d-md-flex
+ .group-search
+ = render "shared/groups/search_form"
+
+ = render "shared/groups/dropdown", options_hash: subgroups_sort_options_hash
+
+ .tab-content
+ #subgroups_and_projects.tab-pane
+ = render "subgroups_and_projects", group: @group
+
+ #shared.tab-pane
+ = render "shared_projects", group: @group
+
+ #archived.tab-pane
+ = render "archived_projects", group: @group
diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml
index 28ffb2dd63c..efb3815b257 100644
--- a/app/views/help/_shortcuts.html.haml
+++ b/app/views/help/_shortcuts.html.haml
@@ -356,6 +356,18 @@
%td.shortcut
%kbd l
%td Change Label
+ %tr
+ %td.shortcut
+ %kbd ]
+ \/
+ %kbd j
+ %td Move to next file
+ %tr
+ %td.shortcut
+ %kbd [
+ \/
+ %kbd k
+ %td Move to previous file
%tbody.hidden-shortcut{ style: 'display:none' }
%tr
%th
diff --git a/app/views/help/index.html.haml b/app/views/help/index.html.haml
index dfa5d820ce9..50933c7d434 100644
--- a/app/views/help/index.html.haml
+++ b/app/views/help/index.html.haml
@@ -1,11 +1,11 @@
%div
- if Gitlab::CurrentSettings.help_page_text.present?
- = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
+ .prepend-top-default.md
+ = markdown_field(Gitlab::CurrentSettings.current_application_settings, :help_page_text)
%hr
%h1
- GitLab
- Community Edition
+ = default_brand_title
- if user_signed_in?
%span= link_to_version
= version_status_badge
@@ -24,12 +24,13 @@
Used by more than 100,000 organizations, GitLab is the most popular solution to manage git repositories on-premises.
%br
Read more about GitLab at #{link_to promo_host, promo_url, target: '_blank', rel: 'noopener noreferrer'}.
- %p= link_to 'Check the current instance configuration ', help_instance_configuration_url
- %hr
+
+%p= link_to 'Check the current instance configuration ', help_instance_configuration_url
+%hr
.row.prepend-top-default
.col-md-8
- .documentation-index.wiki
+ .documentation-index.md
= markdown(@help_index)
.col-md-4
.card
diff --git a/app/views/help/instance_configuration.html.haml b/app/views/help/instance_configuration.html.haml
index f09e3825a4b..99576d45f76 100644
--- a/app/views/help/instance_configuration.html.haml
+++ b/app/views/help/instance_configuration.html.haml
@@ -1,5 +1,5 @@
- page_title 'Instance Configuration'
-.wiki.documentation
+.documentation.md
%h1 Instance Configuration
%p
diff --git a/app/views/help/show.html.haml b/app/views/help/show.html.haml
index c07c148a12a..dce27dee9be 100644
--- a/app/views/help/show.html.haml
+++ b/app/views/help/show.html.haml
@@ -1,3 +1,3 @@
- page_title @path.split("/").reverse.map(&:humanize)
-.documentation.wiki.prepend-top-default
+.documentation.md.prepend-top-default
= markdown @markdown
diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml
index 506f580b246..cdc894ee5a0 100644
--- a/app/views/help/ui.html.haml
+++ b/app/views/help/ui.html.haml
@@ -70,7 +70,7 @@
.cover-title
John Smith
- .cover-desc
+ .cover-desc.cgray
= lorem
.cover-controls
@@ -513,7 +513,7 @@
%h2#markdown Markdown
%h4
- %code .md or .wiki and others
+ %code .md
Markdown rendering has a bit different css and presented in next UI elements:
diff --git a/app/views/ide/_show.html.haml b/app/views/ide/_show.html.haml
index b24d6e27536..057225d021f 100644
--- a/app/views/ide/_show.html.haml
+++ b/app/views/ide/_show.html.haml
@@ -4,7 +4,7 @@
- content_for :page_specific_javascripts do
= stylesheet_link_tag 'page_bundles/ide'
-#ide.ide-loading{ data: ide_data() }
+#ide.ide-loading{ data: ide_data }
.text-center
= icon('spinner spin 2x')
%h2.clgray= _('Loading the GitLab IDE...')
diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml
index f4a29ed18dc..b05c039c85c 100644
--- a/app/views/import/_githubish_status.html.haml
+++ b/app/views/import/_githubish_status.html.haml
@@ -1,56 +1,9 @@
- provider = local_assigns.fetch(:provider)
- provider_title = Gitlab::ImportSources.title(provider)
-%p.light
- = import_githubish_choose_repository_message
-%hr
-%p
- = button_tag class: "btn btn-import btn-success js-import-all" do
- = import_all_githubish_repositories_button_label
- = icon("spinner spin", class: "loading-icon")
-
-.table-responsive
- %table.table.import-jobs
- %colgroup.import-jobs-from-col
- %colgroup.import-jobs-to-col
- %colgroup.import-jobs-status-col
- %thead
- %tr
- %th= _('From %{provider_title}') % { provider_title: provider_title }
- %th= _('To GitLab')
- %th= _('Status')
- %tbody
- - @already_added_projects.each do |project|
- %tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) }
- %td
- = provider_project_link(provider, project.import_source)
- %td
- = link_to project.full_path, [project.namespace.becomes(Namespace), project]
- %td.job-status
- = render 'import/project_status', project: project
-
- - @repos.each do |repo|
- %tr{ id: "repo_#{repo.id}", data: { qa: { repo_path: repo.full_name } } }
- %td
- = provider_project_link(provider, repo.full_name)
- %td.import-target
- %fieldset.row
- .input-group
- .project-path.input-group-prepend
- - if current_user.can_select_namespace?
- - selected = params[:namespace_id] || :current_user
- - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner.login, path: repo.owner.login) } : {}
- = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace qa-project-namespace-select', tabindex: 1 }
- - else
- = text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
- %span.input-group-prepend
- .input-group-text /
- = text_field_tag :path, sanitize_project_name(repo.name), class: "input-mini form-control", tabindex: 2, autofocus: true, required: true
- %td.import-actions.job-status
- = button_tag class: "btn btn-import js-add-to-import" do
- = has_ci_cd_only_params? ? _('Connect') : _('Import')
- = icon("spinner spin", class: "loading-icon")
-
-.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]),
- import_path: url_for([:import, provider]),
- ci_cd_only: has_ci_cd_only_params?.to_s } }
+#import-projects-mount-element{ data: { provider: provider, provider_title: provider_title,
+ can_select_namespace: current_user.can_select_namespace?.to_s,
+ ci_cd_only: has_ci_cd_only_params?.to_s,
+ repos_path: url_for([:status, :import, provider, format: :json]),
+ jobs_path: url_for([:realtime_changes, :import, provider, format: :json]),
+ import_path: url_for([:import, provider, format: :json]) } }
diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml
index c4e27efc76f..40609fddbde 100644
--- a/app/views/import/bitbucket_server/status.html.haml
+++ b/app/views/import/bitbucket_server/status.html.haml
@@ -56,7 +56,7 @@
.project-path.input-group-prepend
- if current_user.can_select_namespace?
- selected = params[:namespace_id] || :extra_group
- - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.project_key, path: repo.project_key) } : {}
+ - opts = current_user.can_create_group? ? { extra_group: Group.new(name: sanitize_project_name(repo.project_key), path: sanitize_project_name(repo.project_key)) } : {}
= select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'input-group-text select2 js-select-namespace', tabindex: 1 }
- else
= text_field_tag :path, current_user.namespace_path, class: "input-group-text input-large form-control", tabindex: 1, disabled: true
diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml
index a88b04eccbb..c4670869c93 100644
--- a/app/views/import/gitea/new.html.haml
+++ b/app/views/import/gitea/new.html.haml
@@ -2,18 +2,18 @@
- header_title _("Projects"), root_path
%h3.page-title
- = custom_icon('go_logo')
+ = custom_icon('gitea_logo')
= _('Import Projects from Gitea')
%p
- - link_to_personal_token = link_to(_('Personal Access Token'), 'https://github.com/gogits/go-gogs-client/wiki#access-token')
+ - link_to_personal_token = link_to(_('Personal Access Token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api')
= _('To get started, please enter your Gitea Host URL and a %{link_to_personal_token}.').html_safe % { link_to_personal_token: link_to_personal_token }
= form_tag personal_access_token_import_gitea_path do
.form-group.row
= label_tag :gitea_host_url, _('Gitea Host URL'), class: 'col-form-label col-sm-2'
.col-sm-4
- = text_field_tag :gitea_host_url, nil, placeholder: 'https://try.gitea.io', class: 'form-control'
+ = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control'
.form-group.row
= label_tag :personal_access_token, _('Personal Access Token'), class: 'col-form-label col-sm-2'
.col-sm-4
diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml
index 88244fde16b..ef0693e73c3 100644
--- a/app/views/import/gitea/status.html.haml
+++ b/app/views/import/gitea/status.html.haml
@@ -1,7 +1,7 @@
- page_title _("Gitea Import")
- header_title _("Projects"), root_path
%h3.page-title
- = custom_icon('go_logo')
+ = custom_icon('gitea_logo')
= _('Import Projects from Gitea')
= render 'import/githubish_status', provider: 'gitea'
diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml
index 6ff25f2c842..72e5934574a 100644
--- a/app/views/import/github/new.html.haml
+++ b/app/views/import/github/new.html.haml
@@ -4,7 +4,7 @@
- header_title _("Projects"), root_path
%h3.page-title
- = icon 'github', text: import_github_title
+ = icon 'github', text: _('Import repositories from GitHub')
- if github_import_configured?
%p
@@ -22,6 +22,8 @@
= text_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40
= submit_tag _('List your GitHub repositories'), class: 'btn btn-success'
+ = render_if_exists 'import/github/ci_cd_only'
+
- unless github_import_configured?
%hr
%p
diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml
index be057be6d1a..ee295e70cce 100644
--- a/app/views/import/github/status.html.haml
+++ b/app/views/import/github/status.html.haml
@@ -2,7 +2,7 @@
- page_title title
- breadcrumb_title title
- header_title _("Projects"), root_path
-%h3.page-title
- = icon 'github', text: import_github_title
+%h3.page-title.mb-0
+ = icon 'github', class: 'fa-2x', text: _('Import repositories from GitHub')
= render 'import/githubish_status', provider: 'github'
diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml
index 5b2e1005398..3d4abc32b88 100644
--- a/app/views/import/manifest/status.html.haml
+++ b/app/views/import/manifest/status.html.haml
@@ -7,7 +7,7 @@
%p
= button_tag class: "btn btn-import btn-success js-import-all" do
- = import_all_githubish_repositories_button_label
+ = _('Import all repositories')
= icon("spinner spin", class: "loading-icon")
.table-responsive
diff --git a/app/views/instance_statistics/conversational_development_index/_callout.html.haml b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
index 33a4dab1e00..a4256e23979 100644
--- a/app/views/instance_statistics/conversational_development_index/_callout.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_callout.html.haml
@@ -2,12 +2,12 @@
.user-callout{ data: { uid: 'convdev_intro_callout_dismissed' } }
.bordered-box.landing.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button',
- 'aria-label' => 'Dismiss ConvDev introduction' }
+ 'aria-label' => _('Dismiss ConvDev introduction') }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
.user-callout-copy
%h4
- Introducing Your Conversational Development Index
+ = _('Introducing Your Conversational Development Index')
%p
- Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.
+ = _('Your Conversational Development Index gives an overview of how you are using GitLab from a feature perspective. View how you compare with other organizations, discover features you are not using, and learn best practices through blog posts and white papers.')
.svg-container.convdev
= custom_icon('convdev_overview')
diff --git a/app/views/instance_statistics/conversational_development_index/_card.html.haml b/app/views/instance_statistics/conversational_development_index/_card.html.haml
index 57eda06630b..76af55dcf7a 100644
--- a/app/views/instance_statistics/conversational_development_index/_card.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_card.html.haml
@@ -9,11 +9,11 @@
.board-card-score
.board-card-score-value
= format_score(card.instance_score)
- .board-card-score-name You
+ .board-card-score-name= _('You')
.board-card-score
.board-card-score-value
= format_score(card.leader_score)
- .board-card-score-name Lead
+ .board-card-score-name= _('Lead')
.board-card-score-big
= number_to_percentage(card.percentage_score, precision: 1)
.board-card-buttons
diff --git a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
index dd795aee135..4e8f34cd574 100644
--- a/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/_no_data.html.haml
@@ -1,7 +1,7 @@
.container.convdev-empty
.col-sm-12.justify-content-center.text-center
= custom_icon('convdev_no_data')
- %h4 Data is still calculating...
+ %h4= _('Data is still calculating...')
%p
- In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.
- = link_to 'Learn more', help_page_path('user/instance_statistics/convdev'), target: '_blank'
+ = _('In order to gather accurate feature usage data, it can take 1 to 2 weeks to see your index.')
+ = link_to _('Learn more'), help_page_path('user/instance_statistics/convdev'), target: '_blank'
diff --git a/app/views/instance_statistics/conversational_development_index/index.html.haml b/app/views/instance_statistics/conversational_development_index/index.html.haml
index 1e7db4982d6..23f90b876a0 100644
--- a/app/views/instance_statistics/conversational_development_index/index.html.haml
+++ b/app/views/instance_statistics/conversational_development_index/index.html.haml
@@ -17,9 +17,9 @@
%h2.convdev-header-title{ class: "convdev-#{score_level(@metric.average_percentage_score)}-score" }
= number_to_percentage(@metric.average_percentage_score, precision: 1)
.convdev-header-subtitle
- index
+ = _('index')
%br
- score
+ = _('score')
= link_to icon('question-circle', 'aria-hidden' => 'true'), help_page_path('user/instance_statistics/convdev')
.convdev-cards.board-card-container
diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder
index 21cf6d0dd65..94c32df7c60 100644
--- a/app/views/issues/_issue.atom.builder
+++ b/app/views/issues/_issue.atom.builder
@@ -12,6 +12,7 @@ xml.entry do
xml.summary issue.title
xml.description issue.description if issue.description
+ xml.content issue.description if issue.description
xml.milestone issue.milestone.title if issue.milestone
xml.due_date issue.due_date if issue.due_date
diff --git a/app/views/issues/_issues_calendar.ics.ruby b/app/views/issues/_issues_calendar.ics.ruby
index 73ab8489e0c..94c3099ace2 100644
--- a/app/views/issues/_issues_calendar.ics.ruby
+++ b/app/views/issues/_issues_calendar.ics.ruby
@@ -3,7 +3,7 @@ cal.prodid = '-//GitLab//NONSGML GitLab//EN'
cal.x_wr_calname = 'GitLab Issues'
# rubocop: disable CodeReuse/ActiveRecord
-@issues.includes(project: :namespace).each do |issue|
+@issues.preload(project: :namespace).each do |issue|
cal.event do |event|
event.dtstart = Icalendar::Values::Date.new(issue.due_date)
event.summary = "#{issue.title} (in #{issue.project.full_path})"
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index 08a6359f777..c357207054b 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -11,20 +11,20 @@
%meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' }
-# Open Graph - http://ogp.me/
- %meta{ property: 'og:type', content: "object" }
- %meta{ property: 'og:site_name', content: site_name }
- %meta{ property: 'og:title', content: page_title }
+ %meta{ property: 'og:type', content: "object" }
+ %meta{ property: 'og:site_name', content: site_name }
+ %meta{ property: 'og:title', content: page_title }
%meta{ property: 'og:description', content: page_description }
- %meta{ property: 'og:image', content: page_image }
+ %meta{ property: 'og:image', content: page_image }
%meta{ property: 'og:image:width', content: '64' }
%meta{ property: 'og:image:height', content: '64' }
- %meta{ property: 'og:url', content: request.base_url + request.fullpath }
+ %meta{ property: 'og:url', content: request.base_url + request.fullpath }
-# Twitter Card - https://dev.twitter.com/cards/types/summary
- %meta{ property: 'twitter:card', content: "summary" }
- %meta{ property: 'twitter:title', content: page_title }
- %meta{ property: 'twitter:description', content: page_description }
- %meta{ property: 'twitter:image', content: page_image }
+ %meta{ property: 'twitter:card', content: "summary" }
+ %meta{ property: 'twitter:title', content: page_title }
+ %meta{ property: 'twitter:description', content: page_description }
+ %meta{ property: 'twitter:image', content: page_image }
= page_card_meta_tags
%title= page_title(site_name)
@@ -33,11 +33,13 @@
= favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png'
= stylesheet_link_tag "application", media: "all"
- = stylesheet_link_tag "print", media: "print"
- = stylesheet_link_tag "test", media: "all" if Rails.env.test?
+ = stylesheet_link_tag "print", media: "print"
+ = stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
+ = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all"
+
= Gon::Base.render_data
- if content_for?(:library_javascripts)
@@ -61,10 +63,10 @@
%meta{ name: 'theme-color', content: '#474D57' }
-# Apple Safari/iOS home screen icons
- = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon'
- = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76'
+ = favicon_link_tag 'touch-icon-iphone.png', rel: 'apple-touch-icon'
+ = favicon_link_tag 'touch-icon-ipad.png', rel: 'apple-touch-icon', sizes: '76x76'
= favicon_link_tag 'touch-icon-iphone-retina.png', rel: 'apple-touch-icon', sizes: '120x120'
- = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152'
+ = favicon_link_tag 'touch-icon-ipad-retina.png', rel: 'apple-touch-icon', sizes: '152x152'
%link{ rel: 'mask-icon', href: image_path('logo.svg'), color: 'rgb(226, 67, 41)' }
-# Windows 8 pinned site tile
@@ -75,3 +77,4 @@
= render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id')
= render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id')
+ = render_if_exists 'layouts/snowplow'
diff --git a/app/views/layouts/_init_client_detection_flags.html.haml b/app/views/layouts/_init_client_detection_flags.html.haml
new file mode 100644
index 00000000000..c729f8aa696
--- /dev/null
+++ b/app/views/layouts/_init_client_detection_flags.html.haml
@@ -0,0 +1,7 @@
+- client = client_js_flags
+
+- if client
+ -# haml-lint:disable InlineJavaScript
+ :javascript
+ gl = window.gl || {};
+ gl.client = #{client.to_json};
diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml
index ddc1cdb24b5..6e8294d6adc 100644
--- a/app/views/layouts/_mailer.html.haml
+++ b/app/views/layouts/_mailer.html.haml
@@ -49,9 +49,10 @@
%table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" }
%tbody
%tr.line
- %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }  
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" }
%tr.header
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ = html_header_message
= header_logo
%tr
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" }
@@ -63,6 +64,8 @@
%tbody
= yield
+ = render_if_exists 'layouts/mailer/additional_text'
+
%tr.footer
%td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
%img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/
@@ -72,3 +75,6 @@
= _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} &middot; %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link }
= yield :additional_footer
+ %tr
+ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" }
+ = html_footer_message
diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml
index 1b2a4cd6780..006334ade07 100644
--- a/app/views/layouts/_page.html.haml
+++ b/app/views/layouts/_page.html.haml
@@ -5,8 +5,10 @@
= render 'shared/outdated_browser'
.mobile-overlay
.alert-wrapper
+ = render_if_exists "layouts/header/ee_license_banner"
= render "layouts/broadcast"
= render "layouts/header/read_only_banner"
+ = render "layouts/nav/classification_level_banner"
= yield :flash_message
= render "shared/ping_consent"
- unless @hide_breadcrumbs
diff --git a/app/views/layouts/_piwik.html.haml b/app/views/layouts/_piwik.html.haml
index a888e8ae187..473b14ce626 100644
--- a/app/views/layouts/_piwik.html.haml
+++ b/app/views/layouts/_piwik.html.haml
@@ -7,7 +7,7 @@
(function() {
var u="//#{extra_config.piwik_url}/";
_paq.push(['setTrackerUrl', u+'piwik.php']);
- _paq.push(['setSiteId', #{extra_config.piwik_site_id}]);
+ _paq.push(['setSiteId', "#{extra_config.piwik_site_id}"]);
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
g.type='text/javascript'; g.async=true; g.defer=true; g.src=u+'piwik.js'; s.parentNode.insertBefore(g,s);
})();
diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml
index a86972d8cf3..a6023a1cbb9 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -2,7 +2,7 @@
- group_data_attrs = { group_path: j(@group.path), name: @group.name, issues_path: issues_group_path(j(@group.path)), mr_path: merge_requests_group_path(j(@group.path)) }
- if @project && @project.persisted?
- project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? }
-.search.search-form
+.search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input" } }
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
.search-input-wrap
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 1f4d24d996c..043cca6ad38 100644
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -1,10 +1,13 @@
!!! 5
%html{ lang: I18n.locale, class: page_class }
= render "layouts/head"
- %body{ class: "#{user_application_theme} #{@body_class}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
+ %body{ class: "#{user_application_theme} #{@body_class} #{client_class_list}", data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
= render "layouts/init_auto_complete" if @gfm_form
+ = render "layouts/init_client_detection_flags"
= render 'peek/bar'
- = render partial: "layouts/header/default", locals: { project: @project, group: @group }
+ = header_message
+ = render partial: "layouts/header/default", locals: { project: @project, group: @group }
= render 'layouts/page', sidebar: sidebar, nav: nav
+ = footer_message
= yield :scripts_body
diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml
index 4f8db74382f..ff3410f6268 100644
--- a/app/views/layouts/devise.html.haml
+++ b/app/views/layouts/devise.html.haml
@@ -1,7 +1,8 @@
!!! 5
%html.devise-layout-html{ class: system_message_class }
= render "layouts/head"
- %body.ui-indigo.login-page.application.navless{ data: { page: body_data_page } }
+ %body.ui-indigo.login-page.application.navless.qa-login-page{ data: { page: body_data_page } }
+ = header_message
.page-wrap
= render "layouts/header/empty"
.login-page-broadcast
@@ -9,15 +10,17 @@
.container.navless-container
.content
= render "layouts/flash"
- .row.append-bottom-15
- .col-sm-7.brand-holder
- %h1
+ .row.mt-3
+ .col-sm-12
+ %h1.mb-3.font-weight-normal
= brand_title
+ .row.mb-3
+ .col-sm-7.order-12.order-sm-1.brand-holder
= brand_image
- if current_appearance&.description?
= brand_text
- else
- %h3
+ %h3.mt-sm-0
= _('Open source software to collaborate on code')
%p
@@ -25,7 +28,10 @@
- if Gitlab::CurrentSettings.sign_in_text.present?
= markdown_field(Gitlab::CurrentSettings.current_application_settings, :sign_in_text)
- .col-sm-5.new-session-forms-container
+
+ = render_if_exists 'layouts/devise_help_text'
+
+ .col-sm-5.order-1.order-sm-12.new-session-forms-container
= yield
%hr.footer-fixed
@@ -34,3 +40,4 @@
= link_to _("Explore"), explore_root_path
= link_to _("Help"), help_path
= link_to _("About GitLab"), "https://about.gitlab.com/"
+ = footer_message
diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml
index 663e5b24368..6c9c8aa4431 100644
--- a/app/views/layouts/devise_empty.html.haml
+++ b/app/views/layouts/devise_empty.html.haml
@@ -2,6 +2,7 @@
%html{ lang: "en", class: system_message_class }
= render "layouts/head"
%body.ui-indigo.login-page.application.navless
+ = header_message
= render "layouts/header/empty"
= render "layouts/broadcast"
.container.navless-container
@@ -15,3 +16,4 @@
= link_to _("Explore"), explore_root_path
= link_to _("Help"), help_path
= link_to _("About GitLab"), "https://about.gitlab.com/"
+ = footer_message
diff --git a/app/views/layouts/empty_mailer.html.haml b/app/views/layouts/empty_mailer.html.haml
new file mode 100644
index 00000000000..a25dcefd445
--- /dev/null
+++ b/app/views/layouts/empty_mailer.html.haml
@@ -0,0 +1,5 @@
+= html_header_message
+
+= yield
+
+= html_footer_message
diff --git a/app/views/layouts/empty_mailer.text.erb b/app/views/layouts/empty_mailer.text.erb
new file mode 100644
index 00000000000..6ab0dbead07
--- /dev/null
+++ b/app/views/layouts/empty_mailer.text.erb
@@ -0,0 +1,5 @@
+<%= text_header_message %>
+
+<%= yield -%>
+
+<%= text_footer_message %>
diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml
index bfbfeee7c4b..1d40b78fa83 100644
--- a/app/views/layouts/group.html.haml
+++ b/app/views/layouts/group.html.haml
@@ -1,7 +1,7 @@
- page_title @group.name
- page_description @group.description unless page_description
- header_title group_title(@group) unless header_title
-- nav "group"
+- nav "group"
- @left_sidebar = true
- content_for :page_specific_javascripts do
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index e8d0d809181..f8b7d0c530a 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -17,6 +17,10 @@
- if logo_text.present?
%span.logo-text.d-none.d-lg-block.prepend-left-8
= logo_text
+ - if Gitlab.com?
+ = link_to 'https://next.gitlab.com', class: 'label-link js-canary-badge canary-badge bg-transparent hidden', target: :_blank do
+ %span.color-label.has-tooltip.badge.badge-pill.green-badge
+ = _('Next')
- if current_user
= render "layouts/nav/dashboard"
@@ -38,7 +42,7 @@
= link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('issues', size: 16)
- issues_count = assigned_issuables_count(:issues)
- %span.badge.badge-pill.issues-count{ class: ('hidden' if issues_count.zero?) }
+ %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
- if header_link?(:merge_requests)
= nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do
@@ -60,7 +64,7 @@
.dropdown-menu.dropdown-menu-right
= render 'layouts/header/help_dropdown'
- if header_link?(:user_dropdown)
- %li.nav-item.header-user.dropdown
+ %li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown" } }
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar"
= sprite_icon('angle-down', css_class: 'caret-down')
diff --git a/app/views/layouts/header/_empty.html.haml b/app/views/layouts/header/_empty.html.haml
index 2dfc787b7a8..348ce18b122 100644
--- a/app/views/layouts/header/_empty.html.haml
+++ b/app/views/layouts/header/_empty.html.haml
@@ -1,4 +1,2 @@
%header.navbar.fixed-top.navbar-empty
- .container
- .mx-auto
- = brand_header_logo
+ = brand_header_logo
diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml
index 953c0e7f46c..5643a508ddc 100644
--- a/app/views/layouts/header/_help_dropdown.html.haml
+++ b/app/views/layouts/header/_help_dropdown.html.haml
@@ -2,5 +2,13 @@
- if current_user_menu?(:help)
%li
= link_to _("Help"), help_path
+ = render_if_exists "shared/learn_gitlab_menu_item"
+ %li.divider
+ %li
+ = link_to _("Submit feedback"), "https://about.gitlab.com/submit-feedback"
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
= render 'shared/user_dropdown_contributing_link'
+ = render_if_exists 'shared/user_dropdown_instance_review'
+ - if Gitlab.com?
+ %li.js-canary-link
+ = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml
index 5cb8aebadb3..438340464bd 100644
--- a/app/views/layouts/header/_new_dropdown.haml
+++ b/app/views/layouts/header/_new_dropdown.haml
@@ -1,4 +1,4 @@
-%li.header-new.dropdown
+%li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown" } }
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do
= sprite_icon('plus-square', size: 16)
= sprite_icon('angle-down', css_class: 'caret-down')
@@ -38,4 +38,4 @@
%li= link_to _('New project'), new_project_path, class: 'qa-global-new-project-link'
- if current_user.can_create_group?
%li= link_to _('New group'), new_group_path
- %li= link_to _('New snippet'), new_snippet_path
+ %li= link_to _('New snippet'), new_snippet_path, class: 'qa-global-new-snippet-link'
diff --git a/app/views/layouts/mailer.text.erb b/app/views/layouts/mailer.text.erb
index 8e11174f8d7..1a06ea68bcd 100644
--- a/app/views/layouts/mailer.text.erb
+++ b/app/views/layouts/mailer.text.erb
@@ -1,4 +1,9 @@
+<%= text_header_message %>
+
<%= yield -%>
-- <%# signature marker %>
<%= _("You're receiving this email because of your account on %{host}.") % { host: Gitlab.config.gitlab.host } %>
+<%= render_if_exists 'layouts/mailer/additional_text' %>
+
+<%= text_footer_message %>
diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml
new file mode 100644
index 00000000000..cc4caf079b8
--- /dev/null
+++ b/app/views/layouts/nav/_classification_level_banner.html.haml
@@ -0,0 +1,5 @@
+- if ::Gitlab::ExternalAuthorization.enabled? && @project
+ = content_for :header_content do
+ %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') }
+ = sprite_icon('lock-open', size: 8, css_class: 'inline')
+ = @project.external_authorization_classification_label
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index 7057a5a142f..47710b9e9e5 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -2,16 +2,16 @@
-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information.
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
- = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
- %button{ type: 'button', data: { toggle: "dropdown" } }
+ = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown", data: { track_label: "projects_dropdown", track_event: "click_dropdown" } }) do
+ %button.btn{ type: 'button', data: { toggle: "dropdown" } }
= _('Projects')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
- if dashboard_nav_link?(:groups)
- = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown" }) do
- %button{ type: 'button', data: { toggle: "dropdown" } }
+ = nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { id: 'nav-groups-dropdown', class: "home dropdown header-groups qa-groups-dropdown", data: { track_label: "groups_dropdown", track_event: "click_dropdown" } }) do
+ %button.btn{ type: 'button', data: { toggle: "dropdown" } }
= _('Groups')
= sprite_icon('angle-down', css_class: 'caret-down')
.dropdown-menu.frequent-items-dropdown-menu
@@ -29,7 +29,7 @@
- if dashboard_nav_link?(:snippets)
= nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
- = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
+ = link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets qa-snippets-link', title: _('Snippets') do
= _('Snippets')
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
@@ -95,3 +95,4 @@
= link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
+ = render_if_exists 'layouts/nav/geo_primary_node_url'
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 5f15ba87729..83fe871285a 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -48,7 +48,7 @@
%span
= _('Gitaly Servers')
- = nav_link(controller: %w(system_info background_jobs logs health_check requests_profiles)) do
+ = nav_link(controller: admin_monitoring_nav_links) do
= link_to admin_system_info_path do
.nav-icon-container
= sprite_icon('monitor')
@@ -81,6 +81,7 @@
= link_to admin_requests_profiles_path, title: _('Requests Profiles') do
%span
= _('Requests Profiles')
+ = render_if_exists 'layouts/nav/ee/admin/new_monitoring_sidebar'
= nav_link(controller: :broadcast_messages) do
= link_to admin_broadcast_messages_path do
@@ -132,6 +133,21 @@
= _('Abuse Reports')
%span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(AbuseReport.count(:all))
+ = render_if_exists 'layouts/nav/sidebar/licenses_link'
+
+ - if instance_clusters_enabled?
+ = nav_link(controller: :clusters) do
+ = link_to admin_clusters_path do
+ .nav-icon-container
+ = sprite_icon('cloud-gear')
+ %span.nav-item-name
+ = _('Kubernetes')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(controller: :clusters, html_options: { class: "fly-out-top-item" } ) do
+ = link_to admin_clusters_path do
+ %strong.fly-out-top-item-name
+ = _('Kubernetes')
+
- if akismet_enabled?
= nav_link(controller: :spam_logs) do
= link_to admin_spam_logs_path do
@@ -145,6 +161,10 @@
%strong.fly-out-top-item-name
= _('Spam Logs')
+ = render_if_exists 'layouts/nav/sidebar/push_rules_link'
+
+ = render_if_exists 'layouts/nav/ee/admin/geo_sidebar'
+
= nav_link(controller: :deploy_keys) do
= link_to admin_deploy_keys_path do
.nav-icon-container
@@ -207,7 +227,7 @@
= _('Settings')
%li.divider.fly-out-top-item
= nav_link(path: 'application_settings#show') do
- = link_to admin_application_settings_path, title: _('General') do
+ = link_to admin_application_settings_path, title: _('General'), class: 'qa-admin-settings-general-item' do
%span
= _('General')
= nav_link(path: 'application_settings#integrations') do
@@ -220,7 +240,7 @@
= _('Repository')
- if template_exists?('admin/application_settings/templates')
= nav_link(path: 'application_settings#templates') do
- = link_to templates_admin_application_settings_path, title: _('Templates') do
+ = link_to templates_admin_application_settings_path, title: _('Templates'), class: 'qa-admin-settings-template-item' do
%span
= _('Templates')
= nav_link(path: 'application_settings#ci_cd') do
@@ -232,7 +252,7 @@
%span
= _('Reporting')
= nav_link(path: 'application_settings#metrics_and_profiling') do
- = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling') do
+ = link_to metrics_and_profiling_admin_application_settings_path, title: _('Metrics and profiling'), class: 'qa-admin-settings-metrics-and-profiling-item' do
%span
= _('Metrics and profiling')
= nav_link(path: 'application_settings#network') do
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index 477030a20c1..0fc5ebbea7e 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -1,12 +1,11 @@
- issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened')
-- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll
.context-header
= link_to group_path(@group), title: @group.name do
- .avatar-container.s40.group-avatar
+ .avatar-container.rect-avatar.s40.group-avatar
= group_icon(@group, class: "avatar s40 avatar-tile")
.sidebar-context-title
= @group.name
@@ -20,13 +19,14 @@
= _('Overview')
%ul.sidebar-sub-level-items
- = nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do
= link_to group_path(@group) do
%strong.fly-out-top-item-name
= _('Overview')
%li.divider.fly-out-top-item
- = nav_link(path: ['groups#show', 'groups#subgroups'], html_options: { class: 'home' }) do
- = link_to group_path(@group), title: _('Group details') do
+
+ = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do
+ = link_to details_group_path(@group), title: _('Group details') do
%span
= _('Details')
@@ -36,18 +36,21 @@
%span
= _('Activity')
- = render_if_exists 'groups/sidebar/security_dashboard'
+ = render_if_exists 'groups/sidebar/security_dashboard' # EE-specific
- if group_sidebar_link?(:contribution_analytics)
= nav_link(path: 'analytics#show') do
- = link_to group_analytics_path(@group), title: 'Contribution Analytics', data: {placement: 'right'} do
+ = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right' } do
%span
- Contribution Analytics
+ = _('Contribution Analytics')
+
+ = render_if_exists 'layouts/nav/group_insights_link'
+ = render_if_exists 'groups/sidebar/dependency_proxy' # EE-specific
= render_if_exists "layouts/nav/ee/epic_link", group: @group
- if group_sidebar_link?(:issues)
- = nav_link(path: issues_sub_menu_items) do
+ = nav_link(path: group_issues_sub_menu_items) do
= link_to issues_group_path(@group) do
.nav-icon-container
= sprite_icon('issues')
@@ -103,19 +106,6 @@
= _('Merge Requests')
%span.badge.badge-pill.count.merge_counter.js-merge-counter.fly-out-badge= number_with_delimiter(merge_requests_count)
- - if group_sidebar_link?(:group_members)
- = nav_link(path: 'group_members#index') do
- = link_to group_group_members_path(@group) do
- .nav-icon-container
- = sprite_icon('users')
- %span.nav-item-name.qa-group-members-item
- = _('Members')
- %ul.sidebar-sub-level-items.is-fly-out-only
- = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
- = link_to group_group_members_path(@group) do
- %strong.fly-out-top-item-name
- = _('Members')
-
- if group_sidebar_link?(:kubernetes)
= nav_link(controller: [:clusters]) do
= link_to group_clusters_path(@group) do
@@ -129,6 +119,19 @@
%strong.fly-out-top-item-name
= _('Kubernetes')
+ - if group_sidebar_link?(:group_members)
+ = nav_link(path: 'group_members#index') do
+ = link_to group_group_members_path(@group) do
+ .nav-icon-container
+ = sprite_icon('users')
+ %span.nav-item-name.qa-group-members-item
+ = _('Members')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(path: 'group_members#index', html_options: { class: "fly-out-top-item" } ) do
+ = link_to group_group_members_path(@group) do
+ %strong.fly-out-top-item-name
+ = _('Members')
+
- if group_sidebar_link?(:settings)
= nav_link(path: group_nav_link_paths) do
= link_to edit_group_path(@group) do
diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml
index 69167edb1df..7dd33f3c641 100644
--- a/app/views/layouts/nav/sidebar/_profile.html.haml
+++ b/app/views/layouts/nav/sidebar/_profile.html.haml
@@ -3,8 +3,8 @@
.context-header
= link_to profile_path, title: _('Profile Settings') do
.avatar-container.s40.settings-avatar
- = sprite_icon('user', size: 24)
- .sidebar-context-title User Settings
+ = image_tag avatar_icon_for_user(current_user, 40), class: "avatar s40 avatar-tile", alt: current_user.name
+ .sidebar-context-title= _('User Settings')
%ul.sidebar-top-level-items
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
= link_to profile_path do
@@ -28,6 +28,8 @@
= link_to profile_account_path do
%strong.fly-out-top-item-name
= _('Account')
+
+ = render_if_exists 'layouts/nav/sidebar/profile_billing_link'
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path do
.nav-icon-container
@@ -151,4 +153,6 @@
%strong.fly-out-top-item-name
= _('Authentication Log')
+ = render_if_exists 'layouts/nav/sidebar/profile_pipeline_quota_link'
+
= render 'shared/sidebar_toggle_button'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index bdd0108db0d..399305baec1 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -3,7 +3,7 @@
- can_edit = can?(current_user, :admin_project, @project)
.context-header
= link_to project_path(@project), title: @project.name do
- .avatar-container.s40.project-avatar
+ .avatar-container.rect-avatar.s40.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile', width: 40, height: 40)
.sidebar-context-title
= @project.name
@@ -26,9 +26,14 @@
%span= _('Details')
= nav_link(path: 'projects#activity') do
- = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity' do
+ = link_to activity_project_path(@project), title: _('Activity'), class: 'shortcuts-project-activity qa-activity-link' do
%span= _('Activity')
+ - if project_nav_tab?(:releases)
+ = nav_link(controller: :releases) do
+ = link_to project_releases_path(@project), title: _('Releases'), class: 'shortcuts-project-releases' do
+ %span= _('Releases')
+
= render_if_exists 'projects/sidebar/security_dashboard'
- if can?(current_user, :read_cycle_analytics, @project)
@@ -36,9 +41,11 @@
= link_to project_cycle_analytics_path(@project), title: _('Cycle Analytics'), class: 'shortcuts-project-cycle-analytics' do
%span= _('Cycle Analytics')
+ = render_if_exists 'layouts/nav/project_insights_link'
+
- if project_nav_tab? :files
= nav_link(controller: sidebar_repository_paths) do
- = link_to project_tree_path(@project), class: 'shortcuts-tree' do
+ = link_to project_tree_path(@project), class: 'shortcuts-tree qa-project-menu-repo' do
.nav-icon-container
= sprite_icon('doc-text')
%span.nav-item-name
@@ -59,10 +66,10 @@
= _('Commits')
= nav_link(html_options: {class: branches_tab_class}) do
- = link_to project_branches_path(@project) do
+ = link_to project_branches_path(@project), class: 'qa-branches-link' do
= _('Branches')
- = nav_link(controller: [:tags, :releases]) do
+ = nav_link(controller: [:tags]) do
= link_to project_tags_path(@project) do
= _('Tags')
@@ -141,7 +148,7 @@
- if project_nav_tab? :merge_requests
= nav_link(controller: @project.issues_enabled? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
- = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests' do
+ = link_to project_merge_requests_path(@project), class: 'shortcuts-merge_requests qa-merge-requests-link' do
.nav-icon-container
= sprite_icon('git-merge')
%span.nav-item-name
@@ -165,7 +172,7 @@
= _('CI / CD')
%ul.sidebar-sub-level-items
- = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" } ) do
+ = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts], html_options: { class: "fly-out-top-item" }) do
= link_to project_pipelines_path(@project) do
%strong.fly-out-top-item-name
= _('CI / CD')
@@ -196,7 +203,7 @@
- if project_nav_tab? :operations
= nav_link(controller: sidebar_operations_paths) do
- = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations qa-link-operations' do
.nav-icon-container
= sprite_icon('cloud-gear')
%span.nav-item-name
@@ -222,6 +229,12 @@
%span
= _('Environments')
+ - if project_nav_tab?(:error_tracking)
+ = nav_link(controller: :error_tracking) do
+ = link_to project_error_tracking_index_path(@project), title: _('Error Tracking'), class: 'shortcuts-tracking qa-operations-tracking-link' do
+ %span
+ = _('Error Tracking')
+
- if project_nav_tab? :serverless
= nav_link(controller: :functions) do
= link_to project_serverless_functions_path(@project), title: _('Serverless') do
@@ -257,6 +270,8 @@
%span= _("Got it!")
= sprite_icon('thumb-up')
+ = render_if_exists 'layouts/nav/sidebar/project_feature_flags_link'
+
- if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do
= link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry' do
@@ -270,19 +285,36 @@
%strong.fly-out-top-item-name
= _('Registry')
+ = render_if_exists 'layouts/nav/sidebar/project_packages_link'
+
- if project_nav_tab? :wiki
+ - wiki_url = project_wiki_path(@project, :home)
= nav_link(controller: :wikis) do
- = link_to get_project_wiki_path(@project), class: 'shortcuts-wiki' do
+ = link_to wiki_url, class: 'shortcuts-wiki qa-wiki-link' do
.nav-icon-container
= sprite_icon('book')
%span.nav-item-name
= _('Wiki')
%ul.sidebar-sub-level-items.is-fly-out-only
= nav_link(controller: :wikis, html_options: { class: "fly-out-top-item" } ) do
- = link_to get_project_wiki_path(@project) do
+ = link_to wiki_url do
%strong.fly-out-top-item-name
= _('Wiki')
+ - if project_nav_tab?(:external_wiki)
+ - external_wiki_url = @project.external_wiki.external_wiki_url
+ = nav_link do
+ = link_to external_wiki_url, class: 'shortcuts-external_wiki' do
+ .nav-icon-container
+ = sprite_icon('issue-external')
+ %span.nav-item-name
+ = _('External Wiki')
+ %ul.sidebar-sub-level-items.is-fly-out-only
+ = nav_link(html_options: { class: "fly-out-top-item" } ) do
+ = link_to external_wiki_url do
+ %strong.fly-out-top-item-name
+ = _('External Wiki')
+
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
= link_to project_snippets_path(@project), class: 'shortcuts-snippets' do
@@ -329,12 +361,15 @@
= link_to project_settings_repository_path(@project), title: _('Repository') do
%span
= _('Repository')
- - if @project.feature_available?(:builds, current_user)
+ - if !@project.archived? && @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
= link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do
%span
= _('CI / CD')
- = render_if_exists 'projects/sidebar/settings_operations'
+ - if !@project.archived? && settings_operations_available?
+ = nav_link(controller: [:operations]) do
+ = link_to project_settings_operations_path(@project), title: _('Operations') do
+ = _('Operations')
- if @project.pages_available?
= nav_link(controller: :pages) do
= link_to project_pages_path(@project), title: _('Pages') do
diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml
index 1c3e05e07f4..de487a94d40 100644
--- a/app/views/layouts/notify.html.haml
+++ b/app/views/layouts/notify.html.haml
@@ -7,6 +7,7 @@
= yield :head
%body
.content
+ = html_header_message
= yield
.footer{ style: "margin-top: 10px;" }
%p
@@ -30,3 +31,6 @@
adjust your notification settings.
= email_action @target_url
+
+ = render_if_exists 'layouts/email_additional_text'
+ = html_footer_message
diff --git a/app/views/layouts/notify.text.erb b/app/views/layouts/notify.text.erb
index 9dc490efa9a..0ee30c2a6cf 100644
--- a/app/views/layouts/notify.text.erb
+++ b/app/views/layouts/notify.text.erb
@@ -1,3 +1,5 @@
+<%= text_header_message %>
+
<%= yield -%>
-- <%# signature marker %>
@@ -10,3 +12,6 @@
<% end -%>
<%= "You're receiving this email because #{notification_reason_text(@reason)}." %>
+<%= render_if_exists 'layouts/mailer/additional_text' %>
+
+<%= text_footer_message -%>
diff --git a/app/views/layouts/snippets.html.haml b/app/views/layouts/snippets.html.haml
index 6418500d5d1..5f986c81ff4 100644
--- a/app/views/layouts/snippets.html.haml
+++ b/app/views/layouts/snippets.html.haml
@@ -1,4 +1,4 @@
-- header_title _("Snippets"), snippets_path
+- header_title _("Snippets"), snippets_path
- content_for :page_specific_javascripts do
- if @snippet && current_user
diff --git a/app/views/notify/_note_email.html.haml b/app/views/notify/_note_email.html.haml
index 1fbae2f64ed..83c7f548975 100644
--- a/app/views/notify/_note_email.html.haml
+++ b/app/views/notify/_note_email.html.haml
@@ -4,17 +4,13 @@
- note_style = local_assigns.fetch(:note_style, "")
- discussion = note.discussion if note.part_of_discussion?
-- diff_discussion = discussion&.diff_discussion?
-- on_image = discussion.on_image? if diff_discussion
- if discussion
- - phrase_end_char = on_image ? "." : ":"
-
%p{ style: "color: #777777;" }
- = succeed phrase_end_char do
+ = succeed ':' do
= link_to note.author_name, user_url(note.author)
- - if diff_discussion
+ - if discussion&.diff_discussion?
- if discussion.new_discussion?
started a new discussion
- else
@@ -31,7 +27,7 @@
%p.details
#{link_to note.author_name, user_url(note.author)} commented:
-- if diff_discussion && !on_image
+- if discussion&.diff_discussion? && discussion.on_text?
= content_for :head do
= stylesheet_link_tag 'mailers/highlighted_diff_email'
diff --git a/app/views/notify/_note_email.text.erb b/app/views/notify/_note_email.text.erb
index 4bf252b6ce1..fae8fa3ccf3 100644
--- a/app/views/notify/_note_email.text.erb
+++ b/app/views/notify/_note_email.text.erb
@@ -1,9 +1,10 @@
<% note = local_assigns.fetch(:note, @note) -%>
<% diff_limit = local_assigns.fetch(:diff_limit, nil) -%>
+<% target_url = local_assigns.fetch(:target_url, @target_url) -%>
<% discussion = note.discussion if note.part_of_discussion? -%>
<% if discussion && !discussion.individual_note? -%>
-<%= note.author_name -%>
+<%= sanitize_name(note.author_name) -%>
<% if discussion.new_discussion? -%>
<%= " started a new discussion" -%>
<% else -%>
@@ -13,14 +14,17 @@
<%= " on #{discussion.file_path}" -%>
<% end -%>
<%= ":" -%>
+<% if discussion.diff_discussion? || !discussion.new_discussion? -%>
+<%= " #{target_url}" -%>
+<% end -%>
<% elsif Gitlab::CurrentSettings.email_author_in_body -%>
-<%= "#{note.author_name} commented:" -%>
+<%= "#{sanitize_name(note.author_name)} commented:" -%>
<% end -%>
-<% if discussion&.diff_discussion? -%>
+<% if discussion&.diff_discussion? && discussion.on_text? -%>
<% discussion.truncated_diff_lines(highlight: false, diff_limit: diff_limit).each do |line| -%>
<%= "> #{line.text}\n" -%>
<% end -%>
diff --git a/app/views/notify/_reassigned_issuable_email.html.haml b/app/views/notify/_reassigned_issuable_email.html.haml
new file mode 100644
index 00000000000..4ab40ff2659
--- /dev/null
+++ b/app/views/notify/_reassigned_issuable_email.html.haml
@@ -0,0 +1,10 @@
+%p
+ Assignee changed
+ - if previous_assignees.any?
+ from
+ %strong= sanitize_name(previous_assignees.map(&:name).to_sentence)
+ to
+ - if issuable.assignees.any?
+ %strong= sanitize_name(issuable.assignee_list)
+ - else
+ %strong Unassigned
diff --git a/app/views/notify/_removal_notification.html.haml b/app/views/notify/_removal_notification.html.haml
new file mode 100644
index 00000000000..590e0d569aa
--- /dev/null
+++ b/app/views/notify/_removal_notification.html.haml
@@ -0,0 +1,9 @@
+- if @domain.remove_at
+ %p
+ Unless you verify your domain by
+ %strong= @domain.remove_at.strftime('%F %T,')
+ it will be removed from your GitLab project.
+- else
+ %p
+ If you no longer wish to use this domain with GitLab Pages, please remove it
+ from your GitLab project and delete any related DNS records.
diff --git a/app/views/notify/autodevops_disabled_email.text.erb b/app/views/notify/autodevops_disabled_email.text.erb
index 695780c3145..bf863952478 100644
--- a/app/views/notify/autodevops_disabled_email.text.erb
+++ b/app/views/notify/autodevops_disabled_email.text.erb
@@ -3,7 +3,7 @@ Auto DevOps pipeline was disabled for <%= @project.name %>
The Auto DevOps pipeline failed for pipeline <%= @pipeline.iid %> (<%= pipeline_url(@pipeline) %>) and has been disabled for <%= @project.name %>. In order to use the Auto DevOps pipeline with your project, please review the currently supported languagues (https://docs.gitlab.com/ee/topics/autodevops/#currently-supported-languages), adjust your project accordingly, and turn on the Auto DevOps pipeline within your CI/CD project settings (<%= project_settings_ci_cd_url(@project) %>).
<% if @pipeline.user -%>
- Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+ Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/changed_milestone_issue_email.html.haml b/app/views/notify/changed_milestone_email.html.haml
index 7d5425fc72d..01d27cac36b 100644
--- a/app/views/notify/changed_milestone_issue_email.html.haml
+++ b/app/views/notify/changed_milestone_email.html.haml
@@ -1,3 +1,5 @@
%p
Milestone changed to
%strong= link_to(@milestone.name, @milestone_url)
+ - if date_range = milestone_date_range(@milestone)
+ = "(#{date_range})"
diff --git a/app/views/notify/changed_milestone_email.text.erb b/app/views/notify/changed_milestone_email.text.erb
new file mode 100644
index 00000000000..a466da4eb19
--- /dev/null
+++ b/app/views/notify/changed_milestone_email.text.erb
@@ -0,0 +1 @@
+Milestone changed to <%= @milestone.name %><% if date_range = milestone_date_range(@milestone) %> (<%= date_range %>)<% end %> ( <%= @milestone_url %> )
diff --git a/app/views/notify/changed_milestone_issue_email.text.erb b/app/views/notify/changed_milestone_issue_email.text.erb
deleted file mode 100644
index c5fc0b61518..00000000000
--- a/app/views/notify/changed_milestone_issue_email.text.erb
+++ /dev/null
@@ -1 +0,0 @@
-Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> )
diff --git a/app/views/notify/changed_milestone_merge_request_email.html.haml b/app/views/notify/changed_milestone_merge_request_email.html.haml
deleted file mode 100644
index 7d5425fc72d..00000000000
--- a/app/views/notify/changed_milestone_merge_request_email.html.haml
+++ /dev/null
@@ -1,3 +0,0 @@
-%p
- Milestone changed to
- %strong= link_to(@milestone.name, @milestone_url)
diff --git a/app/views/notify/changed_milestone_merge_request_email.text.erb b/app/views/notify/changed_milestone_merge_request_email.text.erb
deleted file mode 100644
index c5fc0b61518..00000000000
--- a/app/views/notify/changed_milestone_merge_request_email.text.erb
+++ /dev/null
@@ -1 +0,0 @@
-Milestone changed to <%= @milestone.name %> ( <%= @milestone_url %> )
diff --git a/app/views/notify/closed_issue_email.html.haml b/app/views/notify/closed_issue_email.html.haml
index b7284dd819b..f21cf1ad34b 100644
--- a/app/views/notify/closed_issue_email.html.haml
+++ b/app/views/notify/closed_issue_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was closed by #{@updated_by.name}
+ Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}.
diff --git a/app/views/notify/closed_issue_email.text.haml b/app/views/notify/closed_issue_email.text.haml
index b35d4b7502d..5567adc9165 100644
--- a/app/views/notify/closed_issue_email.text.haml
+++ b/app/views/notify/closed_issue_email.text.haml
@@ -1,3 +1,3 @@
-Issue was closed by #{@updated_by.name}
+Issue was closed by #{sanitize_name(@updated_by.name)} #{closure_reason_text(@closed_via, format: formats.first)}.
Issue ##{@issue.iid}: #{project_issue_url(@issue.project, @issue)}
diff --git a/app/views/notify/closed_merge_request_email.html.haml b/app/views/notify/closed_merge_request_email.html.haml
index 44e018304e1..2aa753e0d55 100644
--- a/app/views/notify/closed_merge_request_email.html.haml
+++ b/app/views/notify/closed_merge_request_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
+ Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/closed_merge_request_email.text.haml b/app/views/notify/closed_merge_request_email.text.haml
index c4e06cb3cb1..6e84f9fb355 100644
--- a/app/views/notify/closed_merge_request_email.text.haml
+++ b/app/views/notify/closed_merge_request_email.text.haml
@@ -1,8 +1,8 @@
-Merge Request #{@merge_request.to_reference} was closed by #{@updated_by.name}
+Merge Request #{@merge_request.to_reference} was closed by #{sanitize_name(@updated_by.name)}
Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/import_issues_csv_email.html.haml b/app/views/notify/import_issues_csv_email.html.haml
new file mode 100644
index 00000000000..f30d2b5f078
--- /dev/null
+++ b/app/views/notify/import_issues_csv_email.html.haml
@@ -0,0 +1,18 @@
+- text_style = 'font-size:16px; text-align:center; line-height:30px;'
+
+%p{ style: text_style }
+ Your CSV import for project
+ %a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none;" }
+ = @project.full_name
+ has been completed.
+
+%p{ style: text_style }
+ #{pluralize(@results[:success], 'issue')} imported.
+
+- if @results[:error_lines].present?
+ %p{ style: text_style }
+ Errors found on line #{'number'.pluralize(@results[:error_lines].size)}: #{@results[:error_lines].join(', ')}. Please check if these lines have an issue title.
+
+- if @results[:parse_error]
+ %p{ style: text_style }
+ Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
diff --git a/app/views/notify/import_issues_csv_email.text.erb b/app/views/notify/import_issues_csv_email.text.erb
new file mode 100644
index 00000000000..1117f90714d
--- /dev/null
+++ b/app/views/notify/import_issues_csv_email.text.erb
@@ -0,0 +1,11 @@
+Your CSV import for project <%= @project.full_name %> (<%= project_url(@project) %>) has been completed.
+
+<%= pluralize(@results[:success], 'issue') %> imported.
+
+<% if @results[:error_lines].present? %>
+Errors found on line <%= 'number'.pluralize(@results[:error_lines].size) %>: <%= @results[:error_lines].join(', ') %>. Please check if these lines have an issue title.
+<% end %>
+
+<% if @results[:parse_error] %>
+Error parsing CSV file. Please make sure it has the correct format: a delimited text file that uses a comma to separate values.
+<% end %>
diff --git a/app/views/notify/issue_due_email.html.haml b/app/views/notify/issue_due_email.html.haml
index e81144b8fcb..08bc98ca05c 100644
--- a/app/views/notify/issue_due_email.html.haml
+++ b/app/views/notify/issue_due_email.html.haml
@@ -3,7 +3,7 @@
- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_list}
+ = assignees_label(@issue)
%p
This issue is due on: #{@issue.due_date.to_s(:medium)}
diff --git a/app/views/notify/issue_due_email.text.erb b/app/views/notify/issue_due_email.text.erb
index 3c7a57a8a2e..ae50b703fe3 100644
--- a/app/views/notify/issue_due_email.text.erb
+++ b/app/views/notify/issue_due_email.text.erb
@@ -2,6 +2,6 @@ The following issue is due on <%= @issue.due_date %>:
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_list %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml
index 472c31e9a5e..b766cb1a523 100644
--- a/app/views/notify/issue_moved_email.html.haml
+++ b/app/views/notify/issue_moved_email.html.haml
@@ -1,6 +1,9 @@
%p
Issue was moved to another project.
-%p
- New issue:
- = link_to project_issue_url(@new_project, @new_issue) do
- = @new_issue.title
+- if @can_access_project
+ %p
+ New issue:
+ = link_to project_issue_url(@new_project, @new_issue) do
+ = @new_issue.title
+- else
+ You don't have access to the project.
diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb
index 66ede43635b..985e689aa9d 100644
--- a/app/views/notify/issue_moved_email.text.erb
+++ b/app/views/notify/issue_moved_email.text.erb
@@ -1,4 +1,8 @@
Issue was moved to another project.
+<% if @can_access_project %>
New issue location:
<%= project_issue_url(@new_project, @new_issue) %>
+<% else %>
+You don't have access to the project.
+<% end %>
diff --git a/app/views/notify/issue_status_changed_email.html.haml b/app/views/notify/issue_status_changed_email.html.haml
index b6051b11cea..66e73a9b03f 100644
--- a/app/views/notify/issue_status_changed_email.html.haml
+++ b/app/views/notify/issue_status_changed_email.html.haml
@@ -1,2 +1,2 @@
%p
- Issue was #{@issue_status} by #{@updated_by.name}
+ Issue was #{@issue_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/issue_status_changed_email.text.erb b/app/views/notify/issue_status_changed_email.text.erb
index 4200881f7e8..f38b09e9820 100644
--- a/app/views/notify/issue_status_changed_email.text.erb
+++ b/app/views/notify/issue_status_changed_email.text.erb
@@ -1,4 +1,4 @@
-Issue was <%= @issue_status %> by <%= @updated_by.name %>
+Issue was <%= @issue_status %> by <%= sanitize_name(@updated_by.name) %>
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
diff --git a/app/views/notify/links/ci/builds/_build.text.erb b/app/views/notify/links/ci/builds/_build.text.erb
index 773ae8174e9..afb02f97e5a 100644
--- a/app/views/notify/links/ci/builds/_build.text.erb
+++ b/app/views/notify/links/ci/builds/_build.text.erb
@@ -1 +1 @@
-Job #<%= build.id %> ( <%= pipeline_job_url(pipeline, build) %> )
+Job #<%= build.id %> ( <%= raw_project_job_url(pipeline.project, build) %> )
diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml
index 18dec806539..1c50dba9c97 100644
--- a/app/views/notify/member_access_granted_email.html.haml
+++ b/app/views/notify/member_access_granted_email.html.haml
@@ -1,3 +1,10 @@
+- link_end = '</a>'.html_safe
+- source_type = member_source.model_name.singular
+- leave_link = polymorphic_url([member_source], leave: 1)
+- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer')
+
%p
- You have been granted #{member.human_access} access to the
- #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}.
+ = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: member.human_access, source_link: source_link, source_type: source_type }
+%p
+ - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link }
+ = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end }
diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb
index a9fb3a589a5..445009bb413 100644
--- a/app/views/notify/member_access_granted_email.text.erb
+++ b/app/views/notify/member_access_granted_email.text.erb
@@ -1,3 +1,8 @@
-You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<% source_type = member_source.model_name.singular %>
+<%= _('You have been granted %{access_level} access to the %{source_name} %{source_type}.') % { access_level: member.human_access, source_name: member_source.human_name, source_type: source_type } %>
<%= member_source.web_url %>
+
+<%= _('If this was a mistake you can leave the %{source_type}.') % { source_type: source_type } %>
+
+<%= polymorphic_url([member_source], leave: 1) %>
diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb
index 9c5ee0eaf26..ddb4a7b3d2c 100644
--- a/app/views/notify/member_access_requested_email.text.erb
+++ b/app/views/notify/member_access_requested_email.text.erb
@@ -1,3 +1,3 @@
-<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<%= sanitize_name(member.user.name) %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= polymorphic_url([member_source, :members]) %>
diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb
index cef87101427..c824533eac2 100644
--- a/app/views/notify/member_invite_accepted_email.text.erb
+++ b/app/views/notify/member_invite_accepted_email.text.erb
@@ -1,3 +1,3 @@
-<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
+<%= member.invite_email %>, now known as <%= sanitize_name(member.user.name) %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>.
<%= member_source.web_url %>
diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb
index 0a6393355be..d944c3b4a50 100644
--- a/app/views/notify/member_invited_email.text.erb
+++ b/app/views/notify/member_invited_email.text.erb
@@ -1,4 +1,4 @@
-You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
+You have been invited <%= "by #{sanitize_name(member.created_by.name)} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>.
Accept invitation: <%= invite_url(@token) %>
Decline invitation: <%= decline_invite_url(@token) %>
diff --git a/app/views/notify/merge_request_status_email.html.haml b/app/views/notify/merge_request_status_email.html.haml
index b487e26b122..ffb416abf72 100644
--- a/app/views/notify/merge_request_status_email.html.haml
+++ b/app/views/notify/merge_request_status_email.html.haml
@@ -1,2 +1,2 @@
%p
- Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
+ Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
diff --git a/app/views/notify/merge_request_status_email.text.haml b/app/views/notify/merge_request_status_email.text.haml
index ae2a2933865..e3b24bbd405 100644
--- a/app/views/notify/merge_request_status_email.text.haml
+++ b/app/views/notify/merge_request_status_email.text.haml
@@ -1,8 +1,8 @@
-Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{@updated_by.name}
+Merge Request #{@merge_request.to_reference} was #{@mr_status} by #{sanitize_name(@updated_by.name)}
Merge Request url: #{project_merge_request_url(@merge_request.target_project, @merge_request)}
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/merge_request_unmergeable_email.text.haml b/app/views/notify/merge_request_unmergeable_email.text.haml
index dcdd6db69d6..e9708a297d7 100644
--- a/app/views/notify/merge_request_unmergeable_email.text.haml
+++ b/app/views/notify/merge_request_unmergeable_email.text.haml
@@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/merged_merge_request_email.text.haml b/app/views/notify/merged_merge_request_email.text.haml
index 661c23bcbe2..d623e701a30 100644
--- a/app/views/notify/merged_merge_request_email.text.haml
+++ b/app/views/notify/merged_merge_request_email.text.haml
@@ -4,5 +4,5 @@ Merge Request url: #{project_merge_request_url(@merge_request.target_project, @m
= merge_path_description(@merge_request, 'to')
-Author: #{@merge_request.author_name}
-Assignee: #{@merge_request.assignee_name}
+Author: #{sanitize_name(@merge_request.author_name)}
+= assignees_label(@merge_request)
diff --git a/app/views/notify/new_gpg_key_email.html.haml b/app/views/notify/new_gpg_key_email.html.haml
index 4b9350c4e88..b857705e01f 100644
--- a/app/views/notify/new_gpg_key_email.html.haml
+++ b/app/views/notify/new_gpg_key_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user.name}!
+ Hi #{sanitize_name(@user.name)}!
%p
A new GPG key was added to your account:
%p
diff --git a/app/views/notify/new_gpg_key_email.text.erb b/app/views/notify/new_gpg_key_email.text.erb
index 80b5a1fd7ff..92ea851eee4 100644
--- a/app/views/notify/new_gpg_key_email.text.erb
+++ b/app/views/notify/new_gpg_key_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
A new GPG key was added to your account:
diff --git a/app/views/notify/new_issue_email.html.haml b/app/views/notify/new_issue_email.html.haml
index e6cdaf85c0d..8aa7939dd0b 100644
--- a/app/views/notify/new_issue_email.html.haml
+++ b/app/views/notify/new_issue_email.html.haml
@@ -4,7 +4,7 @@
- if @issue.assignees.any?
%p
- Assignee: #{@issue.assignee_list}
+ = assignees_label(@issue)
- if @issue.description
%div
diff --git a/app/views/notify/new_issue_email.text.erb b/app/views/notify/new_issue_email.text.erb
index 3c716f77296..ff258711b48 100644
--- a/app/views/notify/new_issue_email.text.erb
+++ b/app/views/notify/new_issue_email.text.erb
@@ -1,7 +1,7 @@
New Issue was created.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_list %>
+Author: <%= sanitize_name(@issue.author_name) %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_issue_email.text.erb b/app/views/notify/new_mention_in_issue_email.text.erb
index 23213106c5b..8e95063b40f 100644
--- a/app/views/notify/new_mention_in_issue_email.text.erb
+++ b/app/views/notify/new_mention_in_issue_email.text.erb
@@ -1,7 +1,7 @@
You have been mentioned in an issue.
Issue <%= @issue.iid %>: <%= url_for(project_issue_url(@issue.project, @issue)) %>
-Author: <%= @issue.author_name %>
-Assignee: <%= @issue.assignee_list %>
+Author: <%= sanitize_name(@issue.author_name) %>
+<%= assignees_label(@issue) %>
<%= @issue.description %>
diff --git a/app/views/notify/new_mention_in_merge_request_email.text.erb b/app/views/notify/new_mention_in_merge_request_email.text.erb
index 6fcebb22fc4..3c78e257a88 100644
--- a/app/views/notify/new_mention_in_merge_request_email.text.erb
+++ b/app/views/notify/new_mention_in_merge_request_email.text.erb
@@ -3,7 +3,7 @@ You have been mentioned in Merge Request <%= @merge_request.to_reference %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
<%= merge_path_description(@merge_request, 'to') %>
-Author: <%= @merge_request.author_name %>
-Assignee: <%= @merge_request.assignee_name %>
+Author: <%= sanitize_name(@merge_request.author_name) %>
+= assignees_label(@merge_request)
<%= @merge_request.description %>
diff --git a/app/views/notify/new_merge_request_email.html.haml b/app/views/notify/new_merge_request_email.html.haml
index 5acd45b74a7..9ab648e2a64 100644
--- a/app/views/notify/new_merge_request_email.html.haml
+++ b/app/views/notify/new_merge_request_email.html.haml
@@ -3,11 +3,11 @@
#{link_to @merge_request.author_name, user_url(@merge_request.author)} created a merge request:
%p.details
- != merge_path_description(@merge_request, '&rarr;')
+ = merge_path_description(@merge_request, '→')
-- if @merge_request.assignee_id.present?
+- if @merge_request.assignees.any?
%p
- Assignee: #{@merge_request.assignee_name}
+ = assignees_label(@merge_request)
= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter
diff --git a/app/views/notify/new_merge_request_email.text.erb b/app/views/notify/new_merge_request_email.text.erb
index 754f4bca1cd..e6c42f1cf5f 100644
--- a/app/views/notify/new_merge_request_email.text.erb
+++ b/app/views/notify/new_merge_request_email.text.erb
@@ -4,7 +4,7 @@ New Merge Request <%= @merge_request.to_reference %>
<%= merge_path_description(@merge_request, 'to') %>
Author: <%= @merge_request.author_name %>
-Assignee: <%= @merge_request.assignee_name %>
+<%= assignees_label(@merge_request) %>
<%= render_if_exists 'notify/merge_request_approvers', presenter: @mr_presenter %>
<%= @merge_request.description %>
diff --git a/app/views/notify/new_ssh_key_email.html.haml b/app/views/notify/new_ssh_key_email.html.haml
index 63b0cbbd205..d031842be95 100644
--- a/app/views/notify/new_ssh_key_email.html.haml
+++ b/app/views/notify/new_ssh_key_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user.name}!
+ Hi #{sanitize_name(@user.name)}!
%p
A new public key was added to your account:
%p
diff --git a/app/views/notify/new_ssh_key_email.text.erb b/app/views/notify/new_ssh_key_email.text.erb
index 05b551c89a0..690357d69ed 100644
--- a/app/views/notify/new_ssh_key_email.text.erb
+++ b/app/views/notify/new_ssh_key_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
A new public key was added to your account:
diff --git a/app/views/notify/new_user_email.html.haml b/app/views/notify/new_user_email.html.haml
index db4424a01f9..dfbb5c75bd3 100644
--- a/app/views/notify/new_user_email.html.haml
+++ b/app/views/notify/new_user_email.html.haml
@@ -1,5 +1,5 @@
%p
- Hi #{@user['name']}!
+ Hi #{sanitize_name(@user['name'])}!
%p
- if Gitlab::CurrentSettings.allow_signup?
Your account has been created successfully.
diff --git a/app/views/notify/new_user_email.text.erb b/app/views/notify/new_user_email.text.erb
index dd9b71e3b84..f3f20f3bfba 100644
--- a/app/views/notify/new_user_email.text.erb
+++ b/app/views/notify/new_user_email.text.erb
@@ -1,4 +1,4 @@
-Hi <%= @user.name %>!
+Hi <%= sanitize_name(@user.name) %>!
The Administrator created an account for you. Now you are a member of the company GitLab application.
diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml
index 34ce4238a12..224b79bfde8 100644
--- a/app/views/notify/pages_domain_disabled_email.html.haml
+++ b/app/views/notify/pages_domain_disabled_email.html.haml
@@ -10,6 +10,4 @@
If this domain has been disabled in error, please follow
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
to verify and re-enable your domain.
-%p
- If you no longer wish to use this domain with GitLab Pages, please remove it
- from your GitLab project and delete any related DNS records.
+= render 'removal_notification'
diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml
index 0bb0eb09fd5..03b298f8e7c 100644
--- a/app/views/notify/pages_domain_verification_failed_email.html.haml
+++ b/app/views/notify/pages_domain_verification_failed_email.html.haml
@@ -12,6 +12,4 @@
Please visit
= link_to 'these instructions', help_page_url('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
for more information about custom domain verification.
-%p
- If you no longer wish to use this domain with GitLab Pages, please remove it
- from your GitLab project and delete any related DNS records.
+= render 'removal_notification'
diff --git a/app/views/notify/pipeline_failed_email.text.erb b/app/views/notify/pipeline_failed_email.text.erb
index 294238eee51..722eedf90be 100644
--- a/app/views/notify/pipeline_failed_email.text.erb
+++ b/app/views/notify/pipeline_failed_email.text.erb
@@ -10,20 +10,20 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
Commit Message: <%= @pipeline.git_commit_message.truncate(50) %>
<% commit = @pipeline.commit -%>
<% if commit.author -%>
-Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> )
+Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> )
<% else -%>
Commit Author: <%= commit.author_name %>
<% end -%>
<% if commit.different_committer? -%>
<% if commit.committer -%>
-Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> )
+Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> )
<% else -%>
Committed by: <%= commit.committer_name %>
<% end -%>
<% end -%>
<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/pipeline_success_email.text.erb b/app/views/notify/pipeline_success_email.text.erb
index 39622cf7f02..9aadf380f79 100644
--- a/app/views/notify/pipeline_success_email.text.erb
+++ b/app/views/notify/pipeline_success_email.text.erb
@@ -10,13 +10,13 @@ Commit: <%= @pipeline.short_sha %> ( <%= commit_url(@pipeline) %> )
Commit Message: <%= @pipeline.git_commit_message.truncate(50) %>
<% commit = @pipeline.commit -%>
<% if commit.author -%>
-Commit Author: <%= commit.author.name %> ( <%= user_url(commit.author) %> )
+Commit Author: <%= sanitize_name(commit.author.name) %> ( <%= user_url(commit.author) %> )
<% else -%>
Commit Author: <%= commit.author_name %>
<% end -%>
<% if commit.different_committer? -%>
<% if commit.committer -%>
-Committed by: <%= commit.committer.name %> ( <%= user_url(commit.committer) %> )
+Committed by: <%= sanitize_name(commit.committer.name) %> ( <%= user_url(commit.committer) %> )
<% else -%>
Committed by: <%= commit.committer_name %>
<% end -%>
@@ -25,7 +25,7 @@ Committed by: <%= commit.committer_name %>
<% job_count = @pipeline.total_size -%>
<% stage_count = @pipeline.stages_count -%>
<% if @pipeline.user -%>
-Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= @pipeline.user.name %> ( <%= user_url(@pipeline.user) %> )
+Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by <%= sanitize_name(@pipeline.user.name) %> ( <%= user_url(@pipeline.user) %> )
<% else -%>
Pipeline #<%= @pipeline.id %> ( <%= pipeline_url(@pipeline) %> ) triggered by API
<% end -%>
diff --git a/app/views/notify/push_to_merge_request_email.html.haml b/app/views/notify/push_to_merge_request_email.html.haml
index 67744ec1cee..97258833cfc 100644
--- a/app/views/notify/push_to_merge_request_email.html.haml
+++ b/app/views/notify/push_to_merge_request_email.html.haml
@@ -1,5 +1,5 @@
%h3
- = @updated_by_user.name
+ = sanitize_name(@updated_by_user.name)
pushed new commits to merge request
= link_to(@merge_request.to_reference, project_merge_request_url(@merge_request.target_project, @merge_request))
diff --git a/app/views/notify/push_to_merge_request_email.text.haml b/app/views/notify/push_to_merge_request_email.text.haml
index 95759d127e2..10c8e158846 100644
--- a/app/views/notify/push_to_merge_request_email.text.haml
+++ b/app/views/notify/push_to_merge_request_email.text.haml
@@ -1,4 +1,4 @@
-#{@updated_by_user.name} pushed new commits to merge request #{@merge_request.to_reference}
+#{sanitize_name(@updated_by_user.name)} pushed new commits to merge request #{@merge_request.to_reference}
\
#{url_for(project_merge_request_url(@merge_request.target_project, @merge_request))}
\
diff --git a/app/views/notify/reassigned_issue_email.html.haml b/app/views/notify/reassigned_issue_email.html.haml
index ee2f40e1683..6b088927623 100644
--- a/app/views/notify/reassigned_issue_email.html.haml
+++ b/app/views/notify/reassigned_issue_email.html.haml
@@ -1,10 +1 @@
-%p
- Assignee changed
- - if @previous_assignees.any?
- from
- %strong= @previous_assignees.map(&:name).to_sentence
- to
- - if @issue.assignees.any?
- %strong= @issue.assignee_list
- - else
- %strong Unassigned
+= render 'reassigned_issuable_email', issuable: @issue, previous_assignees: @previous_assignees
diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb
index 6c357f1074a..7bf2e8e6ce3 100644
--- a/app/views/notify/reassigned_issue_email.text.erb
+++ b/app/views/notify/reassigned_issue_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Issue <%= @issue.iid %>
<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %>
-Assignee changed <%= "from #{@previous_assignees.map(&:name).to_sentence}" if @previous_assignees.any? -%>
+Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/reassigned_merge_request_email.html.haml b/app/views/notify/reassigned_merge_request_email.html.haml
index 24c2b08810b..0aefca6b14a 100644
--- a/app/views/notify/reassigned_merge_request_email.html.haml
+++ b/app/views/notify/reassigned_merge_request_email.html.haml
@@ -1,10 +1 @@
-%p
- Assignee changed
- - if @previous_assignee
- from
- %strong= @previous_assignee.name
- to
- - if @merge_request.assignee_id
- %strong= @merge_request.assignee_name
- - else
- %strong Unassigned
+= render 'reassigned_issuable_email', issuable: @merge_request, previous_assignees: @previous_assignees
diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb
index 998a40fefde..82ec7aa0fa4 100644
--- a/app/views/notify/reassigned_merge_request_email.text.erb
+++ b/app/views/notify/reassigned_merge_request_email.text.erb
@@ -2,5 +2,5 @@ Reassigned Merge Request <%= @merge_request.iid %>
<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %>
-Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%>
- to <%= "#{@merge_request.assignee_id ? @merge_request.assignee_name : 'Unassigned'}" %>
+Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%>
+ to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %>
diff --git a/app/views/notify/resolved_all_discussions_email.html.haml b/app/views/notify/resolved_all_discussions_email.html.haml
index 522421b7cc3..502b8f21e35 100644
--- a/app/views/notify/resolved_all_discussions_email.html.haml
+++ b/app/views/notify/resolved_all_discussions_email.html.haml
@@ -1,2 +1,2 @@
%p
- All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{@resolved_by.name}
+ All discussions on Merge Request #{@merge_request.to_reference} were resolved by #{sanitize_name(@resolved_by.name)}
diff --git a/app/views/notify/resolved_all_discussions_email.text.erb b/app/views/notify/resolved_all_discussions_email.text.erb
index 2881f3e699e..c4b36bfe1a8 100644
--- a/app/views/notify/resolved_all_discussions_email.text.erb
+++ b/app/views/notify/resolved_all_discussions_email.text.erb
@@ -1,3 +1,3 @@
-All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= @resolved_by.name %>
+All discussions on Merge Request <%= @merge_request.to_reference %> were resolved by <%= sanitize_name(@resolved_by.name) %>
<%= url_for(project_merge_request_url(@merge_request.target_project, @merge_request)) %>
diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml
new file mode 100644
index 00000000000..fb4da08e129
--- /dev/null
+++ b/app/views/profiles/_email_settings.html.haml
@@ -0,0 +1,16 @@
+- form = local_assigns.fetch(:form)
+- readonly = @user.read_only_attribute?(:email)
+- email_change_disabled = local_assigns.fetch(:email_change_disabled, nil)
+- read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user)
+- help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text
+
+= form.text_field :email, required: true, class: 'input-lg', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled
+= form.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
+ { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") },
+ control_class: 'select2 input-lg', disabled: email_change_disabled
+- commit_email_link_url = help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank')
+- commit_email_link_start = '<a href="%{url}">'.html_safe % { url: commit_email_link_url }
+- commit_email_docs_link = s_('Profiles|This email will be used for web based operations, such as edits and merges. %{commit_email_link_start}Learn more%{commit_email_link_end}').html_safe % { commit_email_link_start: commit_email_link_start, commit_email_link_end: '</a>'.html_safe }
+= form.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
+ { help: commit_email_docs_link },
+ control_class: 'select2 input-lg', disabled: email_change_disabled
diff --git a/app/views/profiles/_event_table.html.haml b/app/views/profiles/_event_table.html.haml
index 9f525547dd9..977ff30d5a6 100644
--- a/app/views/profiles/_event_table.html.haml
+++ b/app/views/profiles/_event_table.html.haml
@@ -1,14 +1,12 @@
%h5.prepend-top-0
- History of authentications
+ = _('History of authentications')
%ul.content-list
- events.each do |event|
%li
%span.description
= audit_icon(event.details[:with], class: "append-right-5")
- Signed in with
- = event.details[:with]
- authentication
+ = _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]}
%span.float-right= time_ago_with_tooltip(event.created_at)
= paginate events, theme: "gitlab"
diff --git a/app/views/profiles/accounts/_providers.html.haml b/app/views/profiles/accounts/_providers.html.haml
new file mode 100644
index 00000000000..068f9cc70f7
--- /dev/null
+++ b/app/views/profiles/accounts/_providers.html.haml
@@ -0,0 +1,21 @@
+%label.label-bold
+ = s_('Profiles|Connected Accounts')
+ %p= s_('Profiles|Click on icon to activate signin with one of the following services')
+ - providers.each do |provider|
+ - unlink_allowed = unlink_provider_allowed?(provider)
+ - link_allowed = link_provider_allowed?(provider)
+ - if unlink_allowed || link_allowed
+ .provider-btn-group
+ .provider-btn-image
+ = provider_image_tag(provider)
+ - if auth_active?(provider)
+ - if unlink_allowed
+ = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
+ = s_('Profiles|Disconnect')
+ - else
+ %a.provider-btn
+ = s_('Profiles|Active')
+ - elsif link_allowed
+ = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do
+ = s_('Profiles|Connect')
+ = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: group_saml_identities
diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml
index e167e094240..e6380817c8f 100644
--- a/app/views/profiles/accounts/show.html.haml
+++ b/app/views/profiles/accounts/show.html.haml
@@ -1,62 +1,45 @@
-- page_title "Account"
+- page_title _('Account')
- @content_class = "limit-container-width" unless fluid_layout
- if current_user.ldap_user?
.alert.alert-info
- Some options are unavailable for LDAP accounts
+ = s_('Profiles|Some options are unavailable for LDAP accounts')
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Two-Factor Authentication
+ = s_('Profiles|Two-Factor Authentication')
%p
- Increase your account's security by enabling Two-Factor Authentication (2FA).
+ = s_("Profiles|Increase your account's security by enabling Two-Factor Authentication (2FA)")
.col-lg-8
%p
- Status: #{current_user.two_factor_enabled? ? 'Enabled' : 'Disabled'}
+ #{_('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'
- 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 display_providers_on_profile?
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Social sign-in
+ = s_('Profiles|Social sign-in')
%p
- Activate signin with one of the following services
+ = s_('Profiles|Activate signin with one of the following services')
.col-lg-8
- %label.label-bold
- Connected Accounts
- %p Click on icon to activate signin with one of the following services
- - button_based_providers.each do |provider|
- .provider-btn-group
- .provider-btn-image
- = provider_image_tag(provider)
- - if auth_active?(provider)
- - if unlink_allowed?(provider)
- = link_to unlink_profile_account_path(provider: provider), method: :delete, class: 'provider-btn' do
- Disconnect
- - else
- %a.provider-btn
- Active
- - else
- = link_to omniauth_authorize_path(:user, provider), method: :post, class: 'provider-btn not-active' do
- Connect
- = render_if_exists 'profiles/accounts/group_saml_unlink_buttons', group_saml_identities: local_assigns[:group_saml_identities]
+ = render 'providers', providers: button_based_providers, group_saml_identities: local_assigns[:group_saml_identities]
%hr
- if current_user.can_change_username?
.row.prepend-top-default
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0.warning-title
- Change username
+ = s_('Profiles|Change username')
%p
- Changing your username can have unintended side effects.
+ = s_('Profiles|Changing your username can have unintended side effects.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
+ = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'changing-your-username'), target: '_blank'
.col-lg-8
- data = { initial_username: current_user.username, root_url: root_url, action_url: update_username_profile_path(format: :json) }
#update-username{ data: data }
diff --git a/app/views/profiles/active_sessions/_active_session.html.haml b/app/views/profiles/active_sessions/_active_session.html.haml
index 23ef31a0c85..2bf514d72a5 100644
--- a/app/views/profiles/active_sessions/_active_session.html.haml
+++ b/app/views/profiles/active_sessions/_active_session.html.haml
@@ -23,9 +23,3 @@
%strong Signed in
on
= l(active_session.created_at, format: :short)
-
- - unless is_current_session
- .float-right
- = link_to profile_active_session_path(active_session.session_id), data: { confirm: 'Are you sure? The device will be signed out of GitLab.' }, method: :delete, class: "btn btn-danger prepend-left-10" do
- %span.sr-only Revoke
- Revoke
diff --git a/app/views/profiles/audit_log.html.haml b/app/views/profiles/audit_log.html.haml
index a924369050b..275c0428d34 100644
--- a/app/views/profiles/audit_log.html.haml
+++ b/app/views/profiles/audit_log.html.haml
@@ -1,4 +1,4 @@
-- page_title "Authentication log"
+- page_title _('Authentication log')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,6 +6,6 @@
%h4.prepend-top-0
= page_title
%p
- This is a security log of important events involving your account.
+ = _('This is a security log of important events involving your account.')
.col-lg-8
= render 'event_table', events: @events
diff --git a/app/views/profiles/chat_names/_chat_name.html.haml b/app/views/profiles/chat_names/_chat_name.html.haml
index 9e82e47c1e1..ff67f92ad07 100644
--- a/app/views/profiles/chat_names/_chat_name.html.haml
+++ b/app/views/profiles/chat_names/_chat_name.html.haml
@@ -21,7 +21,7 @@
- if chat_name.last_used_at
= time_ago_with_tooltip(chat_name.last_used_at)
- else
- Never
+ = _('Never')
%td
- = link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: 'Are you sure you want to revoke this nickname?' }
+ = link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
diff --git a/app/views/profiles/chat_names/index.html.haml b/app/views/profiles/chat_names/index.html.haml
index 4b6e419af50..0c8098a97d5 100644
--- a/app/views/profiles/chat_names/index.html.haml
+++ b/app/views/profiles/chat_names/index.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Chat'
+- page_title _('Chat')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
@@ -6,7 +6,7 @@
%h4.prepend-top-0
= page_title
%p
- You can see your Chat accounts.
+ = _('You can see your chat accounts.')
.col-lg-8
%h5 Active chat names (#{@chat_names.size})
@@ -16,15 +16,15 @@
%table.table.chat-names
%thead
%tr
- %th Project
- %th Service
- %th Team domain
- %th Nickname
- %th Last used
+ %th= _('Project')
+ %th= _('Service')
+ %th= _('Team domain')
+ %th= _('Nickname')
+ %th= _('Last used')
%th
%tbody
= render @chat_names
- else
.settings-message.text-center
- You don't have any active chat names.
+ = _("You don't have any active chat names.")
diff --git a/app/views/profiles/notifications/_email_settings.html.haml b/app/views/profiles/notifications/_email_settings.html.haml
new file mode 100644
index 00000000000..34dcf8f5402
--- /dev/null
+++ b/app/views/profiles/notifications/_email_settings.html.haml
@@ -0,0 +1,6 @@
+- form = local_assigns.fetch(:form)
+.form-group
+ = form.label :notification_email, class: "label-bold"
+ = form.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2", disabled: local_assigns.fetch(:email_change_disabled, nil)
+ .help-block
+ = local_assigns.fetch(:help_text, nil)
diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml
index 712eb2a4573..fa35fbd3961 100644
--- a/app/views/profiles/notifications/show.html.haml
+++ b/app/views/profiles/notifications/show.html.haml
@@ -1,4 +1,4 @@
-- page_title "Notifications"
+- page_title _('Notifications')
- @content_class = "limit-container-width" unless fluid_layout
%div
@@ -14,17 +14,15 @@
%h4.prepend-top-0
= page_title
%p
- You can specify notification level per group or per project.
+ = _('You can specify notification level per group or per project.')
%p
- By default, all projects and groups will use the global notifications setting.
+ = _('By default, all projects and groups will use the global notifications setting.')
.col-lg-8
%h5.prepend-top-0
- Global notification settings
+ = _('Global notification settings')
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
- .form-group
- = f.label :notification_email, class: "label-bold"
- = f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2"
+ = render_if_exists 'profiles/notifications/email_settings', form: f
= label_tag :global_notification_level, "Global notification level", class: "label-bold"
%br
@@ -37,19 +35,19 @@
= form_for @user, url: profile_notifications_path, method: :put do |f|
%label{ for: 'user_notified_of_own_activity' }
= f.check_box :notified_of_own_activity
- %span Receive notifications about your own activity
+ %span= _('Receive notifications about your own activity')
%hr
%h5
- Groups (#{@group_notifications.count})
+ = _('Groups (%{count})') % { count: @group_notifications.count }
%div
%ul.bordered-list
- @group_notifications.each do |setting|
= render 'group_settings', setting: setting, group: setting.source
%h5
- Projects (#{@project_notifications.count})
+ = _('Projects (%{count})') % { count: @project_notifications.count }
%p.account-well
- To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.
+ = _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
.append-bottom-default
%ul.bordered-list
- @project_notifications.each do |setting|
diff --git a/app/views/profiles/passwords/new.html.haml b/app/views/profiles/passwords/new.html.haml
index d265f3c44ba..081166270ab 100644
--- a/app/views/profiles/passwords/new.html.haml
+++ b/app/views/profiles/passwords/new.html.haml
@@ -1,25 +1,31 @@
-- page_title "New Password"
-- header_title "New Password"
-%h3.page-title Set up new password
+- page_title _('New Password')
+- breadcrumb_title _('New Password')
+
+%h3.page-title= _('Set up new password')
%hr
= form_for @user, url: profile_password_path, method: :post do |f|
%p.slead
- Please set a new password before proceeding.
+ = _('Please set a new password before proceeding.')
%br
- After a successful password update you will be redirected to login screen.
+ = _('After a successful password update you will be redirected to login screen.')
= form_errors(@user)
- unless @user.password_automatically_set?
.form-group.row
- = f.label :current_password, class: 'col-form-label col-sm-2'
- .col-sm-10= f.password_field :current_password, required: true, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :current_password
+ .col-sm-10
+ = f.password_field :current_password, required: true, class: 'form-control'
.form-group.row
- = f.label :password, class: 'col-form-label col-sm-2'
- .col-sm-10= f.password_field :password, required: true, class: 'form-control'
+ .col-sm-2.col-form-label
+ = f.label :password
+ .col-sm-10
+ = f.password_field :password, required: true, class: 'form-control'
.form-group.row
- = f.label :password_confirmation, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :password_confirmation
.col-sm-10
= f.password_field :password_confirmation, required: true, class: 'form-control'
.form-actions
- = f.submit 'Set new password', class: "btn btn-success"
+ = f.submit _('Set new password'), class: 'btn btn-success'
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index 7c378633667..46384bc28ef 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -1,11 +1,11 @@
-- page_title 'Preferences'
+- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme
%h4.prepend-top-0
= s_('Preferences|Navigation theme')
- %p Customize the appearance of the application header and navigation sidebar.
+ %p= _('Customize the appearance of the application header and navigation sidebar.')
.col-lg-8.application-theme
- Gitlab::Themes.each do |theme|
= label_tag do
@@ -18,11 +18,11 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Syntax highlighting theme
+ = _('Syntax highlighting theme')
%p
- This setting allows you to customize the appearance of the syntax.
+ = _('This setting allows you to customize the appearance of the syntax.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
.col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
@@ -35,27 +35,78 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
- Behavior
+ = _('Behavior')
%p
- This setting allows you to customize the behavior of the system layout and default views.
+ = _('This setting allows you to customize the behavior of the system layout and default views.')
= succeed '.' do
- = link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
.col-lg-8
.form-group
= f.label :layout, class: 'label-bold' do
- Layout width
+ = _('Layout width')
= f.select :layout, layout_choices, {}, class: 'form-control'
.form-text.text-muted
- Choose between fixed (max. 1280px) and fluid (100%) application layout.
+ = _('Choose between fixed (max. 1280px) and fluid (100%%) application layout.')
.form-group
= f.label :dashboard, class: 'label-bold' do
- Default dashboard
+ = _('Default dashboard')
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
+
+ = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
+
.form-group
= f.label :project_view, class: 'label-bold' do
- Project overview content
+ = _('Project overview content')
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.form-text.text-muted
- Choose what content you want to see on a project’s overview page.
+ = _('Choose what content you want to see on a project’s overview page.')
+
+ .col-sm-12
+ %hr
+
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0
+ = _('Localization')
+ %p
+ = _('Customize language and region related settings.')
+ = succeed '.' do
+ = link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'localization'), target: '_blank'
+ .col-lg-8
+ .form-group
+ = f.label :preferred_language, class: 'label-bold' do
+ = _('Language')
+ = f.select :preferred_language, language_choices, {}, class: 'select2'
+ .form-text.text-muted
+ = s_('Preferences|This feature is experimental and translations are not complete yet')
+ .form-group
+ = f.label :first_day_of_week, class: 'label-bold' do
+ = _('First day of the week')
+ = f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control'
+ - if Feature.enabled?(:user_time_settings)
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_('Preferences|Time preferences')
+ %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
+ .col-lg-8
+ .form-group
+ %h5= s_('Preferences|Time format')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_format_label = capture do
+ = s_('Preferences|Display time in 24-hour format')
+ = f.check_box :time_format_in_24h, class: 'form-check-input'
+ = f.label :time_format_in_24h do
+ = time_format_label
+ %h5= s_('Preferences|Time display')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_display_label = capture do
+ = s_('Preferences|Use relative times')
+ = f.check_box :time_display_relative, class: 'form-check-input'
+ = f.label :time_display_relative do
+ = time_display_label
+ .text-muted
+ = s_('Preferences|For example: 30 mins ago.')
+ .col-lg-4.profile-settings-sidebar
+ .col-lg-8
.form-group
- = f.submit 'Save changes', class: 'btn btn-success'
+ = f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 2603c558c0f..e36d5192a29 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -2,7 +2,7 @@
- @content_class = "limit-container-width" unless fluid_layout
- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host
-= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f|
+= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f|
= form_errors(@user)
.row
@@ -47,9 +47,9 @@
- if @user.status
= emoji_icon @user.status.emoji
%span#js-no-emoji-placeholder.no-emoji-placeholder{ class: ('hidden' if @user.status) }
- = sprite_icon('emoji_slightly_smiling_face', css_class: 'award-control-icon-neutral')
- = sprite_icon('emoji_smiley', css_class: 'award-control-icon-positive')
- = sprite_icon('emoji_smile', css_class: 'award-control-icon-super-positive')
+ = sprite_icon('slight-smile', css_class: 'award-control-icon-neutral')
+ = sprite_icon('smiley', css_class: 'award-control-icon-positive')
+ = sprite_icon('smile', css_class: 'award-control-icon-super-positive')
- reset_message_button = button_tag type: :button,
id: 'js-clear-user-status-button',
class: 'clear-user-status btn has-tooltip',
@@ -64,6 +64,18 @@
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
+ - if Feature.enabled?(:user_time_settings)
+ %hr
+ .row.user-time-preferences
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_("Profiles|Time settings")
+ %p= s_("Profiles|You can set your current timezone here")
+ .col-lg-8
+ -# TODO: might need an entry in user/profile.md to describe some of these settings
+ -# https://gitlab.com/gitlab-org/gitlab-ce/issues/60070
+ %h5= ("Time zone")
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
%hr
.row
@@ -71,54 +83,40 @@
%h4.prepend-top-0
= s_("Profiles|Main settings")
%p
- = s_("Profiles|This information will appear on your profile.")
+ = s_("Profiles|This information will appear on your profile")
- if current_user.ldap_user?
= s_("Profiles|Some options are unavailable for LDAP accounts")
.col-lg-8
.row
- if @user.read_only_attribute?(:name)
- = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' },
- help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) }
+ = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9 qa-full-name' },
+ help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you") % { provider_label: attribute_provider_label(:name) }
- else
- = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you."
- = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' }
+ = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you")
+ = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' }
- - if @user.read_only_attribute?(:email)
- = f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) }
- - else
- = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?),
- help: user_email_help_text(@user)
- = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email),
- { help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") },
- control_class: 'select2'
- - commit_email_docs_link = link_to s_('Profiles|Learn more'), help_page_path('user/profile/index', anchor: 'commit-email', target: '_blank')
- = f.select :commit_email, options_for_select(commit_email_select_options(@user), selected: selected_commit_email(@user)),
- { help: s_("Profiles|This email will be used for web based operations, such as edits and merges. %{learn_more}").html_safe % { learn_more: commit_email_docs_link } },
- control_class: 'select2'
- = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] },
- { help: s_("Profiles|This feature is experimental and translations are not complete yet.") },
- control_class: 'select2'
- = f.text_field :skype
- = f.text_field :linkedin
- = f.text_field :twitter
- = f.text_field :website_url, label: s_("Profiles|Website")
+ = render_if_exists 'profiles/email_settings', form: f
+ = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username")
+ = f.text_field :linkedin, class: 'input-md', help: s_("Profiles|Your LinkedIn profile name from linkedin.com/in/profilename")
+ = f.text_field :twitter, class: 'input-md', placeholder: s_("Profiles|@username")
+ = f.text_field :website_url, class: 'input-lg', placeholder: s_("Profiles|website.com")
- if @user.read_only_attribute?(:location)
- = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) }
+ = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:location) }
- else
- = f.text_field :location
- = f.text_field :organization
- = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.")
+ = f.text_field :location, label: s_('Profiles|Location'), class: 'input-lg', placeholder: s_("Profiles|City, country")
+ = f.text_field :organization, label: s_('Profiles|Organization'), class: 'input-md', help: s_("Profiles|Who you represent or work for")
+ = f.text_area :bio, label: s_('Profiles|Bio'), rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters")
%hr
- %h5= ("Private profile")
+ %h5= s_("Private profile")
.checkbox-icon-inline-wrapper
- private_profile_label = capture do
= s_("Profiles|Don't display activity-related personal information on your profiles")
- = f.check_box :private_profile, label: private_profile_label
+ = f.check_box :private_profile, label: private_profile_label, inline: true, wrapper_class: 'mr-0'
= link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile')
%h5= s_("Profiles|Private contributions")
- = f.check_box :include_private_contributions, label: 'Include private contributions on my profile'
+ = f.check_box :include_private_contributions, label: s_('Profiles|Include private contributions on my profile'), wrapper_class: 'mb-2', inline: true
.help-block
- = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.")
+ = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information")
.prepend-top-default.append-bottom-default
= f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success'
= link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel'
diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml
index 94ec0cc5db8..d986c566928 100644
--- a/app/views/profiles/two_factor_auths/show.html.haml
+++ b/app/views/profiles/two_factor_auths/show.html.haml
@@ -25,7 +25,8 @@
- else
%p
- Download the Google Authenticator application from App Store or Google Play Store and scan this code.
+ Install a soft token authenticator like <a href="https://freeotp.github.io/">FreeOTP</a>
+ or Google Authenticator from your application repository and scan this QR code.
More information is available in the #{link_to('documentation', help_page_path('user/profile/account/two_factor_authentication'))}.
.row.append-bottom-10
.col-md-4
diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml
index 6bf21570d41..12da62f4c64 100644
--- a/app/views/projects/_activity.html.haml
+++ b/app/views/projects/_activity.html.haml
@@ -1,8 +1,8 @@
%div{ class: container_class }
- .nav-block.activity-filter-block.activities
+ .nav-block.d-none.d-sm-flex.activities
= render 'shared/event_filter'
.controls
- = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn rss-btn has-tooltip' do
+ = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn d-none d-sm-inline-block has-tooltip' do
= icon('rss')
.content_list.project-activity{ :"data-href" => activity_project_path(@project) }
diff --git a/app/views/projects/_classification_policy_settings.html.haml b/app/views/projects/_classification_policy_settings.html.haml
new file mode 100644
index 00000000000..5a766ab024f
--- /dev/null
+++ b/app/views/projects/_classification_policy_settings.html.haml
@@ -0,0 +1,6 @@
+- if ::Gitlab::ExternalAuthorization.enabled?
+ .form-group.col-md-9
+ = f.label :external_authorization_classification_label, _('Classification Label (optional)'), class: 'label-bold'
+ = f.text_field :external_authorization_classification_label, class: "form-control"
+ %span.form-text.text-muted
+ = external_classification_label_help_message
diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml
index aa980da7e95..1056977886a 100644
--- a/app/views/projects/_export.html.haml
+++ b/app/views/projects/_export.html.haml
@@ -1,41 +1,33 @@
- return unless Gitlab::CurrentSettings.project_export_enabled?
- project = local_assigns.fetch(:project)
-- expanded = Rails.env.test?
-%section.settings.no-animate#js-export-project{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Export project
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.
- .settings-content
- .bs-callout.bs-callout-info
- %p.append-bottom-0
- %p
- The following items will be exported:
- %ul
- %li Project and wiki repositories
- %li Project uploads
- %li Project configuration including web hooks and services
- %li Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities
- %li LFS objects
- %p
- The following items will NOT be exported:
- %ul
- %li Job traces and artifacts
- %li Container registry images
- %li CI variables
- %li Any encrypted tokens
- %p
- Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.
- - if project.export_status == :finished
- = link_to 'Download export', download_export_project_path(project),
- rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
- = link_to 'Generate new export', generate_new_export_project_path(project),
- method: :post, class: "btn btn-default"
- - else
- = link_to 'Export project', export_project_path(project),
- method: :post, class: "btn btn-default"
+.sub-section
+ %h4= _('Export project')
+ %p= _('Export this project with all its related data in order to move your project to a new GitLab instance. Once the export is finished, you can import the file from the "New Project" page.')
+
+ .bs-callout.bs-callout-info
+ %p.append-bottom-0
+ %p= _('The following items will be exported:')
+ %ul
+ %li= _('Project and wiki repositories')
+ %li= _('Project uploads')
+ %li= _('Project configuration, including services')
+ %li= _('Issues with comments, merge requests with diffs and comments, labels, milestones, snippets, and other project entities')
+ %li= _('LFS objects')
+ %p= _('The following items will NOT be exported:')
+ %ul
+ %li= _('Job traces and artifacts')
+ %li= _('Container registry images')
+ %li= _('CI variables')
+ %li= _('Webhooks')
+ %li= _('Any encrypted tokens')
+ %p= _('Once the exported file is ready, you will receive a notification email with a download link, or you can download it from this page.')
+ - if project.export_status == :finished
+ = link_to _('Download export'), download_export_project_path(project),
+ rel: 'nofollow', download: '', method: :get, class: "btn btn-default"
+ = link_to _('Generate new export'), generate_new_export_project_path(project),
+ method: :post, class: "btn btn-default"
+ - else
+ = link_to _('Export project'), export_project_path(project),
+ method: :post, class: "btn btn-default"
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 22a721ee9ad..bb46b440c18 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -4,6 +4,7 @@
- project = local_assigns.fetch(:project) { @project }
- content_url = local_assigns.fetch(:content_url) { @tree.readme ? project_blob_path(@project, tree_join(@ref, @tree.readme.path)) : project_tree_path(@project, @ref) }
- show_auto_devops_callout = show_auto_devops_callout?(@project)
+- vue_file_list = Feature.enabled?(:vue_file_list, @project)
#tree-holder.tree-holder.clearfix
.nav-block
@@ -13,7 +14,12 @@
= render 'shared/commit_well', commit: commit, ref: ref, project: project
- if is_project_overview
- .project-buttons.append-bottom-default
+ .project-buttons.append-bottom-default{ class: ("js-hide-on-navigation" if vue_file_list) }
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
+ - if vue_file_list
+ #js-tree-list{ data: { project_path: @project.full_path, full_name: @project.name_with_namespace, ref: ref } }
+ - if @tree.readme
+ = render "projects/tree/readme", readme: @tree.readme
+ - else
+ = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml
index 7a5fff96676..b2dab0b5348 100644
--- a/app/views/projects/_flash_messages.html.haml
+++ b/app/views/projects/_flash_messages.html.haml
@@ -5,4 +5,6 @@
- if current_user && can?(current_user, :download_code, project)
= render 'shared/no_ssh'
= render 'shared/no_password'
- = render 'shared/auto_devops_implicitly_enabled_banner', project: project
+ - unless project.empty_repo?
+ = render 'shared/auto_devops_implicitly_enabled_banner', project: project
+ = render_if_exists 'projects/above_size_limit_warning', project: project
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index e191b009db2..a97322dace4 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,17 +1,18 @@
- empty_repo = @project.empty_repo?
- show_auto_devops_callout = show_auto_devops_callout?(@project)
-.project-home-panel{ class: ("empty-project" if empty_repo) }
- .project-header.row.append-bottom-8
- .project-title-row.col-md-12.col-lg-7.d-flex
- .avatar-container.project-avatar.float-none
+- max_project_topic_length = 15
+.project-home-panel{ class: [("empty-project" if empty_repo), ("js-hide-on-navigation" if Feature.enabled?(:vue_file_list, @project))] }
+ .row.append-bottom-8
+ .home-panel-title-row.col-md-12.col-lg-6.d-flex
+ .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none
= project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64)
.d-flex.flex-column.flex-wrap.align-items-baseline
.d-inline-flex.align-items-baseline
- %h1.project-title.qa-project-name
+ %h1.home-panel-title.prepend-top-8.append-bottom-5.qa-project-name
= @project.name
- %span.project-visibility.prepend-left-8.d-inline-flex.align-items-baseline.visibility-icon.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
+ %span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
- .project-metadata.d-flex.align-items-center
+ .home-panel-metadata.d-flex.flex-wrap.text-secondary
- if can?(current_user, :read_project, @project)
%span.text-secondary
= s_('ProjectPage|Project ID: %{project_id}') % { project_id: @project.id }
@@ -19,16 +20,28 @@
%span.access-request-links.prepend-left-8
= render 'shared/members/access_request_links', source: @project
- if @project.tag_list.present?
- %span.project-tag-list.d-inline-flex.prepend-left-8.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_tags? ? @project.tag_list.join(', ') : nil }
+ %span.home-panel-topic-list.mt-2.w-100.d-inline-flex
= sprite_icon('tag', size: 16, css_class: 'icon append-right-4')
- = @project.tags_to_show
- - if @project.has_extra_tags?
- = _("+ %{count} more") % { count: @project.count_of_extra_tags_not_shown }
- .project-repo-buttons.col-md-12.col-lg-5.d-inline-flex.flex-wrap.justify-content-lg-end
+ - @project.topics_to_show.each do |topic|
+ - project_topics_classes = "badge badge-pill badge-secondary append-right-5"
+ - explore_project_topic_path = explore_projects_path(tag: topic)
+ - if topic.length > max_project_topic_length
+ %a{ class: "#{ project_topics_classes } str-truncated-30 has-tooltip", data: { container: "body" }, title: topic, href: explore_project_topic_path }
+ = topic.titleize
+ - else
+ %a{ class: project_topics_classes, href: explore_project_topic_path }
+ = topic.titleize
+
+ - if @project.has_extra_topics?
+ .text-nowrap.has-tooltip{ data: { container: 'body' }, title: @project.has_extra_topics? ? @project.topics_not_shown.join(', ') : nil }
+ = _("+ %{count} more") % { count: @project.count_of_extra_topics_not_shown }
+
+
+ .project-repo-buttons.col-md-12.col-lg-6.d-inline-flex.flex-wrap.justify-content-lg-end
- if current_user
.d-inline-flex
- = render 'projects/buttons/notifications', notification_setting: @notification_setting, btn_class: 'btn-xs'
+ = render 'shared/notifications/new_button', notification_setting: @notification_setting, btn_class: 'btn-xs'
.count-buttons.d-inline-flex
= render 'projects/buttons/star'
@@ -43,13 +56,16 @@
- if can?(current_user, :download_code, @project)
%nav.project-stats
- .nav-links.quick-links.mt-3
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ .nav-links.quick-links
+ - if @project.empty_repo?
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ - else
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
- .project-home-desc.mt-1
+ .home-panel-home-desc.mt-1
- if @project.description.present?
- .project-description
- .project-description-markdown.read-more-container
+ .home-panel-description
+ .home-panel-description-markdown.read-more-container
= markdown_field(@project, :description)
%button.btn.btn-blank.btn-link.js-read-more-trigger.d-lg-none{ type: "button" }
= _("Read more")
@@ -64,6 +80,8 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) }
+ = render_if_exists "projects/home_mirror"
+
- if @project.badges.present?
.project-badges.mb-2
- @project.badges.each do |badge|
diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml
index 2b425f18389..9c854369c93 100644
--- a/app/views/projects/_import_project_pane.html.haml
+++ b/app/views/projects/_import_project_pane.html.haml
@@ -50,7 +50,7 @@
- if gitea_import_enabled?
%div
= link_to new_import_gitea_path, class: 'btn import_gitea', data: { track_label: "#{track_label}", track_event: "click_button", track_property: "gitea" } do
- = custom_icon('go_logo')
+ = custom_icon('gitea_logo')
Gitea
- if git_import_enabled?
diff --git a/app/views/projects/_issuable_by_email.html.haml b/app/views/projects/_issuable_by_email.html.haml
index d59191a6f87..0b2d179456d 100644
--- a/app/views/projects/_issuable_by_email.html.haml
+++ b/app/views/projects/_issuable_by_email.html.haml
@@ -21,7 +21,7 @@
= clipboard_button(target: '#issuable_email', class: 'btn btn-clipboard input-group-text btn-transparent d-none d-sm-block')
- if issuable_type == 'issue'
- - enter_title_text = _('Enter the issue title')
+ - enter_title_text = _('Enter the issue title')
- enter_description_text = _('Enter the issue description')
- else
- enter_title_text = _('Enter the merge request title')
@@ -36,7 +36,12 @@
%p
= render 'by_email_description'
%p
- This is a private email address, generated just for you.
+ This is a private email address
+
+ %a{ href: 'https://docs.gitlab.com/ee/development/emails.html#email-namespace', target: "_blank", rel: "noopener" }
+ %i.fa.fa-question-circle{ 'aria-label': "Learn more about incoming email addresses" }
+
+ generated just for you.
Anyone who gets ahold of it can create issues or merge requests as if they were you.
You should
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 0f709c65d0e..10575aa68b1 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -7,32 +7,22 @@
= _('This merge request is locked.')
= _('Only project members can comment.')
-.md-area
+.md-area.position-relative
.md-header
%ul.nav.nav-tabs.nav-links.clearfix
%li.md-header-tab.active
%button.js-md-write-button{ tabindex: -1 }
- Write
+ = _("Write")
%li.md-header-tab
%button.js-md-preview-button{ tabindex: -1 }
- Preview
+ = _("Preview")
%li.md-header-toolbar.active
- = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: s_("MarkdownToolbar|Add bold text") })
- = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: s_("MarkdownToolbar|Add italic text") })
- = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: s_("MarkdownToolbar|Insert a quote") })
- = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: s_("MarkdownToolbar|Insert code") })
- = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: s_("MarkdownToolbar|Add a link") })
- = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a bullet list") })
- = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a numbered list") })
- = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a task list") })
- = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: s_("MarkdownToolbar|Add a table") })
- %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: s_("MarkdownToolbar|Go full screen"), data: { container: "body" } }
- = sprite_icon("screen-full")
+ = render 'projects/blob/markdown_buttons', show_fullscreen_button: true
.md-write-holder
= yield
- .md.md-preview-holder.js-md-preview.hide.md-preview{ data: { url: url } }
+ .md.md-preview-holder.js-md-preview.hide{ data: { url: url } }
.referenced-commands.hide
- if referenced_users
diff --git a/app/views/projects/_merge_request_merge_checks_settings.html.haml b/app/views/projects/_merge_request_merge_checks_settings.html.haml
new file mode 100644
index 00000000000..1ab467a3710
--- /dev/null
+++ b/app/views/projects/_merge_request_merge_checks_settings.html.haml
@@ -0,0 +1,19 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Merge checks')
+ %p.text-secondary= s_('ProjectSettings|These checks must pass before merge requests can be merged')
+ .form-check.mb-2.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
+ = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
+ = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
+ = s_('ProjectSettings|Pipelines must succeed')
+ .descr.text-secondary
+ = s_('ProjectSettings|Pipelines need to be configured to enable this feature.')
+ = link_to icon('question-circle'),
+ help_page_path('ci/merge_request_pipelines/index.md',
+ anchor: 'pipelines-for-merge-requests'),
+ target: '_blank'
+ .form-check.mb-2
+ = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input'
+ = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
+ = s_('ProjectSettings|All discussions must be resolved')
diff --git a/app/views/projects/_merge_request_merge_method_settings.html.haml b/app/views/projects/_merge_request_merge_method_settings.html.haml
index 935581643cd..47c311f42d0 100644
--- a/app/views/projects/_merge_request_merge_method_settings.html.haml
+++ b/app/views/projects/_merge_request_merge_method_settings.html.haml
@@ -1,35 +1,33 @@
- form = local_assigns.fetch(:form)
.form-group
- = label_tag :merge_method_merge, class: 'label-bold' do
- Merge method
- .form-check
+ %b= s_('ProjectSettings|Merge method')
+ %p.text-secondary= s_('ProjectSettings|This will dictate the commit history when you merge a merge request')
+ .form-check.mb-2
= form.radio_button :merge_method, :merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_merge, class: 'form-check-label' do
- %strong Merge commit
- %br
- %span.descr
- A merge commit is created for every merge, and merging is allowed as long as there are no conflicts.
+ = s_('ProjectSettings|Merge commit')
+ .descr.text-secondary
+ = s_('ProjectSettings|Every merge creates a merge commit')
- .form-check
+ .form-check.mb-2
= form.radio_button :merge_method, :rebase_merge, class: "js-merge-method-radio form-check-input"
= label_tag :project_merge_method_rebase_merge, class: 'form-check-label' do
- %strong Merge commit with semi-linear history
- %br
- %span.descr
- A merge commit is created for every merge, but merging is only allowed if fast-forward merge is possible.
- This way you could make sure that if this merge request would build, after merging to target branch it would also build.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+ = s_('ProjectSettings|Merge commit with semi-linear history')
+ .descr.text-secondary
+ = s_('ProjectSettings|Every merge creates a merge commit')
+ %br
+ = s_('ProjectSettings|Fast-forward merges only')
+ %br
+ = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
- .form-check
+ .form-check.mb-2
= form.radio_button :merge_method, :ff, class: "js-merge-method-radio qa-radio-button-merge-ff form-check-input"
= label_tag :project_merge_method_ff, class: 'form-check-label' do
- %strong Fast-forward merge
- %br
- %span.descr
- No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.
- %br
- %span.descr
- When fast-forward merge is not possible, the user is given the option to rebase.
+ = s_('ProjectSettings|Fast-forward merge')
+ .descr.text-secondary
+ = s_('ProjectSettings|No merge commits are created')
+ %br
+ = s_('ProjectSettings|Fast-forward merges only')
+ %br
+ = s_('ProjectSettings|When conflicts arise the user is given the option to rebase')
diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml
new file mode 100644
index 00000000000..5ab475822de
--- /dev/null
+++ b/app/views/projects/_merge_request_merge_options_settings.html.haml
@@ -0,0 +1,14 @@
+- form = local_assigns.fetch(:form)
+
+.form-group
+ %b= s_('ProjectSettings|Merge options')
+ %p.text-secondary= s_('ProjectSettings|Additional merge request capabilities that influence how and when merges will be performed')
+ = render_if_exists 'projects/merge_pipelines_settings', form: form
+ .form-check.mb-2
+ = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
+ = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
+ = s_('ProjectSettings|Automatically resolve merge request diff discussions when they become outdated')
+ .form-check.mb-2
+ = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
+ = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
+ = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line')
diff --git a/app/views/projects/_merge_request_merge_settings.html.haml b/app/views/projects/_merge_request_merge_settings.html.haml
deleted file mode 100644
index f178c94e008..00000000000
--- a/app/views/projects/_merge_request_merge_settings.html.haml
+++ /dev/null
@@ -1,23 +0,0 @@
-- form = local_assigns.fetch(:form)
-
-.form-group
- .form-check.builds-feature{ class: ("hidden" if @project && @project.project_feature.send(:builds_access_level) == 0) }
- = form.check_box :only_allow_merge_if_pipeline_succeeds, class: 'form-check-input'
- = form.label :only_allow_merge_if_pipeline_succeeds, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if the pipeline succeeds
- %br
- %span.descr
- Pipelines need to be configured to enable this feature.
- = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
- .form-check
- = form.check_box :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-input'
- = form.label :only_allow_merge_if_all_discussions_are_resolved, class: 'form-check-label' do
- %strong Only allow merge requests to be merged if all discussions are resolved
- .form-check
- = form.check_box :resolve_outdated_diff_discussions, class: 'form-check-input'
- = form.label :resolve_outdated_diff_discussions, class: 'form-check-label' do
- %strong Automatically resolve merge request diff discussions when they become outdated
- .form-check
- = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input'
- = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do
- %strong Show link to create/view merge request when pushing from the command line
diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml
index c80e831dd33..f2ba38387a3 100644
--- a/app/views/projects/_merge_request_settings.html.haml
+++ b/app/views/projects/_merge_request_settings.html.haml
@@ -2,4 +2,6 @@
= render 'projects/merge_request_merge_method_settings', project: @project, form: form
-= render 'projects/merge_request_merge_settings', form: form
+= render 'projects/merge_request_merge_options_settings', project: @project, form: form
+
+= render 'projects/merge_request_merge_checks_settings', project: @project, form: form
diff --git a/app/views/projects/_new_project_fields.html.haml b/app/views/projects/_new_project_fields.html.haml
index ba7d3154326..1c1c7d832bd 100644
--- a/app/views/projects/_new_project_fields.html.haml
+++ b/app/views/projects/_new_project_fields.html.haml
@@ -12,21 +12,21 @@
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
%span= s_("Project URL")
- .input-group
+ .input-group.flex-nowrap
- if current_user.can_select_namespace?
- .input-group-prepend.has-tooltip{ title: root_url }
+ .input-group-prepend.flex-shrink-0.has-tooltip{ title: root_url }
.input-group-text
= root_url
- namespace_id = namespace_id_from(params)
= f.select(:namespace_id,
- namespaces_options(namespace_id || :current_user,
- display_path: true,
- extra_group: namespace_id),
+ namespaces_options_with_developer_maintainer_access(selected: namespace_id,
+ display_path: true,
+ extra_group: namespace_id),
{},
- { class: 'select2 js-select-namespace qa-project-namespace-select', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
+ { class: 'select2 js-select-namespace qa-project-namespace-select block-truncated', tabindex: 1, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_path", track_value: "" }})
- else
- .input-group-prepend.static-namespace.has-tooltip{ title: user_url(current_user.username) + '/' }
+ .input-group-prepend.static-namespace.flex-shrink-0.has-tooltip{ title: user_url(current_user.username) + '/' }
.input-group-text.border-0
#{user_url(current_user.username)}/
= f.hidden_field :namespace_id, value: current_user.namespace_id
@@ -43,7 +43,7 @@
= f.label :description, class: 'label-bold' do
Project description
%span (optional)
- = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" }
+ = f.text_area :description, placeholder: 'Description format', class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" }
= f.label :visibility_level, class: 'label-bold' do
Visibility Level
diff --git a/app/views/projects/_wiki.html.haml b/app/views/projects/_wiki.html.haml
index 45e1d32980c..6103d86bf5a 100644
--- a/app/views/projects/_wiki.html.haml
+++ b/app/views/projects/_wiki.html.haml
@@ -1,8 +1,7 @@
- if @wiki_home.present?
%div{ class: container_class }
- .prepend-top-default.append-bottom-default
- .wiki
- = render_wiki_content(@wiki_home, legacy_render_context(params))
+ .md.md-file.prepend-top-default.append-bottom-default
+ = render_wiki_content(@wiki_home)
- else
- can_create_wiki = can?(current_user, :create_wiki, @project)
.landing{ class: [('row-content-block row p-0 align-items-center' if can_create_wiki), ('content-block' unless can_create_wiki)] }
diff --git a/app/views/projects/_zen.html.haml b/app/views/projects/_zen.html.haml
index afc40ca4eab..c502b392384 100644
--- a/app/views/projects/_zen.html.haml
+++ b/app/views/projects/_zen.html.haml
@@ -8,6 +8,7 @@
= f.text_area attr,
class: classes,
placeholder: placeholder,
+ dir: 'auto',
data: { supports_quick_actions: supports_quick_actions,
supports_autocomplete: supports_autocomplete }
- else
diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml
index cfb91568061..f42d5128715 100644
--- a/app/views/projects/artifacts/_tree_file.html.haml
+++ b/app/views/projects/artifacts/_tree_file.html.haml
@@ -14,4 +14,4 @@
= link_to path_to_file, class: 'str-truncated' do
%span= blob.name
%td
- = number_to_human_size(blob.size, precision: 2)
+ = number_to_human_size(blob.size)
diff --git a/app/views/projects/artifacts/browse.html.haml b/app/views/projects/artifacts/browse.html.haml
index 09295940529..6a7cb1499c5 100644
--- a/app/views/projects/artifacts/browse.html.haml
+++ b/app/views/projects/artifacts/browse.html.haml
@@ -4,7 +4,7 @@
= render "projects/jobs/header"
- add_to_breadcrumbs(s_('CICD|Jobs'), project_jobs_path(@project))
-- add_to_breadcrumbs("##{@build.id}", project_jobs_path(@project))
+- add_to_breadcrumbs("##{@build.id}", project_job_path(@project, @build))
.tree-holder
.nav-block
diff --git a/app/views/projects/badges/badge_flat-square.svg.erb b/app/views/projects/badges/badge_flat-square.svg.erb
new file mode 100644
index 00000000000..5b90da15ef5
--- /dev/null
+++ b/app/views/projects/badges/badge_flat-square.svg.erb
@@ -0,0 +1,17 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="<%= badge.width %>" height="20">
+ <g shape-rendering="crispEdges">
+ <path fill="<%= badge.key_color %>" d="M0 0 h<%= badge.key_width %> v20 H0 z"/>
+ <path fill="<%= badge.value_color %>" d="M<%= badge.key_width %> 0 h<%= badge.value_width %> v20 H<%= badge.key_width %> z"/>
+ </g>
+
+ <g fill="#fff" text-anchor="middle">
+ <g font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
+ <text x="<%= badge.key_text_anchor %>" y="14">
+ <%= badge.key_text %>
+ </text>
+ <text x="<%= badge.value_text_anchor %>" y="14">
+ <%= badge.value_text %>
+ </text>
+ </g>
+ </g>
+</svg>
diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml
index 3c1f33ea95e..a54460f1196 100644
--- a/app/views/projects/blob/_editor.html.haml
+++ b/app/views/projects/blob/_editor.html.haml
@@ -1,4 +1,6 @@
- action = current_action?(:edit) || current_action?(:update) ? 'edit' : 'create'
+- file_name = params[:id].split("/").last ||= ""
+- is_markdown = Gitlab::MarkupHelper.gitlab_markdown?(file_name)
.file-holder-bottom-radius.file-holder.file.append-bottom-default
.js-file-title.file-title.clearfix{ data: { current_action: action } }
@@ -17,6 +19,8 @@
required: true, class: 'form-control new-file-name js-file-path-name-input'
.file-buttons
+ - if is_markdown
+ = render 'projects/blob/markdown_buttons', show_fullscreen_button: false
= button_tag class: 'soft-wrap-toggle btn', type: 'button', tabindex: '-1' do
%span.no-wrap
= custom_icon('icon_no_wrap')
diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml
index 4bef45932d0..88fa31a73b0 100644
--- a/app/views/projects/blob/_header_content.html.haml
+++ b/app/views/projects/blob/_header_content.html.haml
@@ -1,7 +1,7 @@
.file-header-content
= blob_icon blob.mode, blob.name
- %strong.file-title-name
+ %strong.file-title-name.qa-file-title-name
= blob.name
= copy_file_path_button(blob.path)
diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml
new file mode 100644
index 00000000000..28d1ff97825
--- /dev/null
+++ b/app/views/projects/blob/_markdown_buttons.html.haml
@@ -0,0 +1,13 @@
+.md-header-toolbar.active
+ = markdown_toolbar_button({ icon: "bold", data: { "md-tag" => "**" }, title: _("Add bold text") })
+ = markdown_toolbar_button({ icon: "italic", data: { "md-tag" => "*" }, title: _("Add italic text") })
+ = markdown_toolbar_button({ icon: "quote", data: { "md-tag" => "> ", "md-prepend" => true }, title: _("Insert a quote") })
+ = markdown_toolbar_button({ icon: "code", data: { "md-tag" => "`", "md-block" => "```" }, title: _("Insert code") })
+ = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") })
+ = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") })
+ = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") })
+ = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") })
+ = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") })
+ - if show_fullscreen_button
+ %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } }
+ = sprite_icon("screen-full")
diff --git a/app/views/projects/blob/diff.html.haml b/app/views/projects/blob/diff.html.haml
index ea7a71792a3..4f3db61f688 100644
--- a/app/views/projects/blob/diff.html.haml
+++ b/app/views/projects/blob/diff.html.haml
@@ -15,14 +15,14 @@
%a{ href: "#", data: { linenumber: line_number_old }, disabled: true }
%td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
%a{ href: "#", data: { linenumber: line_number_new }, disabled: true }
- %td.line_content.noteable_line{ class: line_class }= line
+ %td.line_content{ class: line_class }= line
- when :parallel
%td.old_line.diff-line-num{ data: { linenumber: line_number_old } }
%a{ href: "##{line_number_old}", data: { linenumber: line_number_old }, disabled: true }
- %td.line_content.noteable_line.left-side{ class: line_class }= line
+ %td.line_content.left-side{ class: line_class }= line
%td.new_line.diff-line-num{ data: { linenumber: line_number_new } }
%a{ href: "##{line_number_new}", data: { linenumber: line_number_new }, disabled: true }
- %td.line_content.noteable_line.right-side{ class: line_class }= line
+ %td.line_content.right-side{ class: line_class }= line
- if @form.unfold? && @form.bottom? && @form.to < @blob.lines.size
%tr.line_holder{ id: @form.to, class: line_class }
diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml
index 3f2d96b70e5..4520cca8cf5 100644
--- a/app/views/projects/blob/edit.html.haml
+++ b/app/views/projects/blob/edit.html.haml
@@ -21,7 +21,7 @@
Write
%li
- = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id, legacy_render: params[:legacy_render]) do
+ = link_to '#preview', 'data-preview-url' => project_preview_blob_path(@project, @id) do
= editing_preview_title(@blob.name)
= form_tag(project_update_blob_path(@project, @id), method: :put, class: 'js-quick-submit js-requires-input js-edit-blob-form', data: blob_editor_paths(@project)) do
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index ff460a3831c..3e893343165 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,21 +1,20 @@
-.diff-file.file-holder
- .diff-content
- - if markup?(@blob.name)
- .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = markup(@blob.name, @content, legacy_render_context(params))
- - else
- .file-content.code.js-syntax-highlight
- - unless @diff_lines.empty?
- %table.text-file
- - @diff_lines.each do |line|
- %tr.line_holder{ class: "#{line.type}" }
- - if line.type == "match"
- %td.old_line.diff-line-num= "..."
- %td.new_line.diff-line-num= "..."
- %td.line_content.match= line.text
- - else
- %td.old_line.diff-line-num
- %td.new_line.diff-line-num
- %td.line_content{ class: "#{line.type}" }= diff_line_content(line.text)
- - else
- .nothing-here-block No changes.
+- if markup?(@blob.name)
+ .file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+ = markup(@blob.name, @content)
+- else
+ .diff-file
+ .diff-content
+ - unless @diff_lines.empty?
+ %table.text-file.code.js-syntax-highlight
+ - @diff_lines.each do |line|
+ %tr.line_holder{ class: line.type }
+ - if line.type == "match"
+ %td.old_line.diff-line-num.match= "..."
+ %td.new_line.diff-line-num.match= "..."
+ %td.line_content.match= line.text
+ - else
+ %td.old_line.diff-line-num{ class: line.type }
+ %td.new_line.diff-line-num{ class: line.type }
+ %td.line_content{ class: line.type }= diff_line_content(line.text)
+ - else
+ .nothing-here-block No changes.
diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml
index 87aa7c1dbf8..5970d41fdab 100644
--- a/app/views/projects/blob/viewers/_dependency_manager.html.haml
+++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml
@@ -3,9 +3,4 @@
This project manages its dependencies using
%strong= viewer.manager_name
- - if viewer.package_name
- and defines a #{viewer.package_type} named
- %strong<
- = link_to_if viewer.package_url.present?, viewer.package_name, viewer.package_url, target: '_blank', rel: 'noopener noreferrer'
-
= link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer'
diff --git a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
index 5be7cc7f25a..61d67a88a5a 100644
--- a/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
+++ b/app/views/projects/blob/viewers/_gitlab_ci_yml.html.haml
@@ -1,9 +1,9 @@
-- if viewer.valid?(@project, @commit.sha)
+- if viewer.valid?(project: @project, sha: @commit.sha, user: @current_user)
= icon('check fw')
This GitLab CI configuration is valid.
- else
= icon('warning fw')
This GitLab CI configuration is invalid:
- = viewer.validation_message(@project, @commit.sha)
+ = viewer.validation_message(project: @project, sha: @commit.sha, user: @current_user)
= link_to 'Learn more', help_page_path('ci/yaml/README')
diff --git a/app/views/projects/blob/viewers/_loading.html.haml b/app/views/projects/blob/viewers/_loading.html.haml
index 120c0540335..df1f3e4e01b 100644
--- a/app/views/projects/blob/viewers/_loading.html.haml
+++ b/app/views/projects/blob/viewers/_loading.html.haml
@@ -1,2 +1,2 @@
.text-center.prepend-top-default.append-bottom-default
- = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…')
+ = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content…', class: 'qa-spinner')
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index 6edbfd91b21..abc74b66e90 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -1,6 +1,4 @@
- blob = viewer.blob
-- context = legacy_render_context(params)
-- unless context[:markdown_engine] == :redcarpet
- - context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
-.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+- context = blob.respond_to?(:rendered_markup) ? { rendered: blob.rendered_markup } : {}
+.file-content.md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml
index d8492abc638..c2329a7aa66 100644
--- a/app/views/projects/blob/viewers/_readme.html.haml
+++ b/app/views/projects/blob/viewers/_readme.html.haml
@@ -1,4 +1,4 @@
= icon('info-circle fw')
= succeed '.' do
To learn more about this project, read
- = link_to "the wiki", get_project_wiki_path(viewer.project)
+ = link_to "the wiki", project_wiki_path(viewer.project, :home)
diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml
index 6d6bd79bc3c..07b9378ba97 100644
--- a/app/views/projects/blob/viewers/_route_map.html.haml
+++ b/app/views/projects/blob/viewers/_route_map.html.haml
@@ -6,4 +6,4 @@
This Route Map is invalid:
= viewer.validation_message
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml
index a5f73fb0197..f11c047e85a 100644
--- a/app/views/projects/blob/viewers/_route_map_loading.html.haml
+++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml
@@ -1,4 +1,4 @@
= icon('spinner spin fw')
Validating Route Map…
-= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment')
+= link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages')
diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml
index 88f9b7dfc9f..1074cd6bf4e 100644
--- a/app/views/projects/branches/_branch.html.haml
+++ b/app/views/projects/branches/_branch.html.haml
@@ -2,6 +2,7 @@
- commit = @repository.commit(branch.dereferenced_target)
- bar_graph_width_factor = @max_commits > 0 ? 100.0/@max_commits : 0
- diverging_commit_counts = @repository.diverging_commit_counts(branch)
+- number_commits_distance = diverging_commit_counts[:distance]
- number_commits_behind = diverging_commit_counts[:behind]
- number_commits_ahead = diverging_commit_counts[:ahead]
- merge_project = merge_request_source_project_for_project(@project)
@@ -9,7 +10,7 @@
.branch-info
.branch-title
= sprite_icon('fork', size: 12)
- = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8' do
+ = link_to project_tree_path(@project, branch.name), class: 'item-title str-truncated-100 ref-name prepend-left-8 qa-branch-name' do
= branch.name
- if branch.name == @repository.root_ref
%span.badge.badge-primary.prepend-left-5 default
@@ -21,6 +22,8 @@
%span.badge.badge-success.prepend-left-5
= s_('Branches|protected')
+ = render_if_exists 'projects/branches/diverged_from_upstream'
+
.block-truncated
- if commit
= render 'projects/branches/commit', commit: commit, project: @project
@@ -28,16 +31,23 @@
= s_('Branches|Cant find HEAD commit for this branch')
- if branch.name != @repository.root_ref
- .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind),
- default_branch: @repository.root_ref,
- number_commits_ahead: diverging_count_label(number_commits_ahead) } }
- .graph-side
- .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
- %span.count.count-behind= diverging_count_label(number_commits_behind)
- .graph-separator
- .graph-side
- .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
- %span.count.count-ahead= diverging_count_label(number_commits_ahead)
+ - if number_commits_distance.nil?
+ .divergence-graph.d-none.d-md-block{ title: s_('%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead') % { number_commits_behind: diverging_count_label(number_commits_behind),
+ default_branch: @repository.root_ref,
+ number_commits_ahead: diverging_count_label(number_commits_ahead) } }
+ .graph-side
+ .bar.bar-behind{ style: "width: #{number_commits_behind * bar_graph_width_factor}%" }
+ %span.count.count-behind= diverging_count_label(number_commits_behind)
+ .graph-separator
+ .graph-side
+ .bar.bar-ahead{ style: "width: #{number_commits_ahead * bar_graph_width_factor}%" }
+ %span.count.count-ahead= diverging_count_label(number_commits_ahead)
+ - else
+ .divergence-graph.d-none.d-md-block{ title: s_('More than %{number_commits_distance} commits different with %{default_branch}') % { number_commits_distance: diverging_count_label(number_commits_distance),
+ default_branch: @repository.root_ref} }
+ .graph-side.full
+ .bar{ style: "width: #{number_commits_distance * bar_graph_width_factor}%" }
+ %span.count= diverging_count_label(number_commits_distance)
.controls.d-none.d-md-block<
- if merge_project && create_mr_button?(@repository.root_ref, branch.name)
@@ -45,9 +55,8 @@
= _('Merge request')
- if branch.name != @repository.root_ref
- = link_to project_compare_index_path(@project, from: @repository.root_ref, to: branch.name),
+ = link_to project_compare_path(@project, @repository.root_ref, branch.name),
class: "btn btn-default #{'prepend-left-10' unless merge_project}",
- method: :post,
title: s_('Branches|Compare') do
= s_('Branches|Compare')
@@ -76,7 +85,7 @@
= icon("trash-o")
- else
= link_to project_branch_path(@project, branch.name),
- class: "btn btn-remove remove-row js-ajax-loading-spinner has-tooltip",
+ class: "btn btn-remove remove-row qa-remove-btn js-ajax-loading-spinner has-tooltip",
title: s_('Branches|Delete branch'),
method: :delete,
data: { confirm: s_("Branches|Deleting the '%{branch_name}' branch cannot be undone. Are you sure?") % { branch_name: branch.name } },
diff --git a/app/views/projects/branches/_commit.html.haml b/app/views/projects/branches/_commit.html.haml
index 7892019bb15..e33e9509e3a 100644
--- a/app/views/projects/branches/_commit.html.haml
+++ b/app/views/projects/branches/_commit.html.haml
@@ -1,9 +1,9 @@
-.branch-commit
+.branch-commit.cgray
.icon-container.commit-icon
= custom_icon("icon_commit")
= link_to commit.short_id, project_commit_path(project, commit.id), class: "commit-sha"
&middot;
%span.str-truncated
- = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message"
+ = link_to_markdown commit.title, project_commit_path(project, commit.id), class: "commit-row-message cgray"
&middot;
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml
index 0e4b119bb54..93061452e12 100644
--- a/app/views/projects/branches/_panel.html.haml
+++ b/app/views/projects/branches/_panel.html.haml
@@ -10,7 +10,7 @@
.card.prepend-top-10
.card-header
= panel_title
- %ul.content-list.all-branches
+ %ul.content-list.all-branches.qa-all-branches
- branches.first(overview_max_branches).each do |branch|
= render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch)
- if branches.size > overview_max_branches
diff --git a/app/views/projects/branches/index.html.haml b/app/views/projects/branches/index.html.haml
index ca867961f6b..d270e461ac8 100644
--- a/app/views/projects/branches/index.html.haml
+++ b/app/views/projects/branches/index.html.haml
@@ -1,5 +1,6 @@
- @no_container = true
- page_title _('Branches')
+- add_to_breadcrumbs(_('Repository'), project_tree_path(@project))
%div{ class: container_class }
.top-area.adjust
@@ -35,7 +36,7 @@
- if can? current_user, :push_code, @project
= link_to project_merged_branches_path(@project),
- class: 'btn btn-inverted btn-remove has-tooltip',
+ class: 'btn btn-inverted btn-remove has-tooltip qa-delete-merged-branches',
title: s_("Branches|Delete all branches that are merged into '%{default_branch}'") % { default_branch: @project.repository.root_ref },
method: :delete,
data: { confirm: s_('Branches|Deleting the merged branches cannot be undone. Are you sure?'),
@@ -44,6 +45,8 @@
= link_to new_project_branch_path(@project), class: 'btn btn-success' do
= s_('Branches|New branch')
+ = render_if_exists 'projects/commits/mirror_status'
+
- if can?(current_user, :admin_project, @project)
- project_settings_link = link_to s_('Branches|project settings'), project_protected_branches_path(@project)
.row-content-block
diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml
index d453a3a9dac..09f05b30433 100644
--- a/app/views/projects/buttons/_clone.html.haml
+++ b/app/views/projects/buttons/_clone.html.haml
@@ -1,17 +1,13 @@
- project = project || @project
.git-clone-holder.js-git-clone-holder.input-group
- - if allowed_protocols_present?
- .input-group-text.clone-dropdown-btn.btn
- %span.js-clone-dropdown-label
- = enabled_project_button(project, enabled_protocol)
- - else
- %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
- %span.append-right-4.js-clone-dropdown-label
- = _('Clone')
- = sprite_icon("arrow-down", css_class: "icon")
- %form.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options
- %li.pb-2
+ %a#clone-dropdown.input-group-text.btn.btn-primary.btn-xs.clone-dropdown-btn.qa-clone-dropdown{ href: '#', data: { toggle: 'dropdown' } }
+ %span.append-right-4.js-clone-dropdown-label
+ = _('Clone')
+ = sprite_icon("arrow-down", css_class: "icon")
+ %ul.p-3.dropdown-menu.dropdown-menu-right.dropdown-menu-large.dropdown-menu-selectable.clone-options-dropdown.qa-clone-options
+ - if ssh_enabled?
+ %li
%label.label-bold
= _('Clone with SSH')
.input-group
@@ -19,7 +15,8 @@
.input-group-append
= clipboard_button(target: '#ssh_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
- %li
+ - if http_enabled?
+ %li.pt-2
%label.label-bold
= _('Clone with %{http_label}') % { http_label: gitlab_config.protocol.upcase }
.input-group
@@ -27,5 +24,6 @@
.input-group-append
= clipboard_button(target: '#http_project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
= render_if_exists 'projects/buttons/geo'
+ = render_if_exists 'projects/buttons/kerberos_clone_field'
= render_if_exists 'shared/geo_info_modal', project: project
diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml
index 4eb53faa6ff..4762045ee96 100644
--- a/app/views/projects/buttons/_download.html.haml
+++ b/app/views/projects/buttons/_download.html.haml
@@ -7,31 +7,22 @@
= sprite_icon('download')
%span.sr-only= _('Select Archive Format')
= sprite_icon("arrow-down")
- %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' }
- %li.dropdown-header
- #{ _('Source code') }
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do
- %span= _('Download zip')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do
- %span= _('Download tar.gz')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do
- %span= _('Download tar.bz2')
- %li
- = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do
- %span= _('Download tar')
-
+ .dropdown-menu.dropdown-menu-right{ role: 'menu' }
+ %section
+ %h5.m-0.dropdown-bold-header= _('Download source code')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil
+ - if directory? && Feature.enabled?(:git_archive_path, default_enabled: true)
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download this directory')
+ .dropdown-menu-content
+ = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path
- if pipeline && pipeline.latest_builds_with_artifacts.any?
- %li.dropdown-header Artifacts
- - unless pipeline.latest?
- - latest_pipeline = project.pipeline_for(ref)
- %li
- .unclickable= ci_status_for_statuseable(latest_pipeline)
- %li.dropdown-header Previous Artifacts
- - pipeline.latest_builds_with_artifacts.each do |job|
- %li
- = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do
- %span
- #{s_('DownloadArtifacts|Download')} '#{job.name}'
+ %section.border-top.pt-1.mt-1
+ %h5.m-0.dropdown-bold-header= _('Download artifacts')
+ - unless pipeline.latest?
+ %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref))
+ %h6.m-0.dropdown-header= _('Previous Artifacts')
+ %ul
+ - pipeline.latest_builds_with_artifacts.each do |job|
+ %li= link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: ''
diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml
new file mode 100644
index 00000000000..d344167a6c5
--- /dev/null
+++ b/app/views/projects/buttons/_download_links.html.haml
@@ -0,0 +1,5 @@
+- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']]
+
+.btn-group.ml-0.w-100
+ - formats.each do |(fmt, extra_class)|
+ = link_to fmt, project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}"
diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml
index 45515fb492f..bbe0a2c97fd 100644
--- a/app/views/projects/buttons/_dropdown.html.haml
+++ b/app/views/projects/buttons/_dropdown.html.haml
@@ -35,8 +35,8 @@
- elsif can_collaborate_with_project?(@project)
%li= link_to _('New file'), project_new_blob_path(@project, @project.default_branch || 'master')
- elsif create_mr_from_new_fork
- - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'),
- notice: edit_in_new_fork_notice,
+ - continue_params = { to: project_new_blob_path(@project, @project.default_branch || 'master'),
+ notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
%li= link_to _('New file'), fork_path, method: :post
diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml
index 44e9cb84341..f4560404c03 100644
--- a/app/views/projects/ci/builds/_build.html.haml
+++ b/app/views/projects/ci/builds/_build.html.haml
@@ -12,7 +12,7 @@
%td.status
= render "ci/status/badge", status: job.detailed_status(current_user), title: job.status_title
- %td.branch-commit
+ %td.branch-commit.cgray
- if can?(current_user, :read_build, job)
= link_to project_job_path(job.project, job) do
%span.build-link ##{job.id}
@@ -25,18 +25,18 @@
= job.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to job.ref, project_ref_path(job.project, job.ref), class: "ref-name"
- else
- .light none
+ .light= _('none')
.icon-container.commit-icon
= custom_icon("icon_commit")
- if commit_sha
- = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha"
+ = link_to job.short_sha, project_commit_path(job.project, job.sha), class: "commit-sha mr-0"
- if job.stuck?
- = icon('warning', class: 'text-warning has-tooltip', title: 'Job is stuck. Check runners.')
+ = 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('refresh', class: 'text-warning has-tooltip', title: _('Job was retried'))
.label-container
- if job.tags.any?
@@ -44,13 +44,13 @@
%span.badge.badge-primary
= tag
- if job.try(:trigger_request)
- %span.badge.badge-info triggered
+ %span.badge.badge-info= _('triggered')
- if job.try(:allow_failure)
- %span.badge.badge-danger allowed to fail
+ %span.badge.badge-danger= _('allowed to fail')
- if job.schedulable?
%span.badge.badge-info= s_('DelayedJobs|delayed')
- elsif job.action?
- %span.badge.badge-info manual
+ %span.badge.badge-info= _('manual')
- if pipeline_link
%td
@@ -70,7 +70,7 @@
- if job.try(:runner)
= runner_link(job.runner)
- else
- .light none
+ .light= _('none')
- if stage
%td
@@ -97,11 +97,11 @@
%td
.float-right
- if can?(current_user, :read_build, job) && job.artifacts?
- = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: 'Download artifacts', class: 'btn btn-build' do
+ = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build' do
= sprite_icon('download')
- if can?(current_user, :update_build, job)
- if job.active?
- = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: 'Cancel', class: 'btn btn-build' do
+ = link_to cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), class: 'btn btn-build' do
= icon('remove', class: 'cred')
- elsif job.scheduled?
.btn-group
@@ -123,8 +123,8 @@
= sprite_icon('time-out')
- elsif allow_retry
- if job.playable? && !admin && can?(current_user, :update_build, job)
- = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Play', class: 'btn btn-build' do
+ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do
= custom_icon('icon_play')
- elsif job.retryable?
- = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: 'Retry', class: 'btn btn-build' do
+ = link_to retry_project_job_path(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/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml
index 30bf1384b22..59b5b9f8a30 100644
--- a/app/views/projects/ci/lints/_create.html.haml
+++ b/app/views/projects/ci/lints/_create.html.haml
@@ -1,45 +1,48 @@
- if @status
%p
- %b Status:
- syntax is correct
+ %b= _("Status:")
+ = _("syntax is correct")
%i.fa.fa-ok.correct-syntax
.table-holder
%table.table.table-bordered
%thead
%tr
- %th Parameter
- %th Value
+ %th= _("Parameter")
+ %th= _("Value")
%tbody
- @stages.each do |stage|
- @builds.select { |build| build[:stage] == stage }.each do |build|
+ - job = @jobs[build[:name].to_sym]
%tr
%td #{stage.capitalize} Job - #{build[:name]}
%td
- %pre= build[:commands]
+ %pre= job[:before_script].to_a.join('\n')
+ %pre= job[:script].to_a.join('\n')
+ %pre= job[:after_script].to_a.join('\n')
%br
- %b Tag list:
+ %b= _("Tag list:")
= build[:tag_list].to_a.join(", ")
%br
- %b Only policy:
- = @jobs[build[:name].to_sym][:only].to_a.join(", ")
+ %b= _("Only policy:")
+ = job[:only].to_a.join(", ")
%br
- %b Except policy:
- = @jobs[build[:name].to_sym][:except].to_a.join(", ")
+ %b= _("Except policy:")
+ = job[:except].to_a.join(", ")
%br
- %b Environment:
+ %b= _("Environment:")
= build[:environment]
%br
- %b When:
+ %b= _("When:")
= build[:when]
- if build[:allow_failure]
- %b Allowed to fail
+ %b= _("Allowed to fail")
- else
%p
- %b Status:
- syntax is incorrect
+ %b= _("Status:")
+ = _("syntax is incorrect")
%i.fa.fa-remove.incorrect-syntax
- %b Error:
+ %b= _("Error:")
= @error
diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml
index cbda6bf2107..7b87664961e 100644
--- a/app/views/projects/ci/lints/show.html.haml
+++ b/app/views/projects/ci/lints/show.html.haml
@@ -1,9 +1,9 @@
-- page_title "CI Lint"
-- page_description "Validate your GitLab CI configuration file"
+- page_title _("CI Lint")
+- page_description _("Validate your GitLab CI configuration file")
- content_for :library_javascripts do
= page_specific_javascript_tag('lib/ace.js')
-%h2.pt-3.pb-3 Check your .gitlab-ci.yml
+%h2.pt-3.pb-3= _("Check your .gitlab-ci.yml")
.project-ci-linter
= form_tag project_ci_lint_path(@project), method: :post do
@@ -11,14 +11,14 @@
.col-sm-12
.file-holder
.js-file-title.file-title.clearfix
- Content of .gitlab-ci.yml
+ = _("Contents of .gitlab-ci.yml")
#ci-editor.ci-editor= @content
= text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true)
.col-sm-12
.float-left.prepend-top-10
- = submit_tag('Validate', class: 'btn btn-success submit-yml')
+ = submit_tag(_('Validate'), class: 'btn btn-success submit-yml')
.float-right.prepend-top-10
- = button_tag('Clear', type: 'button', class: 'btn btn-default clear-yml')
+ = button_tag(_('Clear'), type: 'button', class: 'btn btn-default clear-yml')
.row.prepend-top-20
.col-sm-12
diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml
index 778d27fc61d..ed3c9890efd 100644
--- a/app/views/projects/cleanup/_show.html.haml
+++ b/app/views/projects/cleanup/_show.html.haml
@@ -1,6 +1,4 @@
-- return unless Feature.enabled?(:project_cleanup, @project)
-
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#cleanup{ class: ('expanded' if expanded) }
.settings-header
@@ -26,6 +24,6 @@
= _("No file selected")
= f.file_field :bfg_object_map, accept: 'text/plain', class: "hidden js-object-map-input", required: true
.form-text.text-muted
- = _("The maximum file size allowed is %{max_attachment_size}mb") % { max_attachment_size: Gitlab::CurrentSettings.max_attachment_size }
+ = _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
= f.submit _('Start cleanup'), class: 'btn btn-success'
diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml
index eb677cff5f0..ae9aef5a9b0 100644
--- a/app/views/projects/commit/_ajax_signature.html.haml
+++ b/app/views/projects/commit/_ajax_signature.html.haml
@@ -1,2 +1,2 @@
- if commit.has_signature?
- %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: 'GPG signature (loading...)', 'commit-sha' => commit.sha } }
+ %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } }
diff --git a/app/views/projects/commit/_ci_menu.html.haml b/app/views/projects/commit/_ci_menu.html.haml
index f6666921a25..41f5fb3dcbd 100644
--- a/app/views/projects/commit/_ci_menu.html.haml
+++ b/app/views/projects/commit/_ci_menu.html.haml
@@ -1,10 +1,12 @@
+- any_pipelines = @commit.present(current_user: current_user).any_pipelines?
+
%ul.nav-links.no-top.no-bottom.commit-ci-menu.nav.nav-tabs
= nav_link(path: 'commit#show') do
= link_to project_commit_path(@project, @commit.id) do
- Changes
+ = _('Changes')
%span.badge.badge-pill= @diffs.size
- - if can?(current_user, :read_pipeline, @project)
+ - if any_pipelines
= nav_link(path: 'commit#pipelines') do
= link_to pipelines_project_commit_path(@project, @commit.id) do
- Pipelines
+ = _('Pipelines')
%span.badge.badge-pill.js-pipelines-mr-count= @commit.pipelines.size
diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml
index 2a919a767c0..a0db48bf8ff 100644
--- a/app/views/projects/commit/_commit_box.html.haml
+++ b/app/views/projects/commit/_commit_box.html.haml
@@ -6,8 +6,8 @@
%strong
#{ s_('CommitBoxTitle|Commit') }
%span.commit-sha= @commit.short_id
- = clipboard_button(text: @commit.id, title: _("Copy commit SHA to clipboard"))
- %span.d-none.d-sm-inline authored
+ = clipboard_button(text: @commit.id, title: _('Copy commit SHA to clipboard'))
+ %span.d-none.d-sm-inline= _('authored')
#{time_ago_with_tooltip(@commit.authored_date)}
%span= s_('ByAuthor|by')
= author_avatar(@commit, size: 24, has_tooltip: false)
@@ -43,13 +43,13 @@
= cherry_pick_commit_link(@commit, project_commit_path(@project, @commit.id), has_tooltip: false)
- if can?(current_user, :push_code, @project)
%li.clearfix
- = link_to s_("CreateTag|Tag"), new_project_tag_path(@project, ref: @commit)
+ = link_to s_('CreateTag|Tag'), new_project_tag_path(@project, ref: @commit)
%li.divider
%li.dropdown-header
#{ _('Download') }
- unless @commit.parents.length > 1
- %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches"
- %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff"
+ %li= link_to s_('DownloadCommit|Email Patches'), project_commit_path(@project, @commit, format: :patch), class: "qa-email-patches"
+ %li= link_to s_('DownloadCommit|Plain Diff'), project_commit_path(@project, @commit, format: :diff), class: "qa-plain-diff"
.commit-box{ data: { project_path: project_path(@project) } }
%h3.commit-title
@@ -74,8 +74,8 @@
%span.commit-info.merge-requests{ 'data-project-commit-path' => merge_requests_project_commit_path(@project, @commit.id, format: :json) }
= icon('spinner spin')
- - if @commit.last_pipeline
- - last_pipeline = @commit.last_pipeline
+ - last_pipeline = @commit.last_pipeline
+ - if can?(current_user, :read_pipeline, last_pipeline)
.well-segment.pipeline-info
.status-icon-container
= link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
@@ -95,8 +95,5 @@
.well-segment
= icon('info-circle fw')
- This commit is part of merge request
- = succeed '.' do
- = link_to @merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id)
-
- Comments created here will be created in the context of that merge request.
+ - link_to_merge_request = link_to(@merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id))
+ = _('This commit is part of merge request %{link_to_merge_request}. Comments created here will be created in the context of that merge request.').html_safe % { link_to_merge_request: link_to_merge_request }
diff --git a/app/views/projects/commit/_limit_exceeded_message.html.haml b/app/views/projects/commit/_limit_exceeded_message.html.haml
index a264f3517c4..7d3c0582d0b 100644
--- a/app/views/projects/commit/_limit_exceeded_message.html.haml
+++ b/app/views/projects/commit/_limit_exceeded_message.html.haml
@@ -1,8 +1,8 @@
-.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: "Project has too many #{label_for_message} to search"} }
+.has-tooltip{ class: "limit-box limit-box-#{objects} prepend-left-5", data: { title: _('Project has too many %{label_for_message} to search') % { label_for_message: label_for_message } } }
.limit-icon
- if objects == :branch
= sprite_icon('fork', size: 12)
- else
= icon('tag')
.limit-message
- %span #{label_for_message.capitalize} unavailable
+ %span= _('%{label_for_message} unavailable') % { label_for_message: label_for_message.capitalize }
diff --git a/app/views/projects/commit/_other_user_signature_badge.html.haml b/app/views/projects/commit/_other_user_signature_badge.html.haml
index d7bf2dc0cb6..bb843bee7c9 100644
--- a/app/views/projects/commit/_other_user_signature_badge.html.haml
+++ b/app/views/projects/commit/_other_user_signature_badge.html.haml
@@ -1,6 +1,6 @@
- title = capture do
- This commit was signed with a different user's verified signature.
+ = _("This commit was signed with a different user's verified signature.")
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
index 22ffd66ff8e..d282ab4f520 100644
--- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
+++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml
@@ -1,7 +1,6 @@
- title = capture do
- This commit was signed with a verified signature, but the committer email
- is <strong>not verified</strong> to belong to the same user.
+ = _('This commit was signed with a verified signature, but the committer email is <strong>not verified</strong> to belong to the same user.').html_safe
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml
index c4d986ef742..1331fa179fc 100644
--- a/app/views/projects/commit/_signature_badge.html.haml
+++ b/app/views/projects/commit/_signature_badge.html.haml
@@ -19,10 +19,10 @@
.clearfix
= render partial: 'projects/commit/signature_badge_user', locals: { signature: signature }
- GPG Key ID:
+ = _('GPG Key ID:')
%span.monospace= signature.gpg_key_primary_keyid
- = link_to('Learn more about signing commits', help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
+ = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link')
%a{ href: 'javascript:void(0)', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } }
= label
diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml
index 00e1efe0582..294f916d18f 100644
--- a/app/views/projects/commit/_unverified_signature_badge.html.haml
+++ b/app/views/projects/commit/_unverified_signature_badge.html.haml
@@ -1,6 +1,6 @@
- title = capture do
- This commit was signed with an <strong>unverified</strong> signature.
+ = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe
-- locals = { signature: signature, title: title, label: 'Unverified', css_class: 'invalid', icon: 'status_notfound_borderless' }
+- locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/_verified_signature_badge.html.haml b/app/views/projects/commit/_verified_signature_badge.html.haml
index 31408806be7..4964b1b8ee7 100644
--- a/app/views/projects/commit/_verified_signature_badge.html.haml
+++ b/app/views/projects/commit/_verified_signature_badge.html.haml
@@ -1,7 +1,6 @@
- title = capture do
- This commit was signed with a <strong>verified</strong> signature and the
- committer email is verified to belong to the same user.
+ = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe
-- locals = { signature: signature, title: title, label: 'Verified', css_class: 'valid', icon: 'status_success_borderless', show_user: true }
+- locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true }
= render partial: 'projects/commit/signature_badge', locals: locals
diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml
index c66ea873dba..f8c27f4c026 100644
--- a/app/views/projects/commit/pipelines.html.haml
+++ b/app/views/projects/commit/pipelines.html.haml
@@ -1,4 +1,4 @@
-- page_title 'Pipelines', "#{@commit.title} (#{@commit.short_id})", 'Commits'
+- page_title _('Pipelines'), "#{@commit.title} (#{@commit.short_id})", _('Commits')
= render 'commit_box'
= render 'ci_menu'
diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml
index 79e32949db9..34226167288 100644
--- a/app/views/projects/commit/show.html.haml
+++ b/app/views/projects/commit/show.html.haml
@@ -1,19 +1,16 @@
- @no_container = true
-- add_to_breadcrumbs "Commits", project_commits_path(@project)
+- add_to_breadcrumbs _('Commits'), project_commits_path(@project)
- breadcrumb_title @commit.short_id
- container_class = !fluid_layout && diff_view == :inline ? 'container-limited' : ''
- limited_container_width = fluid_layout ? '' : 'limit-container-width'
- @content_class = limited_container_width
-- page_title "#{@commit.title} (#{@commit.short_id})", "Commits"
+- page_title "#{@commit.title} (#{@commit.short_id})", _('Commits')
- page_description @commit.description
.container-fluid{ class: [limited_container_width, container_class] }
= render "commit_box"
- - if @commit.status
- = render "ci_menu"
- - else
- .block-connector
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, is_commit: true
+ = render "ci_menu"
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-commit"
.limited-width-notes
= render "shared/notes/notes_with_form", :autocomplete => true
diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml
index 1a74b120c26..771e1881e94 100644
--- a/app/views/projects/commits/_commit.html.haml
+++ b/app/views/projects/commits/_commit.html.haml
@@ -6,8 +6,12 @@
- merge_request = local_assigns.fetch(:merge_request, nil)
- project = local_assigns.fetch(:project) { merge_request&.project }
- ref = local_assigns.fetch(:ref) { merge_request&.source_branch }
+- commit_status = commit.present(current_user: current_user).status_for(ref)
- link = commit_path(project, commit, merge_request: merge_request)
+
+- show_project_name = local_assigns.fetch(:show_project_name, false)
+
%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" }
.avatar-cell.d-none.d-sm-block
@@ -19,12 +23,9 @@
= link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "commit-row-message item-title"
- else
= link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title")
- %span.commit-row-message.d-block.d-sm-none
+ %span.commit-row-message.d-inline.d-sm-none
&middot;
= commit.short_id
- - if commit.status(ref)
- .d-block.d-sm-none
- = render_commit_status(commit, ref: ref)
- if commit.description?
%button.text-expander.js-toggle-button
= sprite_icon('ellipsis_h', size: 12)
@@ -34,24 +35,25 @@
- commit_timeago = time_ago_with_tooltip(commit.authored_date, placement: 'bottom')
- commit_text = _('%{commit_author_link} authored %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago }
#{ commit_text.html_safe }
+ = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project
- if commit.description?
%pre.commit-row-description.js-toggle-content.append-bottom-8
= preserve(markdown_field(commit, :description))
- .commit-actions.flex-row.d-none.d-sm-flex
+ .commit-actions.flex-row
- if request.xhr?
= render partial: 'projects/commit/signature', object: commit.signature
- else
= render partial: 'projects/commit/ajax_signature', locals: { commit: commit }
- - if commit.status(ref)
+ - if commit_status
= render_commit_status(commit, ref: ref)
.js-commit-pipeline-status{ data: { endpoint: pipelines_project_commit_path(project, commit.id, ref: ref) } }
- .commit-sha-group
- .label.label-monospace
+ .commit-sha-group.d-none.d-sm-flex
+ .label.label-monospace.monospace
= commit.short_id
= clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard"), class: "btn btn-default", container: "body")
= link_to_browse_code(project, commit)
diff --git a/app/views/projects/commits/_inline_commit.html.haml b/app/views/projects/commits/_inline_commit.html.haml
index caaff082cc3..56bebeca581 100644
--- a/app/views/projects/commits/_inline_commit.html.haml
+++ b/app/views/projects/commits/_inline_commit.html.haml
@@ -3,6 +3,6 @@
= link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha"
&nbsp;
%span.str-truncated
- = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message")
+ = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message cgray")
.float-right
#{time_ago_with_tooltip(commit.committed_date)}
diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml
index 9d254463fb6..2db1efdd52f 100644
--- a/app/views/projects/commits/show.html.haml
+++ b/app/views/projects/commits/show.html.haml
@@ -30,6 +30,8 @@
= link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do
= icon("rss")
+ = render_if_exists 'projects/commits/mirror_status'
+
%div{ id: dom_id(@project) }
%ol#commits-list.list-unstyled.content_list
= render 'commits', project: @project, ref: @ref
diff --git a/app/views/projects/compare/show.html.haml b/app/views/projects/compare/show.html.haml
index b6bebbabed0..5774b48a054 100644
--- a/app/views/projects/compare/show.html.haml
+++ b/app/views/projects/compare/show.html.haml
@@ -8,7 +8,7 @@
- if @commits.present?
= render "projects/commits/commit_list"
- = render "projects/diffs/diffs", diffs: @diffs, environment: @environment
+ = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, diff_page_context: "is-compare"
- else
.card.bg-light
.center
diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml
index bdf021fd87f..59f0afd59e6 100644
--- a/app/views/projects/cycle_analytics/show.html.haml
+++ b/app/views/projects/cycle_analytics/show.html.haml
@@ -10,7 +10,7 @@
.wrapper{ "v-show" => "!isLoading && !hasError" }
.card
.card-header
- {{ __('Pipeline Health') }}
+ {{ __('Recent Project Activity') }}
.content-block
.container-fluid
.row
diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml
index ff6a9d49a61..59efcde5825 100644
--- a/app/views/projects/default_branch/_show.html.haml
+++ b/app/views/projects/default_branch/_show.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#default-branch-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml
index 062aa423bde..fcf27351a21 100644
--- a/app/views/projects/deploy_keys/_index.html.haml
+++ b/app/views/projects/deploy_keys/_index.html.haml
@@ -1,9 +1,9 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded) }
.settings-header
%h4
Deploy Keys
- %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' }
+ %button.btn.js-settings-toggle{ type: 'button' }
= expanded ? 'Collapse' : 'Expand'
%p
Deploy keys allow read-only or read-write (if enabled) access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml
index f4c91377ecb..c84c376d57b 100644
--- a/app/views/projects/deployments/_actions.haml
+++ b/app/views/projects/deployments/_actions.haml
@@ -10,5 +10,5 @@
- actions.each do |action|
- next unless can?(current_user, :update_build, action)
%li
- = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow', class: 'btn' do
- %span= action.name.humanize
+ = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do
+ %span= action.name
diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml
index 282566eeadc..743aa60b3ba 100644
--- a/app/views/projects/deployments/_commit.html.haml
+++ b/app/views/projects/deployments/_commit.html.haml
@@ -1,17 +1,17 @@
.table-mobile-content
- .branch-commit
+ .branch-commit.cgray
- if deployment.ref
%span.icon-container
= deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
= custom_icon("icon_commit")
- = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha"
+ = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha mr-0"
%p.commit-title.flex-truncate-parent
%span.flex-truncate-child
- if commit_title = deployment.commit_title
= author_avatar(deployment.commit, size: 20)
- = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message"
+ = link_to_markdown commit_title, project_commit_path(@project, deployment.sha), class: "commit-row-message cgray"
- else
= _("Can't find HEAD commit for this branch")
diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
new file mode 100644
index 00000000000..ff40e404e5f
--- /dev/null
+++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml
@@ -0,0 +1,23 @@
+- commit_sha = link_to deployment.short_sha, project_commit_path(@project, deployment.sha), class: "commit-sha has-tooltip", title: h(deployment.commit_title)
+.modal.ws-normal.fade{ tabindex: -1, id: "confirm-rollback-modal-#{deployment.id}" }
+ .modal-dialog
+ .modal-content
+ .modal-header
+ %h4.modal-title.d-flex.mw-100
+ - if deployment.last?
+ = s_("Environments|Re-deploy environment %{environment_name}?") % {environment_name: @environment.name}
+ - else
+ = s_("Environments|Rollback environment %{environment_name}?") % {environment_name: @environment.name}
+ .modal-body
+ - if deployment.last?
+ %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha}
+ - else
+ %p
+ = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha}
+ .modal-footer
+ = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' }
+ = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do
+ - if deployment.last?
+ = s_('Environments|Re-deploy')
+ - else
+ = s_('Environments|Rollback')
diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml
index 85bc8ec07e3..a11e23b6daa 100644
--- a/app/views/projects/deployments/_deployment.html.haml
+++ b/app/views/projects/deployments/_deployment.html.haml
@@ -18,7 +18,7 @@
- if deployment.user
%div
by
- = user_avatar(user: deployment.user, size: 20)
+ = user_avatar(user: deployment.user, size: 20, css_class: "mr-0 float-none")
.table-section.section-15{ role: 'gridcell' }
.table-mobile-header{ role: 'rowheader' }= _("Created")
diff --git a/app/views/projects/deployments/_rollback.haml b/app/views/projects/deployments/_rollback.haml
index 1bd538a08ff..d6bf8d564de 100644
--- a/app/views/projects/deployments/_rollback.haml
+++ b/app/views/projects/deployments/_rollback.haml
@@ -1,7 +1,8 @@
- if can?(current_user, :create_deployment, deployment)
- tooltip = deployment.last? ? s_('Environments|Re-deploy to environment') : s_('Environments|Rollback environment')
- = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-build has-tooltip', title: tooltip do
+ = button_tag class: 'btn btn-default btn-build has-tooltip', type: 'button', data: { toggle: 'modal', target: "#confirm-rollback-modal-#{deployment.id}" }, title: tooltip do
- if deployment.last?
= sprite_icon('repeat')
- else
= sprite_icon('redo')
+ = render 'projects/deployments/confirm_rollback_modal', deployment: deployment
diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml
index 68f74f702ea..590fcdb0234 100644
--- a/app/views/projects/diffs/_content.html.haml
+++ b/app/views/projects/diffs/_content.html.haml
@@ -1,2 +1,2 @@
.diff-content
- = render 'projects/diffs/viewer', viewer: diff_file.rich_viewer || diff_file.simple_viewer
+ = render 'projects/diffs/viewer', viewer: diff_file.viewer
diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml
index cc2d0d3b2d8..2dba3fcd664 100644
--- a/app/views/projects/diffs/_diffs.html.haml
+++ b/app/views/projects/diffs/_diffs.html.haml
@@ -2,7 +2,7 @@
- show_whitespace_toggle = local_assigns.fetch(:show_whitespace_toggle, true)
- can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project)
- diff_files = diffs.diff_files
-- is_commit = local_assigns.fetch(:is_commit, false)
+- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
.content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed
.files-changed-inner
@@ -25,4 +25,4 @@
= render 'projects/diffs/warning', diff_files: diffs
.files{ data: { can_create_note: can_create_note } }
- = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, is_commit: is_commit }
+ = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context }
diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml
index 5565ae1d98b..855b719dc45 100644
--- a/app/views/projects/diffs/_file.html.haml
+++ b/app/views/projects/diffs/_file.html.haml
@@ -1,11 +1,11 @@
- environment = local_assigns.fetch(:environment, nil)
-- is_commit = local_assigns.fetch(:is_commit, false)
+- diff_page_context = local_assigns.fetch(:diff_page_context, nil)
- file_hash = hexdigest(diff_file.file_path)
- image_diff = diff_file.rich_viewer && diff_file.rich_viewer.partial_name == 'image'
- image_replaced = diff_file.old_content_sha && diff_file.old_content_sha != diff_file.content_sha
.diff-file.file-holder{ id: file_hash, data: diff_file_html_data(project, diff_file.file_path, diff_file.content_sha) }
- .js-file-title.file-title-flex-parent{ class: is_commit ? "is-commit" : "" }
+ .js-file-title.file-title-flex-parent{ class: diff_page_context }
.file-header-content
= render "projects/diffs/file_header", diff_file: diff_file, url: "##{file_hash}"
diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml
index ffdca500abe..d35443cca1e 100644
--- a/app/views/projects/diffs/_line.html.haml
+++ b/app/views/projects/diffs/_line.html.haml
@@ -30,7 +30,7 @@
= link_text
- else
%a{ href: "##{line_code}", data: { linenumber: link_text } }
- %td.line_content.noteable_line{ class: type }<
+ %td.line_content{ class: type }<
- if email
%pre= line.rich_text
- else
diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml
index 4b1d4b3ea17..311b0be19ab 100644
--- a/app/views/projects/diffs/_parallel_view.html.haml
+++ b/app/views/projects/diffs/_parallel_view.html.haml
@@ -1,7 +1,7 @@
/ Side-by-side diff view
-.text-file.diff-wrap-lines.code.js-syntax-highlight{ data: diff_view_data }
- %table
+.text-file{ data: diff_view_data }
+ %table.diff-wrap-lines.code.js-syntax-highlight
- diff_file.parallel_diff_lines.each do |line|
- left = line[:left]
- right = line[:right]
@@ -23,7 +23,7 @@
- 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.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
+ %td.line_content.parallel.left-side{ id: left_line_code, class: left.type }= diff_line_content(left.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel.left-side
@@ -44,7 +44,7 @@
- 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.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
+ %td.line_content.parallel.right-side{ id: right_line_code, class: right.type }= diff_line_content(right.rich_text)
- else
%td.old_line.diff-line-num.empty-cell
%td.line_content.parallel.right-side
diff --git a/app/views/projects/diffs/_render_error.html.haml b/app/views/projects/diffs/_render_error.html.haml
index c3dc47a56a7..7dbd9897e83 100644
--- a/app/views/projects/diffs/_render_error.html.haml
+++ b/app/views/projects/diffs/_render_error.html.haml
@@ -1,6 +1,2 @@
.nothing-here-block
- = _("This %{viewer} could not be displayed because %{reason}.") % { viewer: viewer.switcher_title, reason: diff_render_error_reason(viewer) }
-
- You can
- = diff_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
- instead.
+ = viewer.render_error_message.html_safe
diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml
index 6dffc7c4390..566dfe798c6 100644
--- a/app/views/projects/diffs/_replaced_image_diff.html.haml
+++ b/app/views/projects/diffs/_replaced_image_diff.html.haml
@@ -35,10 +35,10 @@
.swipe.view.hide
.swipe-frame
- .frame.deleted
+ .frame.deleted.old-diff
= image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false)
- .swipe-wrap
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
+ .swipe-wrap.left-oriented
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added old-diff js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path }
%span.swipe-bar
%span.top-handle
%span.bottom-handle
diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml
index 454f814795a..daac543b939 100644
--- a/app/views/projects/diffs/_single_image_diff.html.haml
+++ b/app/views/projects/diffs/_single_image_diff.html.haml
@@ -10,5 +10,5 @@
.image.js-single-image{ data: diff_view_data }
.wrap
- single_class_name = diff_file.deleted_file? ? 'deleted' : 'added'
- = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
+ = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} old-diff js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path }
%p.image-info= number_to_human_size(blob.size)
diff --git a/app/views/projects/diffs/_text_file.html.haml b/app/views/projects/diffs/_text_file.html.haml
index 56427a74d56..018c5b38536 100644
--- a/app/views/projects/diffs/_text_file.html.haml
+++ b/app/views/projects/diffs/_text_file.html.haml
@@ -1,7 +1,7 @@
- too_big = diff_file.diff_lines.count > Commit::DIFF_SAFE_LINES
- if too_big
.suppressed-container
- %a.show-suppressed-diff.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
+ %a.show-suppressed-diff.cursor-pointer.js-show-suppressed-diff= _("Changes suppressed. Click to show.")
%table.text-file.diff-wrap-lines.code.js-syntax-highlight.commit-diff{ data: diff_view_data, class: too_big ? 'hide' : '' }
= render partial: "projects/diffs/line",
diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml
index 2eef599cf84..2cc3d921abc 100644
--- a/app/views/projects/diffs/_warning.html.haml
+++ b/app/views/projects/diffs/_warning.html.haml
@@ -9,4 +9,4 @@
= link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn btn-sm"
= link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn btn-sm"
%p
- = _("To preserve performance only <strong>%{display_size} of ${real_size}</strong> files are displayed.").html_safe % { display_size: diff_files.size, real_size: diff_files.real_size }
+ = _("To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed.").html_safe % { display_size: diff_files.size, real_size: diff_files.real_size }
diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml
index 1b52821af15..c15b84d0aac 100644
--- a/app/views/projects/edit.html.haml
+++ b/app/views/projects/edit.html.haml
@@ -1,227 +1,157 @@
-- breadcrumb_title "General Settings"
-- page_title "General"
+- breadcrumb_title _("General Settings")
+- page_title _("General")
- @content_class = "limit-container-width" unless fluid_layout
-- expanded = Rails.env.test?
-
-.project-edit-container
- %section.settings.general-settings.no-animate#js-general-project-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- General project
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Update your project name, description, avatar, and other general settings.
- .settings-content
- .project-edit-errors
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-general-project-settings' }
- %fieldset
- .row
- .form-group.col-md-9
- = f.label :name, class: 'label-bold', for: 'project_name_edit' do
- Project name
- = f.text_field :name, class: "form-control", id: "project_name_edit"
-
- .form-group.col-md-3
- = f.label :id, class: 'label-bold' do
- Project ID
- = f.text_field :id, class: 'form-control', readonly: true
-
- .form-group
- = f.label :description, class: 'label-bold' do
- Project description
- %span.light (optional)
- = f.text_area :description, class: "form-control", rows: 3, maxlength: 250
-
- = render_if_exists 'projects/classification_policy_settings', f: f
-
- = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
-
- .form-group
- = f.label :tag_list, "Tags", class: 'label-bold'
- = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
- %p.form-text.text-muted Separate tags with commas.
- %fieldset.features
- %h5.prepend-top-0= _("Project avatar")
- .form-group
- - if @project.avatar?
- .avatar-container.s160.append-bottom-15
- = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160)
- - if @project.avatar_in_git
- %p.light
- = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git }
- .prepend-top-5.append-bottom-10
- %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...")
- %span.file_name.prepend-left-default.js-filename= _("No file chosen")
- = f.file_field :avatar, class: "js-project-avatar-input hidden"
- .form-text.text-muted= _("The maximum file size allowed is 200KB.")
- - if @project.avatar?
- %hr
- = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted"
- = f.submit 'Save changes', class: "btn btn-success js-btn-success-general-project-settings"
-
- %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Permissions
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Enable or disable certain project features and choose access levels.
- .settings-content
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
- -# haml-lint:disable InlineJavaScript
- %script.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
- .js-project-permissions-form
- = f.submit 'Save changes', class: "btn btn-success"
-
- = render_if_exists 'projects/issues_settings'
-
- %section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
- .settings-header
- %h4
- Merge request
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Customize your merge request restrictions.
- .settings-content
- = render_if_exists 'shared/promotions/promote_mr_features'
-
- = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form" }, authenticity_token: true do |f|
- %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
- = render 'projects/merge_request_settings', form: f
- = f.submit 'Save changes', class: "btn btn-success qa-save-merge-request-changes"
-
- = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
-
- = render_if_exists 'projects/service_desk_settings'
-
- %section.settings.no-animate{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- = s_('ProjectSettings|Badges')
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- = s_('ProjectSettings|Customize your project badges.')
- = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges')
- .settings-content
- = render 'shared/badges/badge_settings'
-
- = render 'export', project: @project
-
- %section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
- .settings-header
- %h4
- Advanced
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? 'Collapse' : 'Expand'
- %p
- Perform advanced options such as housekeeping, archiving, renaming, transferring, or removing your project.
- .settings-content
+- expanded = expanded_by_default?
+
+%section.settings.general-settings.no-animate.expanded#js-general-settings
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= _('Collapse')
+ %p= _('Update your project name, topics, description and avatar.')
+ .settings-content= render 'projects/settings/general'
+
+%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.')
+
+ .settings-content
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' }
+ %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project)
+ .js-project-permissions-form
+ = f.submit _('Save changes'), class: "btn btn-success"
+
+%section.qa-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Merge requests')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Choose your merge method, options, checks, and set up a default merge request description template.')
+
+ .settings-content
+ = render_if_exists 'shared/promotions/promote_mr_features'
+
+ = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' }
+ = render 'projects/merge_request_settings', form: f
+ = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes"
+
+= render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded
+
+
+%section.settings.no-animate{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only
+ = s_('ProjectSettings|Badges')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }
+ = expanded ? _('Collapse') : _('Expand')
+ %p
+ = s_('ProjectSettings|Customize your project badges.')
+ = link_to s_('ProjectSettings|Learn more about badges.'), help_page_path('user/project/badges')
+ .settings-content
+ = render 'shared/badges/badge_settings'
+
+= render_if_exists 'projects/settings/default_issue_template'
+
+= render_if_exists 'projects/service_desk_settings'
+
+%section.qa-advanced-settings.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded) }
+ .settings-header
+ %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced')
+ %button.btn.btn-default.js-settings-toggle{ type: 'button' }= expanded ? _('Collapse') : _('Expand')
+ %p= _('Housekeeping, export, path, transfer, remove, archive.')
+
+ .settings-content
+ .sub-section
+ %h4= _('Housekeeping')
+ %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.')
+ = link_to _('Run housekeeping'), housekeeping_project_path(@project),
+ method: :post, class: "btn btn-default"
+
+ = render 'export', project: @project
+
+ - if can? current_user, :archive_project, @project
.sub-section
- %h4 Housekeeping
- %p
- Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.
- = link_to 'Run housekeeping', housekeeping_project_path(@project),
- method: :post, class: "btn btn-default"
- - if can? current_user, :archive_project, @project
- .sub-section
- %h4.warning-title
- - if @project.archived?
- Unarchive project
- - else
- Archive project
+ %h4.warning-title
- if @project.archived?
- %p
- Unarchiving the project will restore people's ability to make changes to it.
- The repository can be committed to, and issues, comments and other entities can be created.
- %strong Once active this project shows up in the search and on the dashboard.
- = link_to 'Unarchive project', unarchive_project_path(@project),
- data: { confirm: "Are you sure that you want to unarchive this project?" },
- method: :post, class: "btn btn-success"
+ = _('Unarchive project')
- else
- %p
- Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches.
- %strong The repository cannot be committed to, and no issues, comments or other entities can be created.
- = link_to 'Archive project', archive_project_path(@project),
- data: { confirm: "Are you sure that you want to archive this project?" },
- method: :post, class: "btn btn-warning"
- .sub-section.rename-repository
- %h4.warning-title
- Rename repository
- = render 'projects/errors'
- = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
- .form-group.project_name_holder
- = f.label :name, class: 'label-bold' do
- Project name
- .form-group
- = f.text_field :name, class: "form-control"
+ = _('Archive project')
+ - if @project.archived?
+ %p= _("Unarchiving the project will restore people's ability to make changes to it. The repository can be committed to, and issues, comments and other entities can be created. <strong>Once active this project shows up in the search and on the dashboard.</strong>").html_safe
+ = link_to _('Unarchive project'), unarchive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to unarchive this project?") },
+ method: :post, class: "btn btn-success"
+ - else
+ %p= _("Archiving the project will make it entirely read-only. It is hidden from the dashboard and doesn't show up in searches. <strong>The repository cannot be committed to, and no issues, comments or other entities can be created.</strong>").html_safe
+ = link_to _('Archive project'), archive_project_path(@project),
+ data: { confirm: _("Are you sure that you want to archive this project?") },
+ method: :post, class: "btn btn-warning"
+ .sub-section.rename-repository
+ %h4.warning-title= _('Change path')
+ = render 'projects/errors'
+ = form_for([@project.namespace.becomes(Namespace), @project]) do |f|
+ .form-group
+ = f.label :path, _('Path'), class: 'label-bold'
+ .form-group
+ .input-group
+ .input-group-prepend
+ .input-group-text
+ #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
+ = f.text_field :path, class: 'form-control qa-project-path-field h-auto'
+ %ul
+ %li= _("Be careful. Renaming a project's repository can have unintended side effects.")
+ %li= _('You will need to update your local repositories to point to the new location.')
+ - if @project.deployment_platform.present?
+ %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.')
+ = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button"
+
+ - if can?(current_user, :change_namespace, @project)
+ .sub-section
+ %h4.danger-title= _('Transfer project')
+ = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
.form-group
- = f.label :path, class: 'label-bold' do
- %span Path
+ = label_tag :new_namespace_id, nil, class: 'label-bold' do
+ %span= _('Select a new namespace')
.form-group
- .input-group
- .input-group-prepend
- .input-group-text
- #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/
- = f.text_field :path, class: 'form-control'
+ = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
%ul
- %li Be careful. Renaming a project's repository can have unintended side effects.
- %li You will need to update your local repositories to point to the new location.
- - if @project.deployment_platform.present?
- %li Your deployment services will be broken, you will need to manually fix the services after renaming.
- = f.submit 'Rename project', class: "btn btn-warning"
- - if can?(current_user, :change_namespace, @project)
- .sub-section
- %h4.danger-title
- Transfer project
- = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f|
- .form-group
- = label_tag :new_namespace_id, nil, class: 'label-bold' do
- %span Select a new namespace
- .form-group
- = select_tag :new_namespace_id, namespaces_options(nil), include_blank: true, class: 'select2'
- %ul
- %li Be careful. Changing the project's namespace can have unintended side effects.
- %li You can only transfer the project to namespaces you manage.
- %li You will need to update your local repositories to point to the new location.
- %li Project visibility level will be changed to match namespace rules when transferring to a group.
- = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
- - if @project.forked? && can?(current_user, :remove_fork_project, @project)
- .sub-section
- %h4.danger-title
- Remove fork relationship
+ %li= _("Be careful. Changing the project's namespace can have unintended side effects.")
+ %li= _('You can only transfer the project to namespaces you manage.')
+ %li= _('You will need to update your local repositories to point to the new location.')
+ %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.')
+ = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => transfer_project_message(@project) }
+
+ - if @project.forked? && can?(current_user, :remove_fork_project, @project)
+ .sub-section
+ %h4.danger-title= _('Remove fork relationship')
+ %p
+ = _('This will remove the fork relationship to source project')
+ = succeed "." do
+ - if @project.fork_source
+ = link_to(fork_source_name(@project), project_path(@project.fork_source))
+ - else
+ = fork_source_name(@project)
+ = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
%p
- This will remove the fork relationship to source project
- = succeed "." do
- - if @project.fork_source
- = link_to(fork_source_name(@project), project_path(@project.fork_source))
- - else
- = fork_source_name(@project)
- = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f|
- %p
- %strong Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.
- = button_to 'Remove fork relationship', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
- - if can?(current_user, :remove_project, @project)
- .sub-section
- %h4.danger-title
- Remove project
+ %strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.')
+ = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_message(@project) }
+
+ - if can?(current_user, :remove_project, @project)
+ .sub-section
+ %h4.danger-title= _('Remove project')
+ %p= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.')
+ = form_tag(project_path(@project), method: :delete) do
%p
- Removing the project will delete its repository and all related resources including issues, merge requests etc.
- = form_tag(project_path(@project), method: :delete) do
- %p
- %strong Removed projects cannot be restored!
- = button_to 'Remove project', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
+ %strong= _('Removed projects cannot be restored!')
+ = button_to _('Remove project'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_project_message(@project) }
.save-project-loader.hide
.center
%h2
%i.fa.fa-spinner.fa-spin
- Saving project.
- %p Please wait a moment, this page will automatically refresh when ready.
+ = _('Saving project.')
+ %p= _('Please wait a moment, this page will automatically refresh when ready.')
= render 'shared/confirm_modal', phrase: @project.path
diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml
index 081990ac9b7..9fa31c147eb 100644
--- a/app/views/projects/empty.html.haml
+++ b/app/views/projects/empty.html.haml
@@ -7,89 +7,64 @@
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
= render "home_panel"
- .project-empty-note-panel
- %h4.append-bottom-20
- = _('The repository for this project is empty')
+ %h4.prepend-top-0.append-bottom-8
+ = _('The repository for this project is empty')
- - if @project.can_current_user_push_code?
- %p
- - link_to_cli = link_to _('command line instructions'), '#repo-command-line-instructions'
- = _('If you already have files you can push them using the %{link_to_cli} below.').html_safe % { link_to_cli: link_to_cli }
- %p
- %em
- - link_to_protected_branches = link_to _('Learn more about protected branches'), help_page_path('user/project/protected_branches')
- = _('Note that the master branch is automatically protected. %{link_to_protected_branches}').html_safe % { link_to_protected_branches: link_to_protected_branches }
-
- %hr
- %p
- - link_to_auto_devops_settings = link_to(s_('AutoDevOps|enable Auto DevOps'), project_settings_ci_cd_path(@project, anchor: 'autodevops-settings'))
- - link_to_add_kubernetes_cluster = link_to(s_('AutoDevOps|add a Kubernetes cluster'), new_project_cluster_path(@project))
- = s_('AutoDevOps|You can automatically build and test your application if you %{link_to_auto_devops_settings} for this project. You can automatically deploy it as well, if you %{link_to_add_kubernetes_cluster}.').html_safe % { link_to_auto_devops_settings: link_to_auto_devops_settings, link_to_add_kubernetes_cluster: link_to_add_kubernetes_cluster }
+ - if @project.can_current_user_push_code?
+ %p.append-bottom-0
+ = _('You can create files directly in GitLab using one of the following options.')
- %hr
- %p
- = _('Otherwise it is recommended you start with one of the options below.')
- .prepend-top-20
-
- %nav.project-buttons
- .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller.qa-quick-actions
- .fade-left= icon('angle-left')
- .fade-right= icon('angle-right')
- .nav-links.scrolling-tabs.quick-links
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
+ .project-buttons.qa-quick-actions
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_buttons
- if can?(current_user, :push_code, @project)
- %div
- .prepend-top-20
- .empty_wrapper
- %h3#repo-command-line-instructions.page-title-empty
- = _('Command line instructions')
- .git-empty.js-git-empty
- %fieldset
- %h5= _('Git global setup')
- %pre.bg-light
- :preserve
- git config --global user.name "#{h git_user_name}"
- git config --global user.email "#{h git_user_email}"
-
- %fieldset
- %h5= _('Create a new repository')
- %pre.bg-light
- :preserve
- git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- cd #{h @project.path}
- touch README.md
- git add README.md
- git commit -m "add README"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin master
+ .empty-wrapper.prepend-top-32
+ %h3#repo-command-line-instructions.page-title-empty
+ = _('Command line instructions')
+ %p
+ = _('You can also upload existing files from your computer using the instructions below.')
+ .git-empty.js-git-empty
+ %fieldset
+ %h5= _('Git global setup')
+ %pre.bg-light
+ :preserve
+ git config --global user.name "#{h git_user_name}"
+ git config --global user.email "#{h git_user_email}"
- %fieldset
- %h5= _('Existing folder')
- %pre.bg-light
- :preserve
- cd existing_folder
- git init
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- git add .
- git commit -m "Initial commit"
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin master
+ %fieldset
+ %h5= _('Create a new repository')
+ %pre.bg-light
+ :preserve
+ git clone #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ cd #{h @project.path}
+ touch README.md
+ git add README.md
+ git commit -m "add README"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
- %fieldset
- %h5= _('Existing Git repository')
- %pre.bg-light
- :preserve
- cd existing_repo
- git remote rename origin old-origin
- git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
- - if @project.can_current_user_push_to_default_branch?
- %span><
- git push -u origin --all
- git push -u origin --tags
+ %fieldset
+ %h5= _('Push an existing folder')
+ %pre.bg-light
+ :preserve
+ cd existing_folder
+ git init
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ git add .
+ git commit -m "Initial commit"
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin master
- - if can? current_user, :remove_project, @project
- .prepend-top-20
- = link_to _('Remove project'), [@project.namespace.becomes(Namespace), @project], data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right"
+ %fieldset
+ %h5= _('Push an existing Git repository')
+ %pre.bg-light
+ :preserve
+ cd existing_repo
+ git remote rename origin old-origin
+ git remote add origin #{ content_tag(:span, default_url_to_repo, class: 'js-clone')}
+ - if @project.can_current_user_push_to_default_branch?
+ %span><
+ git push -u origin --all
+ git push -u origin --tags
diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml
index cbd5c54cecc..1fbe34cfff3 100644
--- a/app/views/projects/environments/_form.html.haml
+++ b/app/views/projects/environments/_form.html.haml
@@ -17,5 +17,5 @@
= f.url_field :external_url, class: 'form-control'
.form-actions
- = f.submit _('Save'), class: 'btn btn-save'
+ = f.submit _('Save'), class: 'btn btn-success'
= link_to _('Cancel'), project_environments_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml
index b7e1cf85cb7..aebd176af9b 100644
--- a/app/views/projects/environments/folder.html.haml
+++ b/app/views/projects/environments/folder.html.haml
@@ -1,8 +1,5 @@
- @no_container = true
- page_title _("Environments")
-#environments-folder-list-view{ data: { endpoint: folder_project_environments_path(@project, @folder, format: :json),
- "folder-name" => @folder,
- "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
- "can-read-environment" => can?(current_user, :read_environment, @project).to_s,
+#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data,
"css-class" => container_class } }
diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml
index d66de7ab698..99cbbc11acd 100644
--- a/app/views/projects/environments/index.html.haml
+++ b/app/views/projects/environments/index.html.haml
@@ -2,7 +2,6 @@
- page_title _("Environments")
#environments-list-view{ data: { environments_data: environments_list_data,
- "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s,
"can-read-environment" => can?(current_user, :read_environment, @project).to_s,
"can-create-environment" => can?(current_user, :create_environment, @project).to_s,
"new-environment-path" => new_project_environment_path(@project),
diff --git a/app/views/projects/error_tracking/index.html.haml b/app/views/projects/error_tracking/index.html.haml
new file mode 100644
index 00000000000..bc02c5f0e5a
--- /dev/null
+++ b/app/views/projects/error_tracking/index.html.haml
@@ -0,0 +1,3 @@
+- page_title _('Errors')
+
+#js-error_tracking{ data: error_tracking_data(@project) }
diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml
index a69146513d8..3f0798a898d 100644
--- a/app/views/projects/forks/_fork_button.html.haml
+++ b/app/views/projects/forks/_fork_button.html.haml
@@ -5,7 +5,7 @@
.bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked
= link_to project_path(forked_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
- = group_icon(namespace, class: "avatar s100 identicon")
+ = group_icon(namespace, class: "avatar rect-avatar s100 identicon")
- else
.avatar-container.s100
= image_tag(avatar, class: "avatar s100")
@@ -18,7 +18,7 @@
class: ("disabled has-tooltip" unless can_create_project),
title: (_('You have reached your project limit') unless can_create_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
- = group_icon(namespace, class: "avatar s100 identicon")
+ = group_icon(namespace, class: "avatar rect-avatar s100 identicon")
- else
.avatar-container.s100
= image_tag(avatar, class: "avatar s100")
diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml
index e8a89b8c6fc..b37dba8b35d 100644
--- a/app/views/projects/forks/error.html.haml
+++ b/app/views/projects/forks/error.html.haml
@@ -1,24 +1,20 @@
-- page_title "Fork project"
+- page_title _("Fork project")
- if @forked_project && !@forked_project.saved?
.alert.alert-danger.alert-block
%h4
= sprite_icon('fork', size: 16)
- Fork Error!
+ = _("Fork Error!")
%p
- You tried to fork
- = link_to_project @project
- but it failed for the following reason:
-
+ = _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) }
- if @forked_project && @forked_project.errors.any?
%p
&ndash;
- error = @forked_project.errors.full_messages.first
- if error.include?("already been taken")
- Name has already been taken
+ = _("Name has already been taken")
- else
= error
%p
- = link_to new_project_fork_path(@project), title: "Fork", class: "btn" do
- Try to fork again
+ = link_to _("Try to fork again"), new_project_fork_path(@project), title: _("Fork"), class: "btn"
diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml
index c63c34c4ebb..0397a7034c7 100644
--- a/app/views/projects/forks/index.html.haml
+++ b/app/views/projects/forks/index.html.haml
@@ -5,12 +5,12 @@
.nav-controls
= form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
- = search_field_tag :filter_projects, nil, placeholder: 'Search forks', class: 'projects-list-filter project-filter-form-field form-control input-short',
+ = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short',
spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' }
.dropdown
%button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
- %span.light sort:
+ %span.light= _("sort:")
- if @sort.present?
= sort_options_hash[@sort]
- else
@@ -30,13 +30,12 @@
- if current_user && can?(current_user, :fork_project, @project)
- if current_user.already_forked?(@project) && current_user.manageable_namespaces.size < 2
- = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: 'Go to your fork', class: 'btn btn-success' do
+ = link_to namespace_project_path(current_user, current_user.fork_of(@project)), title: _('Go to your fork'), class: 'btn btn-success' do
= sprite_icon('fork', size: 12)
- %span Fork
+ %span= _('Fork')
- else
- = link_to new_project_fork_path(@project), title: "Fork project", class: 'btn btn-success' do
+ = link_to new_project_fork_path(@project), title: _("Fork project"), class: 'btn btn-success' do
= sprite_icon('fork', size: 12)
- %span Fork
-
+ %span= _('Fork')
= render 'projects', projects: @forks
diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml
index a603b1024eb..bf03353a565 100644
--- a/app/views/projects/forks/new.html.haml
+++ b/app/views/projects/forks/new.html.haml
@@ -1,13 +1,11 @@
-- page_title "Fork project"
+- page_title _("Fork project")
.row.prepend-top-default
.col-lg-3
%h4.prepend-top-0
- Fork project
+ = _("Fork project")
%p
- A fork is a copy of a project.
- %br
- Forking a repository allows you to make changes without affecting the original project.
+ = _("A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project.").html_safe
.col-lg-9
- if @namespaces.present?
.fork-thumbnail-container.js-fork-content
@@ -17,13 +15,13 @@
= render 'fork_button', namespace: namespace
- else
%strong
- No available namespaces to fork the project.
+ = _("No available namespaces to fork the project.")
%p.prepend-top-default
- You must have permission to create a project in a namespace before forking.
+ = _("You must have permission to create a project in a namespace before forking.")
.save-project-loader.hide.js-fork-content
%h2.text-center
= icon('spinner spin')
- Forking repository
+ = _("Forking repository")
%p.text-center
- Please wait a moment, this page will automatically refresh when ready.
+ = _("Please wait a moment, this page will automatically refresh when ready.")
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 7614d40ba1f..1118b44d7a2 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
@@ -5,11 +5,11 @@
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
- stage = local_assigns.fetch(:stage, false)
-%tr.generic_commit_status{ class: ('retried' if retried) }
+%tr.generic-commit-status{ class: ('retried' if retried) }
%td.status
= render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user)
- %td.generic_commit_status-link
+ %td.generic-commit-status-link
- if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url
= link_to generic_commit_status.target_url do
%span.build-link ##{generic_commit_status.id}
diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml
index b0e22a35fe1..60160f521ad 100644
--- a/app/views/projects/graphs/charts.html.haml
+++ b/app/views/projects/graphs/charts.html.haml
@@ -55,23 +55,23 @@
#{@commits_graph.authors}
= (_("Authors: %{authors}") % { authors: "<strong>#{authors}</strong>" }).html_safe
.col-md-6
+ %p.slead
+ = _("Commits per day of month")
%div
- %p.slead
- = _("Commits per day of month")
%canvas#month-chart
.row
.col-md-6
.col-md-6
+ %p.slead
+ = _("Commits per weekday")
%div
- %p.slead
- = _("Commits per weekday")
%canvas#weekday-chart
.row
.col-md-6
.col-md-6
+ %p.slead
+ = _("Commits per day hour (UTC)")
%div
- %p.slead
- = _("Commits per day hour (UTC)")
%canvas#hour-chart
-# haml-lint:disable InlineJavaScript
diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml
index f1b14d4c4d1..4b2417ff43b 100644
--- a/app/views/projects/graphs/show.html.haml
+++ b/app/views/projects/graphs/show.html.haml
@@ -22,6 +22,6 @@
= s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref }
%input#brush_change{ :type => "hidden" }
.graphs.row
- #contributors-master
+ #contributors-master.svg-w-100
#contributors.clearfix
- %ol.contributors-list.row
+ %ol.contributors-list.svg-w-100.row
diff --git a/app/views/projects/issues/_closed_by_box.html.haml b/app/views/projects/issues/_closed_by_box.html.haml
deleted file mode 100644
index 38469ed4774..00000000000
--- a/app/views/projects/issues/_closed_by_box.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-.issue-closed-by-widget.second-block
- - pluralized_mr_this = merge_request_count > 1 ? "these" : "this"
- - pluralized_mr_is = merge_request_count > 1 ? "are" : "is"
- When #{pluralized_mr_this} merge #{"request".pluralize(merge_request_count)} #{pluralized_mr_is} accepted, this issue will be closed automatically.
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 4917f4b8903..42b6aaa2634 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -5,7 +5,7 @@
= link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
-%section.js-vue-notes-event
+%section.issuable-discussion.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@issue).to_json,
noteable_data: serialize_issuable(@issue),
noteable_type: 'Issue',
diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml
index 1e4e9450ffa..1be1087b36f 100644
--- a/app/views/projects/issues/_form.html.haml
+++ b/app/views/projects/issues/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @issue],
- html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' },
- data: { markdown_version: @issue.cached_markdown_version } do |f|
+ html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml
index 31c72f2f759..9293aa1b309 100644
--- a/app/views/projects/issues/_issue.html.haml
+++ b/app/views/projects/issues/_issue.html.haml
@@ -6,7 +6,7 @@
.issuable-info-container
.issuable-main-info
.issue-title.title
- %span.issue-title-text
+ %span.issue-title-text{ dir: "auto" }
- if issue.confidential?
%span.has-tooltip{ title: _('Confidential') }
= confidential_icon(issue)
@@ -36,8 +36,10 @@
= issue.due_date.to_s(:medium)
- if issue.labels.any?
&nbsp;
- - issue.labels.each do |label|
- = link_to_label(label, subject: issue.project, css_class: 'label-link')
+ - presented_labels_sorted_by_title(issue.labels, issue.project).each do |label|
+ = link_to_label(label, css_class: 'label-link')
+
+ = render_if_exists "projects/issues/issue_weight", issue: issue
.issuable-meta
%ul.controls
@@ -46,7 +48,7 @@
CLOSED
- if issue.assignees.any?
%li
- = render 'shared/issuable/assignees', project: @project, issue: issue
+ = render 'shared/issuable/assignees', project: @project, issuable: issue
= render 'shared/issuable_meta_data', issuable: issue
diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml
deleted file mode 100644
index 5c36d2202a6..00000000000
--- a/app/views/projects/issues/_merge_requests.html.haml
+++ /dev/null
@@ -1,35 +0,0 @@
-- if @merge_requests.any?
- %h2.merge-requests-title
- = pluralize(@merge_requests.count, 'Related Merge Request')
- %ul.unstyled-list.related-merge-requests
- - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id)
- - @merge_requests.each do |merge_request|
- %li
- %span.merge-request-ci-status
- - if merge_request.head_pipeline
- = render_pipeline_status(merge_request.head_pipeline)
- - elsif has_any_head_pipeline
- = icon('blank fw')
- %span.merge-request-id
- = merge_request.to_reference
- %span.merge-request-info
- %strong
- = link_to merge_request.title, merge_request_path(merge_request), class: "row_title"
- - unless @issue.project.id == merge_request.target_project.id
- in
- - project = merge_request.target_project
- = link_to project.full_name, project_path(project)
-
- - if merge_request.merged?
- %span.merge-request-status.prepend-left-10.merged
- Merged
- - elsif merge_request.closed?
- %span.merge-request-status.prepend-left-10.closed
- Closed
- - else
- %span.merge-request-status.prepend-left-10.open
- Open
-
- - if @closed_by_merge_requests.present?
- %li
- = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count}
diff --git a/app/views/projects/issues/_nav_btns.html.haml b/app/views/projects/issues/_nav_btns.html.haml
index e4a0d4b8479..329efa0cdbf 100644
--- a/app/views/projects/issues/_nav_btns.html.haml
+++ b/app/views/projects/issues/_nav_btns.html.haml
@@ -1,11 +1,30 @@
-= render 'shared/issuable/feed_buttons'
-
-- if @can_bulk_update
- = button_tag "Edit issues", class: "btn btn-default append-right-10 js-bulk-update-toggle"
-- if show_new_issue_link?(@project)
- = link_to "New issue", new_project_issue_path(@project,
- issue: { assignee_id: finder.assignee.try(:id),
- milestone_id: finder.milestones.first.try(:id) }),
- class: "btn btn-success",
- title: "New issue",
- id: "new_issue_link"
+- show_feed_buttons = local_assigns.fetch(:show_feed_buttons, true)
+- show_import_button = local_assigns.fetch(:show_import_button, true) && can?(current_user, :import_issues, @project)
+- show_export_button = local_assigns.fetch(:show_export_button, true)
+
+.nav-controls.issues-nav-controls
+ - if show_feed_buttons
+ = render 'shared/issuable/feed_buttons'
+
+ .btn-group.append-right-10<
+ - if show_export_button
+ = render_if_exists 'projects/issues/export_csv/button'
+
+ - if show_import_button
+ = render 'projects/issues/import_csv/button'
+
+ - if @can_bulk_update
+ = button_tag _("Edit issues"), class: "btn btn-default append-right-10 js-bulk-update-toggle"
+ - if show_new_issue_link?(@project)
+ = link_to _("New issue"), new_project_issue_path(@project,
+ issue: { assignee_id: finder.assignee.try(:id),
+ milestone_id: finder.milestones.first.try(:id) }),
+ class: "btn btn-success",
+ title: _("New issue"),
+ id: "new_issue_link"
+
+- if show_export_button
+ = render_if_exists 'projects/issues/export_csv/modal'
+
+- if show_import_button
+ = render 'projects/issues/import_csv/modal'
diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml
index 5374f4a1de0..fbd70cd1906 100644
--- a/app/views/projects/issues/_new_branch.html.haml
+++ b/app/views/projects/issues/_new_branch.html.haml
@@ -30,7 +30,7 @@
= icon('check', class: 'icon')
= _('Create merge request and branch')
- %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
+ %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: _('Create branch') } }
.menu-item
= icon('check', class: 'icon')
= _('Create branch')
diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml
index 1df38db9fd4..6da4956a036 100644
--- a/app/views/projects/issues/_related_branches.html.haml
+++ b/app/views/projects/issues/_related_branches.html.haml
@@ -6,9 +6,9 @@
%li
- target = @project.repository.find_branch(branch).dereferenced_target
- pipeline = @project.pipeline_for(branch, target.sha) if target
- - if pipeline
+ - if can?(current_user, :read_pipeline, pipeline)
%span.related-branch-ci-status
- = render_pipeline_status(pipeline)
+ = render 'ci/status/icon', status: pipeline.detailed_status(current_user)
%span.related-branch-info
%strong
= link_to branch, project_compare_path(@project, from: @project.default_branch, to: branch), class: "ref-name"
diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml
new file mode 100644
index 00000000000..acc2c50294f
--- /dev/null
+++ b/app/views/projects/issues/import_csv/_button.html.haml
@@ -0,0 +1,9 @@
+- type = local_assigns.fetch(:type, :icon)
+
+%button.csv-import-button.btn{ title: _('Import CSV'), class: ('has-tooltip' if type == :icon),
+ data: { toggle: 'modal', target: '.issues-import-modal' } }
+ - if type == :icon
+ = sprite_icon('upload')
+ - else
+ = _('Import CSV')
+
diff --git a/app/views/projects/issues/import_csv/_modal.html.haml b/app/views/projects/issues/import_csv/_modal.html.haml
new file mode 100644
index 00000000000..86bc54786ad
--- /dev/null
+++ b/app/views/projects/issues/import_csv/_modal.html.haml
@@ -0,0 +1,24 @@
+.issues-import-modal.modal
+ .modal-dialog
+ .modal-content
+ = form_tag import_csv_namespace_project_issues_path, multipart: true do
+ .modal-header
+ %h3
+ = _('Import issues')
+ .svg-content.import-export-svg-container
+ = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration'
+ %a.close{ href: '#', 'data-dismiss' => 'modal' } ×
+ .modal-body
+ .modal-text
+ %p
+ = _("Your issues will be imported in the background. Once finished, you'll get a confirmation email.")
+ .form-group
+ = label_tag :file, _('Upload CSV file'), class: 'label-bold'
+ %div
+ = file_field_tag :file, accept: '.csv,text/csv', required: true
+ %p.text-secondary
+ = _('It must have a header row and at least two columns: the first column is the issue title and the second column is the issue description. The separator is automatically detected.')
+ = _('The maximum file size allowed is %{size}.') % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) }
+ .modal-footer
+ %button{ type: 'submit', class: 'btn btn-success', title: _('Import issues'), data: { track_label: "export_issues_csv", track_event: "click_button"} }
+ = _('Import issues')
diff --git a/app/views/projects/issues/index.html.haml b/app/views/projects/issues/index.html.haml
index 1e7737aeb97..39e9e9171cf 100644
--- a/app/views/projects/issues/index.html.haml
+++ b/app/views/projects/issues/index.html.haml
@@ -11,8 +11,7 @@
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
- .nav-controls
- = render "projects/issues/nav_btns"
+ = render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
- if @can_bulk_update
@@ -23,4 +22,4 @@
- if new_issue_email
= render 'projects/issuable_by_email', email: new_issue_email, issuable_type: 'issue'
- else
- = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project)
+ = render 'shared/empty_states/issues', button_path: new_project_issue_path(@project), show_import_button: true
diff --git a/app/views/projects/issues/new.html.haml b/app/views/projects/issues/new.html.haml
index 9a081a42b6f..d1601d7fd10 100644
--- a/app/views/projects/issues/new.html.haml
+++ b/app/views/projects/issues/new.html.haml
@@ -1,9 +1,8 @@
-- add_to_breadcrumbs "Issues", project_issues_path(@project)
-- breadcrumb_title "New"
-- page_title "New Issue"
+- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
+- breadcrumb_title _("New")
+- page_title _("New Issue")
-%h3.page-title
- New Issue
+%h3.page-title= _("New Issue")
%hr
= render "form"
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index b50b3ca207b..d55afee4523 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -1,7 +1,7 @@
- @content_class = "limit-container-width" unless fluid_layout
-- add_to_breadcrumbs "Issues", project_issues_path(@project)
+- add_to_breadcrumbs _("Issues"), project_issues_path(@project)
- breadcrumb_title @issue.to_reference
-- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
+- page_title "#{@issue.title} (#{@issue.to_reference})", _("Issues")
- page_description @issue.description
- page_card_attributes @issue.card_attributes
@@ -14,8 +14,14 @@
.detail-page-header-body
.issuable-status-box.status-box.status-box-issue-closed{ class: issue_button_visibility(@issue, false) }
= sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none')
- %span.d-none.d-sm-block
- Closed
+ .d-none.d-sm-block
+ - if @issue.moved? && can?(current_user, :read_issue, @issue.moved_to)
+ - moved_link_start = "<a href=\"#{issue_path(@issue.moved_to)}\" class=\"text-white text-underline\">".html_safe
+ - moved_link_end = '</a>'.html_safe
+ = s_('IssuableStatus|Closed (%{moved_link_start}moved%{moved_link_end})').html_safe % {moved_link_start: moved_link_start,
+ moved_link_end: moved_link_end}
+ - else
+ = _("Closed")
.issuable-status-box.status-box.status-box-open{ class: issue_button_visibility(@issue, true) }
= sprite_icon('issue-open-m', size: 16, css_class: 'd-block d-sm-none')
%span.d-none.d-sm-block Open
@@ -55,7 +61,7 @@
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'd-none d-sm-none d-md-block btn btn-grouped btn-spam', title: 'Submit as spam'
- if can_create_issue
- = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped new-issue-link btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do
+ = link_to new_project_issue_path(@project), class: 'd-none d-sm-none d-md-block btn btn-grouped btn-success btn-inverted', title: 'New issue', id: 'new_issue_link' do
New issue
.issue-details.issuable-details
@@ -66,16 +72,18 @@
%h2.title= markdown_field(@issue, :title)
- if @issue.description.present?
.description{ class: can?(current_user, :update_issue, @issue) ? 'js-task-list-container' : '' }
- .wiki= markdown_field(@issue, :description)
+ .md= markdown_field(@issue, :description)
%textarea.hidden.js-task-list-field= @issue.description
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago')
- #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ = render_if_exists 'projects/issues/related_issues'
- #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
- // This element is filled in using JavaScript.
+ #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
+
+ - if can?(current_user, :download_code, @project)
+ #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
+ -# This element is filled in using JavaScript.
.content-block.emoji-block.emoji-block-sticky
.row
@@ -85,7 +93,6 @@
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@issue), notes_filters: UserPreference.notes_filters.to_json } }
= render 'new_branch' unless @issue.confidential?
- %section.issuable-discussion
- = render 'projects/issues/discussion'
+ = render_if_exists 'projects/issues/discussion'
-= render 'shared/issuable/sidebar', issuable: @issue
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @issue.assignees
diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml
index 59592abcf6a..afea5268006 100644
--- a/app/views/projects/jobs/index.html.haml
+++ b/app/views/projects/jobs/index.html.haml
@@ -8,10 +8,6 @@
.nav-controls
- if can?(current_user, :update_build, @project)
- - if @all_builds.running_or_pending.limit(1).any? # rubocop: disable CodeReuse/ActiveRecord
- = link_to 'Cancel running', cancel_all_project_jobs_path(@project),
- data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post
-
- unless @repository.gitlab_ci_yml
= link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn btn-info'
diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml
index 475bae887ec..81a53f22f67 100644
--- a/app/views/projects/jobs/show.html.haml
+++ b/app/views/projects/jobs/show.html.haml
@@ -8,6 +8,7 @@
%div{ class: container_class }
#js-job-vue-app{ data: { endpoint: project_job_path(@project, @build, format: :json),
+ deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'),
runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'),
runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'),
build_options: javascript_build_options } }
diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml
index 56b06374d6d..511d7a82d1b 100644
--- a/app/views/projects/labels/index.html.haml
+++ b/app/views/projects/labels/index.html.haml
@@ -5,42 +5,36 @@
- subscribed = params[:subscribed]
- labels_or_filters = @labels.exists? || @prioritized_labels.exists? || search.present? || subscribed.present?
-- if labels_or_filters && can_admin_label
- - content_for(:header_content) do
- .nav-controls
- = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new"
-
- if labels_or_filters
#promote-label-modal
%div{ class: container_class }
- = render 'shared/labels/nav'
+ = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label
.labels-container.prepend-top-10
- - if can_admin_label
- - if search.blank?
- %p.text-muted
- = _('Labels can be applied to issues and merge requests.')
- %br
- = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
- -# Only show it in the first page
- - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
- .prioritized-labels{ class: ('hide' if hide) }
- %h5.prepend-top-10= _('Prioritized Labels')
- .content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_project_labels_path(@project) }
- #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
- = render 'shared/empty_states/priority_labels'
- - if @prioritized_labels.present?
- = render partial: 'shared/label', subject: @project, collection: @prioritized_labels, as: :label, locals: { force_priority: true }
- - elsif search.present?
- .nothing-here-block
- = _('No prioritised labels with such name or description')
+ - if can_admin_label && search.blank?
+ %p.text-muted
+ = _('Labels can be applied to issues and merge requests.')
+ %br
+ = _('Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.')
+
+ -# Only show it in the first page
+ - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1')
+ .prioritized-labels{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] }
+ %h5.prepend-top-10= _('Prioritized Labels')
+ .content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } }
+ #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" }
+ = render 'shared/empty_states/priority_labels'
+ - if @prioritized_labels.present?
+ = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project }
+ - elsif search.present?
+ .nothing-here-block
+ = _('No prioritised labels with such name or description')
- if @labels.present?
.other-labels
- - if can_admin_label
- %h5{ class: ('hide' if hide) }= _('Other Labels')
+ %h5{ class: ('hide' if hide) }= _('Other Labels')
.content-list.manage-labels-list.js-other-labels
- = render partial: 'shared/label', subject: @project, collection: @labels, as: :label
+ = render partial: 'shared/label', collection: @labels, as: :label, locals: { subject: @project }
= paginate @labels, theme: 'gitlab'
- elsif search.present?
.other-labels
diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml
index 13b967beba1..a7c9e54506d 100644
--- a/app/views/projects/merge_requests/_form.html.haml
+++ b/app/views/projects/merge_requests/_form.html.haml
@@ -1,4 +1,3 @@
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request],
- html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' },
- data: { markdown_version: @merge_request.cached_markdown_version } do |f|
+ html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter
diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml
index faa070d0389..67e5e4ca62d 100644
--- a/app/views/projects/merge_requests/_merge_request.html.haml
+++ b/app/views/projects/merge_requests/_merge_request.html.haml
@@ -34,8 +34,8 @@
= merge_request.target_branch
- if merge_request.labels.any?
&nbsp;
- - merge_request.labels.each do |label|
- = link_to_label(label, subject: merge_request.project, type: :merge_request, css_class: 'label-link')
+ - presented_labels_sorted_by_title(merge_request.labels, merge_request.project).each do |label|
+ = link_to_label(label, type: :merge_request, css_class: 'label-link')
.issuable-meta
%ul.controls
@@ -46,16 +46,17 @@
%li.issuable-status.d-none.d-sm-inline-block
= icon('ban')
CLOSED
- - if merge_request.head_pipeline
+ - if can?(current_user, :read_pipeline, merge_request.head_pipeline)
%li.issuable-pipeline-status.d-none.d-sm-inline-block
- = render_pipeline_status(merge_request.head_pipeline)
+ = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user)
- if merge_request.open? && merge_request.broken?
%li.issuable-pipeline-broken.d-none.d-sm-inline-block
= link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do
= icon('exclamation-triangle')
- - if merge_request.assignee
+ - if merge_request.assignees.any?
%li
- = link_to_member(merge_request.source_project, merge_request.assignee, name: false, title: _('Assigned to :name'))
+ = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request
+ = render_if_exists 'projects/merge_requests/approvals_count', merge_request: merge_request
= render 'shared/issuable_meta_data', issuable: merge_request
diff --git a/app/views/projects/merge_requests/_merge_requests.html.haml b/app/views/projects/merge_requests/_merge_requests.html.haml
index bd6f1c05949..57fbd360d46 100644
--- a/app/views/projects/merge_requests/_merge_requests.html.haml
+++ b/app/views/projects/merge_requests/_merge_requests.html.haml
@@ -1,5 +1,5 @@
%ul.content-list.mr-list.issuable-list
- - if @merge_requests.exists?
+ - if @merge_requests.present?
= render @merge_requests
- else
= render 'shared/empty_states/merge_requests'
diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml
index 1a9ab288683..7f2c9dcacfd 100644
--- a/app/views/projects/merge_requests/_mr_box.html.haml
+++ b/app/views/projects/merge_requests/_mr_box.html.haml
@@ -5,7 +5,7 @@
%div
- if @merge_request.description.present?
.description{ class: can?(current_user, :update_merge_request, @merge_request) ? 'js-task-list-container' : '' }
- .wiki
+ .md
= markdown_field(@merge_request, :description)
%textarea.hidden.js-task-list-field
= @merge_request.description
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index 3cd83feb842..92e34b3ceda 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -1,8 +1,9 @@
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
+- can_reopen_merge_request = can?(current_user, :reopen_merge_request, @merge_request)
- if @merge_request.closed_without_fork?
.alert.alert-danger
- %p The source project of this merge request has been removed.
+ The source project of this merge request has been removed.
.detail-page-header
.detail-page-header-body
@@ -33,10 +34,11 @@
- if can_update_merge_request
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
+ - if can_reopen_merge_request
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
- if can_update_merge_request
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "d-none d-sm-none d-md-block btn btn-grouped js-issuable-edit qa-edit-button"
- = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_update_merge_request
+ = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request, can_reopen: can_reopen_merge_request
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
deleted file mode 100644
index a6e2565a485..00000000000
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ /dev/null
@@ -1,36 +0,0 @@
-- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
-- content_for :page_specific_javascripts do
- = page_specific_javascript_tag('lib/ace.js')
-= render "projects/merge_requests/mr_title"
-
-.merge-request-details.issuable-details
- = render "projects/merge_requests/mr_box"
-
-= render 'shared/issuable/sidebar', issuable: @merge_request
-
-#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
- resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
- .loading{ "v-if" => "isLoading" }
- %i.fa.fa-spinner.fa-spin
-
- .nothing-here-block{ "v-if" => "hasError" }
- {{conflictsData.errorMessage}}
-
- = render partial: "projects/merge_requests/conflicts/commit_stats"
-
- .files-wrapper{ "v-if" => "!isLoading && !hasError" }
- .files
- .diff-file.file-holder.conflict{ "v-for" => "file in conflictsData.files" }
- .js-file-title.file-title
- %i.fa.fa-fw{ ":class" => "file.iconClass" }
- %strong {{file.filePath}}
- = render partial: 'projects/merge_requests/conflicts/file_actions'
- .diff-content.diff-wrap-lines
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
- = render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
- %parallel-conflict-lines{ ":file" => "file" }
- %div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
- = render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
-
- = render partial: "projects/merge_requests/conflicts/submit_form"
diff --git a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
index 8181267184a..55c89f137c5 100644
--- a/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
+++ b/app/views/projects/merge_requests/conflicts/_submit_form.html.haml
@@ -6,7 +6,7 @@
.form-group.row
.col-md-4
%h4= _('Resolve conflicts on source branch')
- .resolve-info
+ .resolve-info{ "v-pre": true }
= translation.html_safe
.col-md-8
%label.label-bold{ "for" => "commit-message" }
diff --git a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
index d828cb6cf9e..03226de120d 100644
--- a/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
+++ b/app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
@@ -1,5 +1,5 @@
%inline-conflict-lines{ "inline-template" => "true", ":file" => "file" }
- %table
+ %table.diff-wrap-lines.code.js-syntax-highlight
%tr.line_holder.diff-inline{ "v-for" => "line in file.inlineLines" }
%td.diff-line-num.new_line{ ":class" => "lineCssClass(line)", "v-if" => "!line.isHeader" }
%a {{line.new_line}}
diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml
index a6e2565a485..f48390aa046 100644
--- a/app/views/projects/merge_requests/conflicts/show.html.haml
+++ b/app/views/projects/merge_requests/conflicts/show.html.haml
@@ -6,7 +6,7 @@
.merge-request-details.issuable-details
= render "projects/merge_requests/mr_box"
-= render 'shared/issuable/sidebar', issuable: @merge_request
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees
#conflicts{ "v-cloak" => "true", data: { conflicts_path: conflicts_project_merge_request_path(@merge_request.project, @merge_request, format: :json),
resolve_conflicts_path: resolve_conflicts_project_merge_request_path(@merge_request.project, @merge_request) } }
@@ -26,9 +26,9 @@
%strong {{file.filePath}}
= render partial: 'projects/merge_requests/conflicts/file_actions'
.diff-content.diff-wrap-lines
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ .file-content{ "v-show" => "!isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
= render partial: "projects/merge_requests/conflicts/components/inline_conflict_lines"
- .diff-wrap-lines.code.file-content.js-syntax-highlight{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
+ .file-content{ "v-show" => "isParallel && file.resolveMode === 'interactive' && file.type === 'text'" }
%parallel-conflict-lines{ ":file" => "file" }
%div{ "v-show" => "file.resolveMode === 'edit' || file.type === 'text-editor'" }
= render partial: "projects/merge_requests/conflicts/components/diff_file_editor"
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 3f2e59d05e3..05aeb5d972b 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -5,8 +5,9 @@
- page_title "#{@merge_request.title} (#{@merge_request.to_reference})", "Merge Requests"
- page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes
+- suggest_changes_help_path = help_page_path('user/discussions/index.md', anchor: 'suggest-changes')
-.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } }
+.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } }
= render "projects/merge_requests/mr_title"
.merge-request-details.issuable-details{ data: { id: @merge_request.project.id } }
@@ -36,21 +37,21 @@
%ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%li.notes-tab.qa-notes-tab
= tab_link_for @merge_request, :show, force_link: @commit.present? do
- Discussion
+ = _("Discussion")
%span.badge.badge-pill= @merge_request.related_notes.user.count
- if @merge_request.source_project
%li.commits-tab
= tab_link_for @merge_request, :commits do
- Commits
+ = _("Commits")
%span.badge.badge-pill= @commits_count
- if @pipelines.any?
%li.pipelines-tab
= tab_link_for @merge_request, :pipelines do
- Pipelines
+ = _("Pipelines")
%span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
%li.diffs-tab.qa-diffs-tab
= tab_link_for @merge_request, :diffs do
- Changes
+ = _("Changes")
%span.badge.badge-pill= @merge_request.diff_size
.d-inline-flex.flex-wrap
#js-vue-discussion-filter{ data: { default_filter: current_user&.notes_filter_for(@merge_request),
@@ -58,6 +59,7 @@
#js-vue-discussion-counter
.tab-content#diff-notes-app
+ #js-diff-file-finder
#notes.notes.tab-pane.voting_notes
.row
%section.col-md-12
@@ -67,7 +69,7 @@
noteable_data: serialize_issuable(@merge_request),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
- help_page_path: nil,
+ help_page_path: suggest_changes_help_path,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
#commits.commits.tab-pane
@@ -77,15 +79,17 @@
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
- help_page_path: nil,
+ help_page_path: suggest_changes_help_path,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
project_path: project_path(@merge_request.project),
- changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg') } }
+ changes_empty_state_illustration: image_path('illustrations/merge_request_changes_empty.svg'),
+ is_fluid_layout: fluid_layout.to_s } }
.mr-loading-status
= spinner
-= render 'shared/issuable/sidebar', issuable: @merge_request
+= render 'shared/issuable/sidebar', issuable_sidebar: @issuable_sidebar, assignees: @merge_request.assignees
+
- if @merge_request.can_be_reverted?(current_user)
= render "projects/commit/change", type: 'revert', commit: @merge_request.merge_commit, title: @merge_request.title
- if @merge_request.can_be_cherry_picked?
diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml
index ebd3229e42b..5cc6b5a173b 100644
--- a/app/views/projects/milestones/_form.html.haml
+++ b/app/views/projects/milestones/_form.html.haml
@@ -1,27 +1,28 @@
= form_for [@project.namespace.becomes(Namespace), @project, @milestone],
- html: {class: 'milestone-form common-note-form js-quick-submit js-requires-input'},
- data: { markdown_version: @milestone.cached_markdown_version } do |f|
+ html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
= form_errors(@milestone)
.row
.col-md-6
.form-group.row
- = f.label :title, "Title", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :title, _('Title')
.col-sm-10
- = f.text_field :title, maxlength: 255, class: "qa-milestone-title form-control", required: true, autofocus: true
+ = f.text_field :title, maxlength: 255, class: 'qa-milestone-title form-control', required: true, autofocus: true
.form-group.row.milestone-description
- = f.label :description, "Description", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :description, _('Description')
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project) } do
- = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: 'Write milestone description...'
+ = render 'projects/zen', f: f, attr: :description, classes: 'qa-milestone-description note-textarea', placeholder: _('Write milestone description...')
= render 'shared/notes/hints'
.clearfix
.error-alert
- = render "shared/milestones/form_dates", f: f
+ = render 'shared/milestones/form_dates', f: f
.form-actions
- if @milestone.new_record?
- = f.submit 'Create milestone', class: "btn-success btn qa-milestone-create-button"
- = link_to "Cancel", project_milestones_path(@project), class: "btn btn-cancel"
+ = f.submit _('Create milestone'), class: 'btn-create btn qa-milestone-create-button'
+ = link_to _('Cancel'), project_milestones_path(@project), class: 'btn btn-cancel'
- else
- = f.submit 'Save changes', class: "btn-success btn"
- = link_to "Cancel", project_milestone_path(@project, @milestone), class: "btn btn-cancel"
+ = f.submit _('Save changes'), class: 'btn-success btn'
+ = link_to _('Cancel'), project_milestone_path(@project, @milestone), class: 'btn btn-cancel'
diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml
index 4006a468792..aa564e00af9 100644
--- a/app/views/projects/milestones/edit.html.haml
+++ b/app/views/projects/milestones/edit.html.haml
@@ -1,14 +1,14 @@
- @no_container = true
-- breadcrumb_title "Edit"
-- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
-- page_title "Edit", @milestone.title, "Milestones"
+- breadcrumb_title _('Edit')
+- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project)
+- page_title _('Edit'), @milestone.title, _('Milestones')
%div{ class: container_class }
%h3.page-title
- Edit Milestone
+ = _('Edit Milestone')
%hr
- = render "form"
+ = render 'form'
diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml
index 57f3c640696..a3414c16d73 100644
--- a/app/views/projects/milestones/index.html.haml
+++ b/app/views/projects/milestones/index.html.haml
@@ -1,15 +1,16 @@
- @no_container = true
-- page_title 'Milestones'
+- page_title _('Milestones')
%div{ class: container_class }
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
.nav-controls
+ = render 'shared/milestones/search_form'
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
- = link_to new_project_milestone_path(@project), class: "btn btn-success qa-new-project-milestone", title: 'New milestone' do
- New milestone
+ = link_to new_project_milestone_path(@project), class: 'btn btn-success qa-new-project-milestone', title: _('New milestone') do
+ = _('New milestone')
.milestones
#delete-milestone-modal
@@ -20,6 +21,6 @@
- if @milestones.blank?
%li
- .nothing-here-block No milestones to show
+ .nothing-here-block= _('No milestones to show')
= paginate @milestones, theme: 'gitlab'
diff --git a/app/views/projects/milestones/new.html.haml b/app/views/projects/milestones/new.html.haml
index 01cc951e8c2..79207fd70b5 100644
--- a/app/views/projects/milestones/new.html.haml
+++ b/app/views/projects/milestones/new.html.haml
@@ -1,12 +1,12 @@
- @no_container = true
-- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
-- breadcrumb_title "New"
-- page_title "New Milestone"
+- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project)
+- breadcrumb_title _('New')
+- page_title _('New Milestone')
%div{ class: container_class }
%h3.page-title
- New Milestone
+ = _('New Milestone')
%hr
- = render "form"
+ = render 'form'
diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml
index 5859de61d71..78b416edd5c 100644
--- a/app/views/projects/milestones/show.html.haml
+++ b/app/views/projects/milestones/show.html.haml
@@ -1,30 +1,30 @@
- @no_container = true
-- add_to_breadcrumbs "Milestones", project_milestones_path(@project)
+- add_to_breadcrumbs _('Milestones'), project_milestones_path(@project)
- breadcrumb_title @milestone.title
-- page_title @milestone.title, "Milestones"
+- page_title @milestone.title, _('Milestones')
- page_description @milestone.description
%div{ class: container_class }
.detail-page-header.milestone-page-header
.status-box{ class: status_box_class(@milestone) }
- if @milestone.closed?
- Closed
+ = _('Closed')
- elsif @milestone.expired?
- Past due
+ = _('Past due')
- elsif @milestone.upcoming?
- Upcoming
+ = _('Upcoming')
- else
- Open
+ = _('Open')
.header-text-content
%span.identifier
%strong
- Milestone
+ = _('Milestone')
- if @milestone.due_date || @milestone.start_date
= milestone_date_range(@milestone)
.milestone-buttons
- if can?(current_user, :admin_milestone, @project)
- = link_to edit_project_milestone_path(@project, @milestone), class: "btn btn-grouped btn-nr" do
- Edit
+ = link_to edit_project_milestone_path(@project, @milestone), class: 'btn btn-grouped btn-nr' do
+ = _('Edit')
- if @project.group
%button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal',
@@ -39,13 +39,13 @@
#promote-milestone-modal
- if @milestone.active?
- = link_to 'Close milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: "btn btn-close btn-nr btn-grouped"
+ = link_to _('Close milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: 'btn btn-close btn-nr btn-grouped'
- else
- = link_to 'Reopen milestone', project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-reopen btn-nr btn-grouped"
+ = link_to _('Reopen milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: 'btn btn-reopen btn-nr btn-grouped'
= render 'shared/milestones/delete_button'
- %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" }
+ %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: '#' }
= icon('angle-double-left')
.detail-page-description.milestone-detail
@@ -54,18 +54,17 @@
%div
- if @milestone.description.present?
- .description
- .wiki
- = markdown_field(@milestone, :description)
+ .description.md
+ = markdown_field(@milestone, :description)
= render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero?
.alert.alert-success.prepend-top-default
- %span Assign some issues to this milestone.
+ %span= _('Assign some issues to this milestone.')
- elsif @milestone.complete?(current_user) && @milestone.active?
.alert.alert-success.prepend-top-default
- %span All issues for this milestone are closed. You may close this milestone now.
+ %span= _('All issues for this milestone are closed. You may close this milestone now.')
= render 'deprecation_message'
= render 'shared/milestones/tabs', milestone: @milestone
diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml
index 293a2e3ebfe..ee82d68d398 100644
--- a/app/views/projects/mirrors/_authentication_method.html.haml
+++ b/app/views/projects/mirrors/_authentication_method.html.haml
@@ -1,14 +1,12 @@
- mirror = f.object
-- is_push = local_assigns.fetch(:is_push, false)
- auth_options = [[_('Password'), 'password'], [_('SSH public key'), 'ssh_public_key']]
-- regen_data = { auth_method: 'ssh_public_key', regenerate_ssh_private_key: true }
-- ssh_public_key_present = mirror.ssh_public_key.present?
.form-group
= f.label :auth_method, _('Authentication method'), class: 'label-bold'
= f.select :auth_method,
options_for_select(auth_options, mirror.auth_method),
{}, { class: "form-control js-mirror-auth-type qa-authentication-method" }
+ = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type"
.form-group
.collapse.js-well-changing-auth
@@ -16,21 +14,3 @@
.well-password-auth.collapse.js-well-password-auth
= f.label :password, _("Password"), class: "label-bold"
= f.password_field :password, value: mirror.password, class: 'form-control qa-password', autocomplete: 'new-password'
- - unless is_push
- .well-ssh-auth.collapse.js-well-ssh-auth
- %p.js-ssh-public-key-present{ class: ('collapse' unless ssh_public_key_present) }
- = _('Here is the public SSH key that needs to be added to the remote server. For more information, please refer to the documentation.')
- %p.js-ssh-public-key-pending{ class: ('collapse' if ssh_public_key_present) }
- = _('An SSH key will be automatically generated when the form is submitted. For more information, please refer to the documentation.')
-
- .clearfix.js-ssh-public-key-wrap{ class: ('collapse' unless ssh_public_key_present) }
- %code.prepend-top-10.ssh-public-key
- = mirror.ssh_public_key
- = clipboard_button(text: mirror.ssh_public_key, title: _("Copy SSH public key to clipboard"), class: 'prepend-top-10 btn-copy-ssh-public-key')
-
- = button_tag type: 'button',
- data: { endpoint: project_mirror_path(@project), project_data: { import_data_attributes: regen_data } },
- class: "btn btn-inverted btn-warning prepend-top-10 js-btn-regenerate-ssh-key#{ ' collapse' unless ssh_public_key_present }" do
- = icon('spinner spin', class: 'js-spinner d-none')
- = _('Regenerate key')
- = render 'projects/mirrors/regenerate_public_ssh_key_confirm_modal'
diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
new file mode 100644
index 00000000000..356cb43f07f
--- /dev/null
+++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
@@ -0,0 +1 @@
+.badge.badge-warning.qa-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled')
diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml
index 35a6885318a..33e5a6e67c3 100644
--- a/app/views/projects/mirrors/_instructions.html.haml
+++ b/app/views/projects/mirrors/_instructions.html.haml
@@ -7,7 +7,7 @@
%li
- minutes = Gitlab.config.gitlab_shell.git_timeout / 60
= _("The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination.") % { number_of_minutes: minutes }
- %li= _('The Git LFS objects will <strong>not</strong> be synced.').html_safe
+ %li= mirror_lfs_sync_message
%li
= _('This user will be the author of all events in the activity feed that are the result of an update,
like new branches being created or new commits being pushed to existing branches.')
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 21b105e6f80..e68fa5d08c7 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- protocols = Gitlab::UrlSanitizer::ALLOWED_SCHEMES.join('|')
%section.settings.project-mirror-settings.js-mirror-settings.no-animate.qa-mirroring-repositories-settings#js-push-remote-settings{ class: ('expanded' if expanded) }
@@ -11,7 +11,7 @@
= link_to _('Read more'), help_page_path('workflow/repository_mirroring'), target: '_blank'
.settings-content
- = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'false', data: mirrors_form_data_attributes } do |f|
+ = form_for @project, url: project_mirror_path(@project), html: { class: 'gl-show-field-errors js-mirror-form', autocomplete: 'new-password', data: mirrors_form_data_attributes } do |f|
.panel.panel-default
.panel-heading
%h3.panel-title= _('Mirror a repository')
@@ -20,7 +20,7 @@
.form-group.has-feedback
= label_tag :url, _('Git repository URL'), class: 'label-light'
- = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+"
+ = text_field_tag :url, nil, class: 'form-control js-mirror-url js-repo-url qa-mirror-repository-url-input', placeholder: _('Input your repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password'
= render 'projects/mirrors/instructions'
@@ -29,7 +29,7 @@
.form-check.append-bottom-10
= check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input'
= label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label'
- = link_to icon('question-circle'), help_page_path('user/project/protected_branches')
+ = link_to icon('question-circle'), help_page_path('user/project/protected_branches'), target: '_blank'
.panel-footer
= f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror
@@ -49,17 +49,19 @@
%tbody.js-mirrors-table-body
= render_if_exists 'projects/mirrors/table_pull_row'
- @project.remote_mirrors.each_with_index do |mirror, index|
- - if mirror.enabled
- %tr.qa-mirrored-repository-row
- %td.qa-mirror-repository-url= mirror.safe_url
- %td= _('Push')
- %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
- %td
- - if mirror.last_error.present?
- .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
- %td.mirror-action-buttons
- .btn-group.mirror-actions-group.pull-right{ role: 'group' }
- - if mirror.ssh_key_auth?
- = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
- = render 'shared/remote_mirror_update_button', remote_mirror: mirror
- %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
+ - next if mirror.new_record?
+ %tr.qa-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?) }
+ %td.qa-mirror-repository-url= mirror.safe_url
+ %td= _('Push')
+ %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %td
+ - if mirror.disabled?
+ = render 'projects/mirrors/disabled_mirror_badge'
+ - if mirror.last_error.present?
+ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
+ %td
+ .btn-group.mirror-actions-group.pull-right{ role: 'group' }
+ - if mirror.ssh_key_auth?
+ = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
+ = render 'shared/remote_mirror_update_button', remote_mirror: mirror
+ %button.js-delete-mirror.qa-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml
index 1d9c83653fe..b7c885b4a63 100644
--- a/app/views/projects/mirrors/_mirror_repos_push.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml
@@ -5,4 +5,4 @@
= rm_f.hidden_field :url, class: 'js-mirror-url-hidden', required: true, pattern: "(#{protocols}):\/\/.+"
= rm_f.hidden_field :only_protected_branches, class: 'js-mirror-protected-hidden'
= render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f }
- = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f, is_push: true }
+ = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f }
diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml
index f61aa6ecd11..7762fb4b844 100644
--- a/app/views/projects/mirrors/_ssh_host_keys.html.haml
+++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml
@@ -3,7 +3,7 @@
- verified_at = mirror.ssh_known_hosts_verified_at
.form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) }
- %button.btn.btn-inverted.btn-success.inline.js-detect-host-keys.append-right-10{ type: 'button' }
+ %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.append-right-10{ type: 'button' }
= icon('spinner spin', class: 'js-spinner d-none')
= _('Detect host keys')
.fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) }
diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml
index a760d02c4c3..d7e16dbd40c 100644
--- a/app/views/projects/new.html.haml
+++ b/app/views/projects/new.html.haml
@@ -16,6 +16,10 @@
= _('A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), %{among_other_things_link}.').html_safe % { among_other_things_link: among_other_things_link }
%p
= _('All features are enabled for blank projects, from templates, or when importing, but you can disable them afterward in the project settings.')
+ = render_if_exists 'projects/new_ci_cd_banner_external_repo'
+ %p
+ - pages_getting_started_guide = link_to _('Pages getting started guide'), help_page_path("user/project/pages/getting_started_part_two", anchor: "fork-a-project-to-get-started-from"), target: '_blank'
+ = _('Information about additional Pages templates and how to install them can be found in our %{pages_getting_started_guide}.').html_safe % { pages_getting_started_guide: pages_getting_started_guide }
.md
= brand_new_project_guidelines
%p
@@ -33,19 +37,25 @@
%span.d-block.d-sm-none Blank
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#create-from-template-pane', id: 'create-from-template-tab', data: { toggle: 'tab', track_label: 'create_from_template', track_event: "click_tab" }, role: 'tab' }
- %span.d-none.d-sm-block Create from template
+ %span.d-none.d-sm-block.qa-project-create-from-template-tab Create from template
%span.d-block.d-sm-none Template
%li.nav-item{ role: 'presentation' }
%a.nav-link{ href: '#import-project-pane', id: 'import-project-tab', data: { toggle: 'tab', track_label: 'import_project', track_event: "click_tab" }, role: 'tab' }
%span.d-none.d-sm-block Import project
%span.d-block.d-sm-none Import
+ = render_if_exists 'projects/new_ci_cd_only_project_tab', active_tab: active_tab
.tab-content.gitlab-tab-content
.tab-pane{ id: 'blank-project-pane', class: active_when(active_tab == 'blank'), role: 'tabpanel' }
= form_for @project, html: { class: 'new_project' } do |f|
= render 'new_project_fields', f: f, project_name_id: "blank-project-name"
- .tab-pane.no-padding{ id: 'create-from-template-pane', class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ #create-from-template-pane.tab-pane.px-0.pb-0{ class: active_when(active_tab == 'template'), role: 'tabpanel' }
+ .card-slim.m-4.p-4
+ %div
+ - contributing_templates_url = 'https://gitlab.com/gitlab-org/project-templates/contributing'
+ - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: contributing_templates_url }
+ = _('Learn how to %{link_start}contribute to the built-in templates%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
= form_for @project, html: { class: 'new_project' } do |f|
.project-template
.form-group
@@ -60,6 +70,8 @@
%h4 No import options available
%p Contact an administrator to enable options for importing your project.
+ = render_if_exists 'projects/new_ci_cd_only_project_pane', active_tab: active_tab
+
.save-project-loader.d-none
.center
%h2
diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml
index eb6838cec8d..044adb75bea 100644
--- a/app/views/projects/notes/_actions.html.haml
+++ b/app/views/projects/notes/_actions.html.haml
@@ -41,9 +41,9 @@
.note-actions-item
= button_tag title: 'Add reaction', class: "note-action-button note-emoji-button js-add-award js-note-emoji} has-tooltip btn btn-transparent", data: { position: 'right', container: 'body' } do
= icon('spinner spin')
- %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
- %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
+ %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile')
- if note_editable
.note-actions-item
diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml
index ae8c801b705..138e2864bad 100644
--- a/app/views/projects/pages/_destroy.haml
+++ b/app/views/projects/pages/_destroy.haml
@@ -9,4 +9,4 @@
.form-actions
= link_to 'Remove pages', project_pages_path(@project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove"
- else
- .nothing-here-block Only the project owner can remove pages
+ .nothing-here-block Only project maintainers can remove pages
diff --git a/app/views/projects/pages/_https_only.html.haml b/app/views/projects/pages/_https_only.html.haml
index ce3ef29c32e..74478ee011c 100644
--- a/app/views/projects/pages/_https_only.html.haml
+++ b/app/views/projects/pages/_https_only.html.haml
@@ -3,7 +3,7 @@
.form-check
= f.check_box :pages_https_only, class: 'form-check-input', disabled: pages_https_only_disabled?
= f.label :pages_https_only, class: pages_https_only_label_class do
- %strong Force domains with SSL certificates to use HTTPS
+ %strong Force HTTPS (requires valid certificates)
- unless pages_https_only_disabled?
.prepend-top-10
diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml
index 0d848f7899c..1e50a101c1e 100644
--- a/app/views/projects/pages_domains/_form.html.haml
+++ b/app/views/projects/pages_domains/_form.html.haml
@@ -5,26 +5,25 @@
%p= msg
.form-group.row
- = f.label :domain, class: 'col-form-label col-sm-2' do
- Domain
+ .col-sm-2.col-form-label
+ = f.label :domain, _("Domain")
.col-sm-10
= f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
- if Gitlab.config.pages.external_https
.form-group.row
- = f.label :certificate, class: 'col-form-label col-sm-2' do
- Certificate (PEM)
+ .col-sm-2.col-form-label
+ = f.label :certificate, _("Certificate (PEM)")
.col-sm-10
= f.text_area :certificate, rows: 5, class: 'form-control'
- %span.help-inline Upload a certificate for your domain with all intermediates
+ %span.help-inline= _("Upload a certificate for your domain with all intermediates")
.form-group.row
- = f.label :key, class: 'col-form-label col-sm-2' do
- Key (PEM)
+ .col-sm-2.col-form-label
+ = f.label :key, _("Key (PEM)")
.col-sm-10
= f.text_area :key, rows: 5, class: 'form-control'
- %span.help-inline Upload a private key for your certificate
+ %span.help-inline= _("Upload a private key for your certificate")
- else
.nothing-here-block
- Support for custom certificates is disabled.
- Ask your system's administrator to enable it.
+ = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.")
diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml
index 342b1482df7..e11387ae742 100644
--- a/app/views/projects/pages_domains/edit.html.haml
+++ b/app/views/projects/pages_domains/edit.html.haml
@@ -1,4 +1,4 @@
-- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- add_to_breadcrumbs _("Pages"), project_pages_path(@project)
- breadcrumb_title @domain.domain
- page_title @domain.domain
%h3.page-title
@@ -8,4 +8,4 @@
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions
- = f.submit 'Save Changes', class: "btn btn-success"
+ = f.submit _('Save Changes'), class: "btn btn-success"
diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml
index 94ad1470052..c7cefa87c76 100644
--- a/app/views/projects/pages_domains/new.html.haml
+++ b/app/views/projects/pages_domains/new.html.haml
@@ -1,12 +1,12 @@
-- add_to_breadcrumbs "Pages", project_pages_path(@project)
-- page_title 'New Pages Domain'
+- add_to_breadcrumbs _("Pages"), project_pages_path(@project)
+- page_title _('New Pages Domain')
%h3.page-title
- New Pages Domain
+ = _("New Pages Domain")
%hr.clearfix
%div
= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f|
= render 'form', { f: f }
.form-actions
- = f.submit 'Create New Domain', class: "btn btn-success"
+ = f.submit _('Create New Domain'), class: "btn btn-success"
.float-right
= link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-cancel'
diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml
index a8484187493..82147568981 100644
--- a/app/views/projects/pages_domains/show.html.haml
+++ b/app/views/projects/pages_domains/show.html.haml
@@ -1,6 +1,6 @@
-- add_to_breadcrumbs "Pages", project_pages_path(@project)
+- add_to_breadcrumbs _("Pages"), project_pages_path(@project)
- breadcrumb_title @domain.domain
-- page_title "#{@domain.domain}", 'Pages Domains'
+- page_title "#{@domain.domain}", _('Pages Domains')
- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}."
- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled?
@@ -9,37 +9,37 @@
= content_for :flash_message do
.alert.alert-warning
.container-fluid.container-limited
- This domain is not verified. You will need to verify ownership before access is enabled.
+ = _("This domain is not verified. You will need to verify ownership before access is enabled.")
%h3.page-title.with-button
- = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right'
- Pages Domain
+ = link_to _('Edit'), edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success float-right'
+ = _("Pages Domain")
.table-holder
%table.table
%tr
%td
- Domain
+ = _("Domain")
%td
= link_to @domain.url do
= @domain.url
= icon('external-link')
%tr
%td
- DNS
+ = _("DNS")
%td
.input-group
= text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true
.input-group-append
= clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block')
%p.form-text.text-muted
- To access this domain create a new DNS record
+ = _("To access this domain create a new DNS record")
- if verification_enabled
- verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}"
%tr
%td
- Verification status
+ = _("Verification status")
%td
= form_tag verify_project_pages_domain_path(@project, @domain) do
.status-badge
@@ -53,17 +53,16 @@
.input-group-append
= clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block')
%p.form-text.text-muted
- - help_link = help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record')
- To #{link_to 'verify ownership', help_link} of your domain,
- add the above key to a TXT record within to your DNS configuration.
+ - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/getting_started_part_three.md', anchor: 'dns-txt-record'))
+ = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help }
%tr
%td
- Certificate
+ = _("Certificate")
%td
- if @domain.certificate_text
%pre
= @domain.certificate_text
- else
.light
- missing
+ = _("missing")
diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml
index 259979417e0..396e5da87bc 100644
--- a/app/views/projects/pipeline_schedules/_form.html.haml
+++ b/app/views/projects/pipeline_schedules/_form.html.haml
@@ -1,4 +1,4 @@
-= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form" } do |f|
+= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f|
= form_errors(@schedule)
.form-group.row
.col-md-9
@@ -11,12 +11,12 @@
.form-group.row
.col-md-9
= f.label :cron_timezone, _('Cron Timezone'), class: 'label-bold'
- = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown w-100', dropdown_class: 'w-100', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
= f.text_field :cron_timezone, value: @schedule.cron_timezone, id: 'schedule_cron_timezone', class: 'hidden', name: 'schedule[cron_timezone]', required: true
.form-group.row
.col-md-9
= f.label :ref, _('Target Branch'), class: 'label-bold'
- = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown', dropdown_class: 'git-revision-dropdown', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
+ = dropdown_tag(_("Select target branch"), options: { toggle_class: 'btn js-target-branch-dropdown w-100', dropdown_class: 'git-revision-dropdown w-100', title: _("Select target branch"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: @project.repository.branch_names, default_branch: @project.default_branch } } )
= f.text_field :ref, value: @schedule.ref, id: 'schedule_ref', class: 'hidden', name: 'schedule[ref]', required: true
.form-group.row.js-ci-variable-list-section
.col-md-9
@@ -34,7 +34,7 @@
= n_('Reveal value', 'Reveal values', @schedule.variables.size)
.form-group.row
.col-md-9
- = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold'
+ = f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold'
%div
= f.check_box :active, required: false, value: @schedule.active?
= _('Active')
diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml
index 0f0114d513c..5d307d6a70d 100644
--- a/app/views/projects/pipelines/_info.html.haml
+++ b/app/views/projects/pipelines/_info.html.haml
@@ -6,25 +6,18 @@
= preserve(markdown(commit.description, pipeline: :single_line))
.info-well
- - if commit.status
- .well-segment.pipeline-info
- .icon-container
- = icon('clock-o')
- = pluralize @pipeline.total_size, "job"
- - if @pipeline.ref
- from
- - if @pipeline.ref_exists?
- = link_to @pipeline.ref, project_ref_path(@project, @pipeline.ref), class: "ref-name"
- - else
- %span.ref-name
- = @pipeline.ref
- - if @pipeline.duration
- in
- = time_interval_in_words(@pipeline.duration)
- - if @pipeline.queued_duration
- = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
+ .well-segment.pipeline-info
+ .icon-container
+ = icon('clock-o')
+ = pluralize @pipeline.total_size, "job"
+ = @pipeline.ref_text
+ - if @pipeline.duration
+ in
+ = time_interval_in_words(@pipeline.duration)
+ - if @pipeline.queued_duration
+ = "(queued for #{time_interval_in_words(@pipeline.queued_duration)})"
- .well-segment
+ .well-segment.qa-pipeline-badges
.icon-container
= sprite_icon('flag')
- if @pipeline.latest?
@@ -49,9 +42,9 @@
content: "<a class='autodevops-link' href='#{popover_content_url}' target='_blank' rel='noopener noreferrer nofollow'>#{popover_content_text}</a>",
} }
Auto DevOps
- - if @pipeline.merge_request?
- %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run in a merge request context" }
- merge request
+ - if @pipeline.detached_merge_request_pipeline?
+ %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "This pipeline is run on the source branch" }
+ detached
- if @pipeline.stuck?
%span.js-pipeline-url-stuck.badge.badge-warning
stuck
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 66e202103a9..c04f076a3ab 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -2,15 +2,15 @@
%ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs
%li.js-pipeline-tab-link
= link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do
- = _("Pipeline")
+ = _('Pipeline')
%li.js-builds-tab-link
= link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do
- = _("Jobs")
+ = _('Jobs')
%span.badge.badge-pill.js-builds-counter= pipeline.total_size
- if @pipeline.failed_builds.present?
%li.js-failures-tab-link
= link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do
- = _("Failed Jobs")
+ = _('Failed Jobs')
%span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
@@ -24,41 +24,41 @@
%table.table.ci-table.pipeline
%thead
%tr
- %th Status
- %th Job ID
- %th Name
+ %th= _('Status')
+ %th= _('Job ID')
+ %th= _('Name')
%th
- %th Coverage
+ %th= _('Coverage')
%th
= render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage
- elsif pipeline.project.builds_enabled? && !pipeline.ci_yaml_file
.bs-callout.bs-callout-warning
- \.gitlab-ci.yml not found in this commit
+ = _("%{gitlab_ci_yml} not found in this commit") % { gitlab_ci_yml: ".gitlab-ci.yml" }
- if @pipeline.failed_builds.present?
#js-tab-failures.build-failures.tab-pane.build-page
%table.table.responsive-table.ci-table.responsive-table-sm-rounded
%thead
%th.table-th-transparent
- %th.table-th-transparent= _("Name")
- %th.table-th-transparent= _("Stage")
- %th.table-th-transparent= _("Failure")
+ %th.table-th-transparent= _('Name')
+ %th.table-th-transparent= _('Stage')
+ %th.table-th-transparent= _('Failure')
%tbody
- @pipeline.failed_builds.each_with_index do |build, index|
- job = build.present(current_user: current_user)
%tr.build-state.responsive-table-border-start
- %td.responsive-table-cell.ci-status-icon-failed{ data: { column: "Status"} }
+ %td.responsive-table-cell.ci-status-icon-failed{ data: { column: _('Status')} }
.d-none.d-md-block.build-icon
= custom_icon("icon_status_#{build.status}")
.d-md-none.build-badge
= render "ci/status/badge", link: false, status: job.detailed_status(current_user)
- %td.responsive-table-cell.build-name{ data: { column: _("Name")} }
+ %td.responsive-table-cell.build-name{ data: { column: _('Name')} }
= link_to build.name, pipeline_job_url(pipeline, build)
- %td.responsive-table-cell.build-stage{ data: { column: _("Stage")} }
+ %td.responsive-table-cell.build-stage{ data: { column: _('Stage')} }
= build.stage.titleize
- %td.responsive-table-cell.build-failure{ data: { column: _("Failure")} }
+ %td.responsive-table-cell.build-failure{ data: { column: _('Failure')} }
= build.present.callout_failure_message
%td.responsive-table-cell.build-actions
- if can?(current_user, :update_build, job)
diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml
index ec17eddba79..4d1d078661d 100644
--- a/app/views/projects/pipelines/charts.html.haml
+++ b/app/views/projects/pipelines/charts.html.haml
@@ -1,11 +1,7 @@
- @no_container = true
-- page_title _("CI / CD Charts")
+- page_title _('CI / CD Charts')
%div{ class: container_class }
- .sub-header-block
- .oneline
- = _("A collection of graphs regarding Continuous Integration")
-
#charts.ci-charts
.row
.col-md-6
diff --git a/app/views/projects/pipelines/charts/_pipeline_times.haml b/app/views/projects/pipelines/charts/_pipeline_times.haml
index c23fe6ff170..c0ac79ed5f8 100644
--- a/app/views/projects/pipelines/charts/_pipeline_times.haml
+++ b/app/views/projects/pipelines/charts/_pipeline_times.haml
@@ -1,7 +1,7 @@
-%div
- %p.light
- = _("Commit duration in minutes for last 30 commits")
+%p.light
+ = _("Commit duration in minutes for last 30 commits")
+%div
%canvas#build_timesChart{ height: 200 }
-# haml-lint:disable InlineJavaScript
diff --git a/app/views/projects/pipelines/charts/_pipelines.haml b/app/views/projects/pipelines/charts/_pipelines.haml
index 14b3d47a9c2..47f1f074210 100644
--- a/app/views/projects/pipelines/charts/_pipelines.haml
+++ b/app/views/projects/pipelines/charts/_pipelines.haml
@@ -13,18 +13,21 @@
%p.light
= _("Pipelines for last week")
(#{date_from_to(Date.today - 7.days, Date.today)})
- %canvas#weekChart{ height: 200 }
+ %div
+ %canvas#weekChart{ height: 200 }
.prepend-top-default
%p.light
= _("Pipelines for last month")
(#{date_from_to(Date.today - 30.days, Date.today)})
- %canvas#monthChart{ height: 200 }
+ %div
+ %canvas#monthChart{ height: 200 }
.prepend-top-default
%p.light
= _("Pipelines for last year")
- %canvas#yearChart.padded{ height: 250 }
+ %div
+ %canvas#yearChart.padded{ height: 250 }
-# haml-lint:disable InlineJavaScript
%script#pipelinesChartsData{ type: "application/json" }
diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml
index c0ee81fe28d..4e4638085fd 100644
--- a/app/views/projects/pipelines/index.html.haml
+++ b/app/views/projects/pipelines/index.html.haml
@@ -1,5 +1,7 @@
- @no_container = true
-- page_title "Pipelines"
+- page_title _('Pipelines')
+
+= render_if_exists "shared/shared_runners_minutes_limit_flash_message"
%div{ 'class' => container_class }
#pipelines-list-vue{ data: { endpoint: project_pipelines_path(@project, format: :json),
diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml
index f1cdc0a70dd..bfcaa09ae8c 100644
--- a/app/views/projects/pipelines/new.html.haml
+++ b/app/views/projects/pipelines/new.html.haml
@@ -1,16 +1,16 @@
-- breadcrumb_title "Pipelines"
-- page_title s_("Pipeline|Run Pipeline")
+- breadcrumb_title _('Pipelines')
+- page_title s_('Pipeline|Run Pipeline')
- settings_link = link_to _('CI/CD settings'), project_settings_ci_cd_path(@project)
%h3.page-title
- = s_("Pipeline|Run Pipeline")
+ = s_('Pipeline|Run Pipeline')
%hr
= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f|
= form_errors(@pipeline)
.form-group.row
.col-sm-12
- = f.label :ref, s_('Pipeline|Create for'), class: 'col-form-label'
+ = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label'
= hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch
= dropdown_tag(params[:ref] || @project.default_branch,
options: { toggle_class: 'js-branch-select wide monospace',
@@ -28,8 +28,8 @@
= (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe
.form-actions
- = f.submit s_('Pipeline|Create pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
- = link_to 'Cancel', project_pipelines_path(@project), class: 'btn btn-default float-right'
+ = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3
+ = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right'
-# haml-lint:disable InlineJavaScript
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml
index 193d437dad1..8a6d7b082e3 100644
--- a/app/views/projects/pipelines/show.html.haml
+++ b/app/views/projects/pipelines/show.html.haml
@@ -1,7 +1,7 @@
- @no_container = true
-- add_to_breadcrumbs "Pipelines", project_pipelines_path(@project)
+- add_to_breadcrumbs _('Pipelines'), project_pipelines_path(@project)
- breadcrumb_title "##{@pipeline.id}"
-- page_title "Pipeline"
+- page_title _('Pipeline')
.js-pipeline-container{ class: container_class, data: { controller_action: "#{controller.action_name}" } }
#js-pipeline-header-vue.pipeline-header-container
@@ -11,11 +11,13 @@
- if @pipeline.builds.empty? && @pipeline.yaml_errors.present?
.bs-callout.bs-callout-danger
- %h4 Found errors in your .gitlab-ci.yml:
+ %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' }
%ul
- @pipeline.yaml_errors.split(",").each do |error|
%li= error
- You can test your .gitlab-ci.yml in #{link_to "CI Lint", project_ci_lint_path(@project)}.
+ - lint_link_url = project_ci_lint_path(@project)
+ - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url }
+ = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe }
- else
= render "projects/pipelines/with_tabs", pipeline: @pipeline
diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml
index 3f05e06b0c6..00321014f91 100644
--- a/app/views/projects/project_members/_groups.html.haml
+++ b/app/views/projects/project_members/_groups.html.haml
@@ -1,7 +1,6 @@
.card.project-members-groups
.card-header
- Groups with access to
- %strong= @project.name
+ = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) }
%span.badge.badge-pill= group_links.size
%ul.content-list.members-list
= render partial: 'shared/members/group', collection: group_links, as: :group_link
diff --git a/app/views/projects/project_members/_new_project_group.html.haml b/app/views/projects/project_members/_new_project_group.html.haml
index 88e68f89024..079811e4e79 100644
--- a/app/views/projects/project_members/_new_project_group.html.haml
+++ b/app/views/projects/project_members/_new_project_group.html.haml
@@ -10,8 +10,9 @@
= select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- = link_to _("Read more"), help_page_path("user/permissions")
- about role permissions
+ - permissions_docs_path = help_page_path('user/permissions')
+ - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
+ = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.form-group
= label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
.clearable-input
diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml
index 1de7d9c6957..efabb7f7b19 100644
--- a/app/views/projects/project_members/_new_project_member.html.haml
+++ b/app/views/projects/project_members/_new_project_member.html.haml
@@ -2,20 +2,22 @@
.col-sm-12
= form_for @project_member, as: :project_member, url: project_project_members_path(@project), html: { class: 'users-project-form' } do |f|
.form-group
- = label_tag :user_ids, "Select members to invite", class: "label-bold"
+ = label_tag :user_ids, _("Select members to invite"), class: "label-bold"
= users_select_tag(:user_ids, multiple: true, class: "input-clamp qa-member-select-input", scope: :all, email_user: true, placeholder: "Search for members to update or invite")
.form-group
- = label_tag :access_level, "Choose a role permission", class: "label-bold"
+ = label_tag :access_level, _("Choose a role permission"), class: "label-bold"
.select-wrapper
= select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "form-control project-access-select select-control"
= icon('chevron-down')
.form-text.text-muted.append-bottom-10
- = link_to "Read more", help_page_path("user/permissions")
- about role permissions
+ - permissions_docs_path = help_page_path('user/permissions')
+ - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path }
+ = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.form-group
.clearable-input
- = label_tag :expires_at, 'Access expiration date', class: 'label-bold'
+ = label_tag :expires_at, _('Access expiration date'), class: 'label-bold'
= text_field_tag :expires_at, nil, class: 'form-control js-access-expiration-date', placeholder: 'Expiration date'
%i.clear-icon.js-clear-input
- = f.submit "Add to project", class: "btn btn-success qa-add-member-button"
- = link_to "Import", import_project_project_members_path(@project), class: "btn btn-default", title: "Import members from another project"
+ = f.submit _("Add to project"), class: "btn btn-success qa-add-member-button"
+ - if can_import_members?
+ = link_to _("Import"), import_project_project_members_path(@project), class: "btn btn-default", title: _("Import members from another project")
diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml
index 9682f8ac922..f220299ec30 100644
--- a/app/views/projects/project_members/_team.html.haml
+++ b/app/views/projects/project_members/_team.html.haml
@@ -4,14 +4,13 @@
.card
.card-header.flex-project-members-panel
%span.flex-project-title
- Members of
- %strong= project.name
+ = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) }
%span.badge.badge-pill= members.total_count
- = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
+ = form_tag project_project_members_path(project), method: :get, class: 'form-inline member-search-form flex-project-members-form' do
.form-group
.position-relative
- = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false }
- %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" }
+ = search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false }
+ %button.member-search-btn{ type: "submit", "aria-label" => _("Submit search") }
= icon("search")
= render 'shared/members/sort_dropdown'
%ul.content-list.members-list.qa-members-list
diff --git a/app/views/projects/project_members/import.html.haml b/app/views/projects/project_members/import.html.haml
index 8b93e81cd31..bcca943de6a 100644
--- a/app/views/projects/project_members/import.html.haml
+++ b/app/views/projects/project_members/import.html.haml
@@ -1,15 +1,15 @@
-- page_title "Import members"
+- page_title _("Import members")
%h3.page-title
- Import members from another project
+ = _("Import members from another project")
%p.light
- Only project members will be imported. Group members will be skipped.
+ = _("Only project members will be imported. Group members will be skipped.")
%hr
= form_tag apply_import_project_project_members_path(@project), method: 'post' do
.form-group.row
- = label_tag :source_project_id, "Project", class: 'col-form-label col-sm-2'
+ = label_tag :source_project_id, _("Project"), class: 'col-form-label col-sm-2'
.col-sm-10= select_tag(:source_project_id, options_from_collection_for_select(@projects, :id, :name_with_namespace), prompt: "Select project", class: "select2 lg", required: true)
.form-actions
- = button_tag 'Import project members', class: "btn btn-success"
- = link_to "Cancel", project_project_members_path(@project), class: "btn btn-cancel"
+ = button_tag _('Import project members'), class: "btn btn-success"
+ = link_to _("Cancel"), project_project_members_path(@project), class: "btn btn-cancel"
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index 14ed3345765..cc98ba64f08 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -1,39 +1,40 @@
-- page_title "Members"
+- page_title _("Members")
+- can_admin_project_members = can?(current_user, :admin_project_member, @project)
.row.prepend-top-default
.col-lg-12
- %h4
- Project members
- - if can?(current_user, :admin_project_member, @project)
- %p
- You can invite a new member to
- %strong= @project.name
- or invite another group.
- - else
- %p
- Members can be added by project
- %i Maintainers
- or
- %i Owners
+ - if project_can_be_shared?
+ %h4
+ = _("Project members")
+ - if can_admin_project_members
+ %p= share_project_description(@project)
+ - else
+ %p
+ = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe
+
.light
- - if can?(current_user, :admin_project_member, @project)
- %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
- %li.nav-tab{ role: 'presentation' }
- %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member
- - if @project.allowed_to_share_with_group?
+ - if can_admin_project_members && project_can_be_shared?
+ - if !membership_locked? && @project.allowed_to_share_with_group?
+ %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
- %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group
+ %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
+ %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
+ %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group")
- .tab-content.gitlab-tab-content
- .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_member', tab_title: 'Invite member'
- .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
- = render 'projects/project_members/new_project_group', tab_title: 'Invite group'
+ .tab-content.gitlab-tab-content
+ .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
+ = render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
+ = render 'projects/project_members/new_project_group', tab_title: _('Invite group')
+ - elsif !membership_locked?
+ .invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
+ - elsif @project.allowed_to_share_with_group?
+ .invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
%h5.member.existing-title
- Existing members and groups
+ = _("Existing members and groups")
- if @group_links.any?
= render 'projects/project_members/groups', group_links: @group_links
diff --git a/app/views/projects/project_templates/_built_in_templates.html.haml b/app/views/projects/project_templates/_built_in_templates.html.haml
index 5b4d8927045..6159f1c3542 100644
--- a/app/views/projects/project_templates/_built_in_templates.html.haml
+++ b/app/views/projects/project_templates/_built_in_templates.html.haml
@@ -1,7 +1,7 @@
- Gitlab::ProjectTemplate.all.each do |template|
.template-option.d-flex.align-items-center
- .logo.append-right-10
- = custom_icon(template.logo, size: 40)
+ .logo.append-right-10.px-1
+ = image_tag template.logo, size: 32, class: "btn-template-icon icon-#{template.name}"
.description
%strong
= template.title
@@ -12,6 +12,6 @@
%a.btn.btn-default.append-right-10{ href: template.preview, rel: 'noopener noreferrer', target: '_blank', data: { track_label: "create_from_template", track_property: "template_preview", track_event: "click_button", track_value: template.name } }
= _("Preview")
%label.btn.btn-success.template-button.choose-template.append-bottom-0{ for: template.name }
- %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } }
+ %input{ type: "radio", autocomplete: "off", name: "project[template_name]", id: template.name, value: template.name, data: { track_label: "create_from_template", track_property: "template_use", track_event: "click_button" } }
%span
= _("Use template")
diff --git a/app/views/projects/protected_branches/_protected_branch.html.haml b/app/views/projects/protected_branches/_protected_branch.html.haml
index b12ae995ece..366d7a7a2eb 100644
--- a/app/views/projects/protected_branches/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/_protected_branch.html.haml
@@ -1,2 +1,2 @@
= render layout: 'projects/protected_branches/shared/protected_branch', locals: { protected_branch: protected_branch } do
- = render partial: 'projects/protected_branches/update_protected_branch', locals: { protected_branch: protected_branch }
+ = render_if_exists 'projects/protected_branches/update_protected_branch', protected_branch: protected_branch
diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
index d617d85afc2..3644a623d2c 100644
--- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml
@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_branch)
.form-group.row
- = f.label :name, class: 'col-md-2 text-right' do
- Branch:
+ .col-md-2.text-right
+ = f.label :name, 'Branch:'
.col-md-10
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
.form-text.text-muted
diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml
index 539b184e5c2..63748d8d85f 100644
--- a/app/views/projects/protected_branches/shared/_index.html.haml
+++ b/app/views/projects/protected_branches/shared/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
index 81b07af22ad..81dcab1d1ab 100644
--- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml
+++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml
@@ -1,6 +1,6 @@
- can_admin_project = can?(current_user, :admin_project, @project)
-%tr.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
+%tr.qa-protected-branch.js-protected-branch-edit-form{ data: { url: namespace_project_protected_branch_path(@project.namespace, @project, protected_branch) } }
%td
%span.ref-name= protected_branch.name
@@ -15,7 +15,7 @@
= link_to(commit.short_id, namespace_project_commit_path(@project.namespace, @project, commit.id), class: 'commit-sha')
= time_ago_with_tooltip(commit.committed_date)
- else
- (branch was removed from repository)
+ (branch was deleted from repository)
= yield
diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
index cbf1938664c..020e6e187a6 100644
--- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
+++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml
@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_tag)
.form-group.row
- = f.label :name, class: 'col-md-2 text-right' do
- Tag:
+ .col-md-2.text-right
+ = f.label :name, 'Tag:'
.col-md-10.protected-tags-dropdown
= render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
.form-text.text-muted
diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml
index 9a50a51e4be..b0c87ac8c17 100644
--- a/app/views/projects/protected_tags/shared/_index.html.haml
+++ b/app/views/projects/protected_tags/shared/_index.html.haml
@@ -1,4 +1,4 @@
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded) }
.settings-header
diff --git a/app/views/projects/registry/repositories/_tag.html.haml b/app/views/projects/registry/repositories/_tag.html.haml
index a4cde53e8c6..9594c9184a2 100644
--- a/app/views/projects/registry/repositories/_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(text: "docker pull #{tag.location}")
+ = clipboard_button(text: "#{tag.location}")
%td
- if tag.revision
%span.has-tooltip{ title: "#{tag.revision}" }
diff --git a/app/views/projects/releases/index.html.haml b/app/views/projects/releases/index.html.haml
new file mode 100644
index 00000000000..28bb4e032eb
--- /dev/null
+++ b/app/views/projects/releases/index.html.haml
@@ -0,0 +1,5 @@
+- @no_container = true
+- page_title _('Releases')
+
+%div{ class: container_class }
+ #js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases/index') } }
diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml
index a6c16c70313..a24ada53bac 100644
--- a/app/views/projects/runners/_group_runners.html.haml
+++ b/app/views/projects/runners/_group_runners.html.haml
@@ -32,6 +32,6 @@
- else
%h4.underlined-title
- = _('Available group Runners : %{runners}').html_safe % { runners: @group_runners.count }
+ = _('Available group Runners: %{runners}').html_safe % { runners: @group_runners.count }
%ul.bordered-list
= render partial: 'projects/runners/runner', collection: @group_runners, as: :runner
diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml
index f650fa0f38f..9c69aedfbfc 100644
--- a/app/views/projects/serverless/functions/index.html.haml
+++ b/app/views/projects/serverless/functions/index.html.haml
@@ -5,7 +5,10 @@
- status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project)
-.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } }
+.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path,
+ installed: @installed,
+ clusters_path: clusters_path,
+ help_path: help_page_path('user/project/clusters/serverless/index') } }
%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.js-serverless-functions-notice
diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml
new file mode 100644
index 00000000000..d1fe208ce60
--- /dev/null
+++ b/app/views/projects/serverless/functions/show.html.haml
@@ -0,0 +1,21 @@
+- @no_container = true
+- @content_class = "limit-container-width" unless fluid_layout
+- clusters_path = project_clusters_path(@project)
+- help_path = help_page_path('user/project/clusters/serverless/index')
+
+- add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
+
+- page_title @service[:name]
+
+.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json,
+ prometheus: @prometheus,
+ clusters_path: clusters_path,
+ help_path: help_path } }
+
+%div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
+ .serverless-function-details#js-serverless-function-details
+
+ .js-serverless-function-notice
+ .flash-container
+
+ .function-holder.js-function-holder.input-group
diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml
index 597c029f755..a1d74b91002 100644
--- a/app/views/projects/services/prometheus/_metrics.html.haml
+++ b/app/views/projects/services/prometheus/_metrics.html.haml
@@ -1,6 +1,6 @@
- project = local_assigns.fetch(:project)
-.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/metrics') } }
+.card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') } }
.card-header
= s_('PrometheusService|Common metrics')
%span.badge.badge-pill.js-monitored-count 0
diff --git a/app/views/projects/services/prometheus/_show.html.haml b/app/views/projects/services/prometheus/_show.html.haml
index 1d0b0265bb7..6aafa85e99a 100644
--- a/app/views/projects/services/prometheus/_show.html.haml
+++ b/app/views/projects/services/prometheus/_show.html.haml
@@ -4,7 +4,9 @@
= s_('PrometheusService|Metrics')
%p
= s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.')
- = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/metrics'), target: '_blank', rel: "noopener noreferrer"
+ = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/index'), target: '_blank', rel: "noopener noreferrer"
.col-lg-9
= render 'projects/services/prometheus/metrics', project: @project
+
+= render_if_exists 'projects/services/prometheus/external_alerts', project: @project
diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml
new file mode 100644
index 00000000000..520f342f567
--- /dev/null
+++ b/app/views/projects/settings/_general.html.haml
@@ -0,0 +1,42 @@
+= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
+ %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
+ = form_errors(@project)
+
+ %fieldset
+ .row
+ .form-group.col-md-5
+ = f.label :name, class: 'label-bold', for: 'project_name_edit' do
+ = _('Project name')
+ = f.text_field :name, class: 'form-control qa-project-name-field', id: "project_name_edit"
+
+ .form-group.col-md-7
+ = f.label :id, class: 'label-bold' do
+ = _('Project ID')
+ = f.text_field :id, class: 'form-control w-auto', readonly: true
+
+ .row
+ .form-group.col-md-9
+ = f.label :tag_list, _('Topics'), class: 'label-bold'
+ = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control"
+ %p.form-text.text-muted= _('Separate topics with commas.')
+
+ .row
+ .form-group.col-md-9
+ = f.label :description, _('Project description (optional)'), class: 'label-bold'
+ = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250
+
+ .row= render_if_exists 'projects/classification_policy_settings', f: f
+
+ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project
+
+ .form-group.prepend-top-default.append-bottom-20
+ .avatar-container.s90
+ = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90')
+ = f.label :avatar, _('Project avatar'), class: 'label-bold d-block'
+ = render 'shared/choose_avatar_button', f: f
+ - if @project.avatar?
+ %hr
+ = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link'
+
+
+ = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button"
diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
index 5ec5a06396e..fe74dc122c3 100644
--- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml
@@ -4,36 +4,26 @@
= form_errors(@project)
%fieldset.builds-feature.js-auto-devops-settings
.form-group
- - message = auto_devops_warning_message(@project)
- - if message
- %p.auto-devops-warning-message.settings-message.text-center
- = message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
.card.auto-devops-card
.card-body
.form-check
- = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: @project.auto_devops_enabled?
+ = form.check_box :enabled, class: 'form-check-input js-toggle-extra-settings', checked: auto_devops_enabled
= form.label :enabled, class: 'form-check-label' do
%strong= s_('CICD|Default to Auto DevOps pipeline')
- - if @project.has_auto_devops_implicitly_enabled?
- %span.badge.badge-info.js-instance-default-badge= s_('CICD|instance enabled')
+ - if auto_devops_enabled
+ %span.badge.badge-info.js-instance-default-badge= badge_for_auto_devops_scope(@project)
.form-text.text-muted
= s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.')
= link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank'
- .card-footer.js-extra-settings{ class: @project.auto_devops_enabled? || 'hidden' }
- = form.label :domain do
- %strong= _('Domain')
- = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
- .form-text.text-muted
- = s_('CICD|You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.')
- - if cluster_ingress_ip = cluster_ingress_ip(@project)
- = s_('%{nip_domain} can be used as an alternative to a custom domain.').html_safe % { nip_domain: "<code>#{cluster_ingress_ip}.nip.io</code>".html_safe }
- = link_to icon('question-circle'), help_page_path('topics/autodevops/index.md', anchor: 'auto-devops-base-domain'), target: '_blank'
-
+ .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' }
+ - if @project.all_clusters.empty?
+ %p.settings-message.text-center
+ - kubernetes_cluster_link = help_page_path('user/project/clusters/index')
+ - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link }
+ = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe }
%label.prepend-top-10
%strong= s_('CICD|Deployment strategy')
- %p.settings-message.text-center
- = s_('CICD|Deployment strategy needs a domain name to work correctly.')
.form-check
= form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input'
= form.label :deploy_strategy_continuous, class: 'form-check-label' do
diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml
index bb328f5344c..b38b8e3f686 100644
--- a/app/views/projects/settings/ci_cd/_form.html.haml
+++ b/app/views/projects/settings/ci_cd/_form.html.haml
@@ -102,9 +102,15 @@
tap --coverage-report=text-summary (NodeJS) -
%code ^Statements\s*:\s*([^%]+)
%li
+ nyc npm test (NodeJS) -
+ %code All files[^|]*\|[^|]*\s+([\d\.]+)
+ %li
excoveralls (Elixir) -
%code \[TOTAL\]\s+(\d+\.\d+)%
%li
+ mix test --cover (Elixir) -
+ %code \d+.\d+\%\s+\|\s+Total
+ %li
JaCoCo (Java/Kotlin)
%code Total.*?([0-9]{1,3})%
%li
diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml
index 98e2829ba43..5e3e1076c2c 100644
--- a/app/views/projects/settings/ci_cd/show.html.haml
+++ b/app/views/projects/settings/ci_cd/show.html.haml
@@ -2,7 +2,7 @@
- page_title _("CI / CD Settings")
- page_title _("CI / CD")
-- expanded = Rails.env.test?
+- expanded = expanded_by_default?
- general_expanded = @project.errors.empty? ? expanded : true
%section.settings#js-general-pipeline-settings.no-animate{ class: ('expanded' if general_expanded) }
@@ -26,7 +26,7 @@
= s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.')
= link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md')
.settings-content
- = render 'autodevops_form'
+ = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled?
= render_if_exists 'projects/settings/ci_cd/protected_environments', expanded: expanded
@@ -43,13 +43,7 @@
%section.qa-variables-settings.settings.no-animate{ class: ('expanded' if expanded) }
.settings-header
- %h4
- = _('Variables')
- = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'variables'), target: '_blank', rel: 'noopener noreferrer'
- %button.btn.js-settings-toggle{ type: 'button' }
- = expanded ? _('Collapse') : _('Expand')
- %p.append-bottom-0
- = render "ci/variables/content"
+ = render 'ci/variables/header', expanded: expanded
.settings-content
= render 'ci/variables/index', save_endpoint: project_variables_path(@project)
diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml
new file mode 100644
index 00000000000..583fc08f375
--- /dev/null
+++ b/app/views/projects/settings/operations/_error_tracking.html.haml
@@ -0,0 +1,20 @@
+- return unless can?(current_user, :read_environment, @project)
+
+- setting = error_tracking_setting
+
+%section.settings.no-animate.js-error-tracking-settings
+ .settings-header
+ %h4
+ = _('Error Tracking')
+ %button.btn.js-settings-toggle{ type: 'button' }
+ = _('Expand')
+ %p
+ = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
+ = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
+ .settings-content
+ .js-error-tracking-form{ data: { list_projects_endpoint: list_projects_project_error_tracking_index_path(@project, format: :json),
+ operations_settings_endpoint: project_settings_operations_path(@project),
+ project: error_tracking_setting_project_json,
+ api_host: setting.api_host,
+ enabled: setting.enabled.to_json,
+ token: setting.token } }
diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml
new file mode 100644
index 00000000000..2fbb9195a04
--- /dev/null
+++ b/app/views/projects/settings/operations/_external_dashboard.html.haml
@@ -0,0 +1,2 @@
+.js-operation-settings{ data: { external_dashboard: { path: '',
+ help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
new file mode 100644
index 00000000000..0a7a155bc12
--- /dev/null
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -0,0 +1,8 @@
+- @content_class = 'limit-container-width' unless fluid_layout
+- page_title _('Operations Settings')
+- breadcrumb_title _('Operations Settings')
+
+= render_if_exists 'projects/settings/operations/incidents'
+= render 'projects/settings/operations/error_tracking'
+= render 'projects/settings/operations/external_dashboard'
+= render_if_exists 'projects/settings/operations/tracing'
diff --git a/app/views/projects/snippets/index.html.haml b/app/views/projects/snippets/index.html.haml
index a4974d89c1a..7682d01a5a1 100644
--- a/app/views/projects/snippets/index.html.haml
+++ b/app/views/projects/snippets/index.html.haml
@@ -1,12 +1,16 @@
- page_title _("Snippets")
-- if current_user
- .top-area
- - include_private = @project.team.member?(current_user) || current_user.admin?
- = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
+- if @snippets.exists?
+ - if current_user
+ .top-area
+ - include_private = @project.team.member?(current_user) || current_user.admin?
+ = render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
- .nav-controls
- if can?(current_user, :create_project_snippet, @project)
- = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet")
+ .nav-controls
+ - if can?(current_user, :create_project_snippet, @project)
+ = link_to _("New snippet"), new_project_snippet_path(@project), class: "btn btn-success", title: _("New snippet")
-= render 'snippets/snippets'
+ = render 'shared/snippets/list'
+- else
+ = render 'shared/empty_states/snippets', button_path: new_namespace_project_snippet_path(@project.namespace, @project)
diff --git a/app/views/projects/snippets/new.html.haml b/app/views/projects/snippets/new.html.haml
index 26b333d4ecf..d64e3a49a81 100644
--- a/app/views/projects/snippets/new.html.haml
+++ b/app/views/projects/snippets/new.html.haml
@@ -1,8 +1,8 @@
- add_to_breadcrumbs _("Snippets"), project_snippets_path(@project)
- breadcrumb_title _("New")
-- page_title _("New Snippets")
+- page_title _("New Snippet")
%h3.page-title
- = _('New Snippet')
+ = _("New Snippet")
%hr
= render "shared/snippets/form", url: project_snippets_path(@project, @snippet)
diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml
index f55202c2c5f..8bfface3f5a 100644
--- a/app/views/projects/tags/_tag.html.haml
+++ b/app/views/projects/tags/_tag.html.haml
@@ -20,15 +20,14 @@
%p
= s_("TagsPage|Can't find HEAD commit for this tag")
- if release && release.description.present?
- .description.prepend-top-default
- .wiki
- = markdown_field(release, :description)
+ .description.md.prepend-top-default
+ = markdown_field(release, :description)
.row-fixed-content.controls.flex-row
= render 'projects/buttons/download', project: @project, ref: tag.name, pipeline: @tags_pipelines[tag.name]
- if can?(current_user, :push_code, @project)
- = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
+ = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do
= icon("pencil")
- if can?(current_user, :admin_project, @project)
diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml
index 026bc44a05f..458096f9dd6 100644
--- a/app/views/projects/tags/index.html.haml
+++ b/app/views/projects/tags/index.html.haml
@@ -27,7 +27,7 @@
- if can?(current_user, :push_code, @project)
= link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn' do
= s_('TagsPage|New tag')
- = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn rss-btn has-tooltip' do
+ = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn d-none d-sm-inline-block has-tooltip' do
= icon("rss")
= render_if_exists 'projects/commits/mirror_status'
diff --git a/app/views/projects/releases/edit.html.haml b/app/views/projects/tags/releases/edit.html.haml
index 52c6c7ec424..e4efeed04f0 100644
--- a/app/views/projects/releases/edit.html.haml
+++ b/app/views/projects/tags/releases/edit.html.haml
@@ -12,8 +12,7 @@
= form_for(@release, method: :put, url: project_tag_release_path(@project, @tag.name),
- html: { class: 'common-note-form release-form js-quick-submit' },
- data: { markdown_version: @release.cached_markdown_version }) do |f|
+ html: { class: 'common-note-form release-form js-quick-submit' }) do |f|
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: "Write your release notes or drag files here…"
= render 'shared/notes/hints'
diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml
index 15a960f81b8..0be62bc5612 100644
--- a/app/views/projects/tags/show.html.haml
+++ b/app/views/projects/tags/show.html.haml
@@ -20,10 +20,10 @@
.nav-controls.controls-flex
- if can?(current_user, :push_code, @project)
- = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
+ = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do
= icon("pencil")
= link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do
- = icon('files-o')
+ = sprite_icon('folder-open')
= link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do
= icon('history')
.btn-container.controls-item
@@ -39,8 +39,7 @@
.append-bottom-default.prepend-top-default
- if @release.description.present?
- .description
- .wiki
- = markdown_field(@release, :description)
+ .description.md
+ = markdown_field(@release, :description)
- else
= s_('TagsPage|This tag has no release notes.')
diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml
index 4daacbe157c..e935af23659 100644
--- a/app/views/projects/tree/_readme.html.haml
+++ b/app/views/projects/tree/_readme.html.haml
@@ -1,5 +1,5 @@
- if readme.rich_viewer
- %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) }
+ %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if Feature.enabled?(:vue_file_list, @project))] }
.js-file-title.file-title
= blob_icon readme.mode, readme.name
= link_to project_blob_path(@project, tree_join(@ref, readme.path)) do
diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml
index 4e9a119ac66..ec8e5234bd4 100644
--- a/app/views/projects/tree/_tree_header.html.haml
+++ b/app/views/projects/tree/_tree_header.html.haml
@@ -40,27 +40,24 @@
#{ _('New directory') }
- elsif can?(current_user, :fork_project, @project) && can?(current_user, :create_merge_request_in, @project)
%li
- - continue_params = { to: project_new_blob_path(@project, @id),
- notice: edit_in_new_fork_notice,
+ - continue_params = { to: project_new_blob_path(@project, @id),
+ notice: edit_in_new_fork_notice,
notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
- continue: continue_params)
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New file') }
%li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to upload a file again.",
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to upload a file again.",
notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
- continue: continue_params)
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
= link_to fork_path, method: :post do
#{ _('Upload file') }
%li
- - continue_params = { to: request.fullpath,
- notice: edit_in_new_fork_notice + " Try to create a new directory again.",
+ - continue_params = { to: request.fullpath,
+ notice: edit_in_new_fork_notice + " Try to create a new directory again.",
notice_now: edit_in_new_fork_notice_now }
- - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id,
- continue: continue_params)
+ - fork_path = project_forks_path(@project, namespace_key: current_user.namespace.id, continue: continue_params)
= link_to fork_path, method: :post do
#{ _('New directory') }
diff --git a/app/views/projects/triggers/_trigger.html.haml b/app/views/projects/triggers/_trigger.html.haml
index 7e4618e1a88..6f6f1e5e0c5 100644
--- a/app/views/projects/triggers/_trigger.html.haml
+++ b/app/views/projects/triggers/_trigger.html.haml
@@ -1,6 +1,6 @@
%tr
%td
- - if can?(current_user, :admin_trigger, trigger)
+ - if trigger.has_token_exposed?
%span= trigger.token
= clipboard_button(text: trigger.token, title: "Copy trigger token to clipboard")
- else
diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml
index 7d8826e540c..5bb69563b51 100644
--- a/app/views/projects/wikis/_form.html.haml
+++ b/app/views/projects/wikis/_form.html.haml
@@ -1,13 +1,9 @@
- commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}")
- commit_message = commit_message % { page_title: @page.title }
-- if params[:legacy_render] || !commonmark_for_repositories_enabled?
- - markdown_version = CacheMarkdownField::CACHE_REDCARPET_VERSION
-- else
- - markdown_version = 0
= form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post,
html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' },
- data: { markdown_version: markdown_version, uploads_path: uploads_path } do |f|
+ data: { uploads_path: uploads_path } do |f|
= form_errors(@page)
- if @page.persisted?
@@ -16,7 +12,7 @@
.form-group.row
.col-sm-12= f.label :title, class: 'control-label-full-width'
.col-sm-12
- = f.text_field :title, class: 'form-control', value: @page.title
+ = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title
- if @page.persisted?
%span.edit-wiki-page-slug-tip
= icon('lightbulb-o')
@@ -31,7 +27,7 @@
.col-sm-12= f.label :content, class: 'control-label-full-width'
.col-sm-12
= render layout: 'projects/md_preview', locals: { url: project_wiki_preview_markdown_path(@project, @page.slug) } do
- = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
+ = render 'projects/zen', f: f, attr: :content, classes: 'note-textarea qa-wiki-content-textarea', placeholder: s_("WikiPage|Write your content or drag files here…")
= render 'shared/notes/hints'
.clearfix
@@ -47,14 +43,14 @@
.form-group.row
.col-sm-12= f.label :commit_message, class: 'control-label-full-width'
- .col-sm-12= f.text_field :message, class: 'form-control', rows: 18, value: commit_message
+ .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message
.form-actions
- if @page && @page.persisted?
- = f.submit _("Save changes"), class: 'btn-success btn'
+ = f.submit _("Save changes"), class: 'btn-success btn qa-save-changes-button'
.float-right
= link_to _("Cancel"), project_wiki_path(@project, @page), class: 'btn btn-cancel btn-grouped'
- else
- = f.submit s_("Wiki|Create page"), class: 'btn-success btn'
+ = f.submit s_("Wiki|Create page"), class: 'btn-success btn qa-create-page-button'
.float-right
= link_to _("Cancel"), project_wiki_path(@project, :home), class: 'btn btn-cancel'
diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml
index 02c5a6ea55c..28353927135 100644
--- a/app/views/projects/wikis/_sidebar.html.haml
+++ b/app/views/projects/wikis/_sidebar.html.haml
@@ -12,7 +12,7 @@
.blocks-container
.block.block-first
- if @sidebar_page
- = render_wiki_content(@sidebar_page, legacy_render_context(params))
+ = render_wiki_content(@sidebar_page)
- else
%ul.wiki-pages
= render @sidebar_wiki_entries, context: 'sidebar'
diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml
index 26671a7b7d2..1277ea6c743 100644
--- a/app/views/projects/wikis/edit.html.haml
+++ b/app/views/projects/wikis/edit.html.haml
@@ -23,9 +23,6 @@
= s_("Wiki|Create Page")
.nav-controls
- - if can?(current_user, :create_wiki, @project)
- = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-success", "data-toggle" => "modal" do
- = s_("Wiki|New page")
- if @page.persisted?
= link_to project_wiki_history_path(@project, @page), class: "btn" do
= s_("Wiki|Page history")
diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml
index aeef64fd7eb..77fdf7f001c 100644
--- a/app/views/projects/wikis/pages.html.haml
+++ b/app/views/projects/wikis/pages.html.haml
@@ -1,7 +1,8 @@
- @no_container = true
-- add_to_breadcrumbs "Wiki", get_project_wiki_path(@project)
+- add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home)
- breadcrumb_title s_("Wiki|Pages")
- page_title s_("Wiki|Pages"), _("Wiki")
+- sort_title = wiki_sort_title(params[:sort])
%div{ class: container_class }
.wiki-page-header
@@ -15,6 +16,18 @@
= icon('cloud-download')
= _("Clone repository")
+ .dropdown.inline.wiki-sort-dropdown
+ .btn-group{ role: 'group' }
+ .btn-group{ role: 'group' }
+ %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ = sort_title
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
+ %li
+ = sortable_item(s_("Wiki|Title"), project_wikis_pages_path(@project, sort: ProjectWiki::TITLE_ORDER), sort_title)
+ = sortable_item(s_("Wiki|Created date"), project_wikis_pages_path(@project, sort: ProjectWiki::CREATED_AT_ORDER), sort_title)
+ = wiki_sort_controls(@project, params[:sort], params[:direction])
+
%ul.wiki-pages-list.content-list
= render @wiki_entries, context: 'pages'
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index 4d5fd55364c..40d674f3fec 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -2,7 +2,7 @@
- breadcrumb_title @page.human_title
- wiki_breadcrumb_dropdown_links(@page.slug)
- page_title @page.human_title, _("Wiki")
-- add_to_breadcrumbs _("Wiki"), get_project_wiki_path(@project)
+- add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home)
.wiki-page-header.has-sidebar-toggle
%button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" }
@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
- .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
- = render_wiki_content(@page, legacy_render_context(params))
+ .md.md-file{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
+ = render_wiki_content(@page)
= render 'sidebar'
diff --git a/app/views/repository_check_mailer/notify.text.haml b/app/views/repository_check_mailer/notify.text.haml
index 6b64b337b0e..a2e04fa710f 100644
--- a/app/views/repository_check_mailer/notify.text.haml
+++ b/app/views/repository_check_mailer/notify.text.haml
@@ -3,3 +3,5 @@
= _("View details: %{details_url}") % { details_url: admin_projects_url(last_repository_check_failed: 1) }
= _("You are receiving this message because you are a GitLab administrator for %{url}.") % { url: Gitlab.config.gitlab.url }
+
+= render_if_exists 'repository_check_mailer/email_additional_text'
diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml
index ff9a7b53a86..df408e5fb60 100644
--- a/app/views/search/_category.html.haml
+++ b/app/views/search/_category.html.haml
@@ -1,3 +1,11 @@
+- users = capture_haml do
+ - if search_tabs?(:members)
+ %li{ class: active_when(@scope == 'users') }
+ = link_to search_filter_path(scope: 'users') do
+ Users
+ %span.badge.badge-pill
+ = limited_count(@search_results.limited_users_count)
+
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left')
.fade-right= icon('angle-right')
@@ -6,75 +14,77 @@
- if project_search_tabs?(:blobs)
%li{ class: active_when(@scope == 'blobs') }
= link_to search_filter_path(scope: 'blobs') do
- Code
+ = _("Code")
%span.badge.badge-pill
= @search_results.blobs_count
- if project_search_tabs?(:issues)
%li{ class: active_when(@scope == 'issues') }
= link_to search_filter_path(scope: 'issues') do
- Issues
+ = _("Issues")
%span.badge.badge-pill
= limited_count(@search_results.limited_issues_count)
- if project_search_tabs?(:merge_requests)
%li{ class: active_when(@scope == 'merge_requests') }
= link_to search_filter_path(scope: 'merge_requests') do
- Merge requests
+ = _("Merge requests")
%span.badge.badge-pill
= limited_count(@search_results.limited_merge_requests_count)
- if project_search_tabs?(:milestones)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
- Milestones
+ = _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
- if project_search_tabs?(:notes)
%li{ class: active_when(@scope == 'notes') }
= link_to search_filter_path(scope: 'notes') do
- Comments
+ = _("Comments")
%span.badge.badge-pill
= limited_count(@search_results.limited_notes_count)
- if project_search_tabs?(:wiki)
%li{ class: active_when(@scope == 'wiki_blobs') }
= link_to search_filter_path(scope: 'wiki_blobs') do
- Wiki
+ = _("Wiki")
%span.badge.badge-pill
= @search_results.wiki_blobs_count
- if project_search_tabs?(:commits)
%li{ class: active_when(@scope == 'commits') }
= link_to search_filter_path(scope: 'commits') do
- Commits
+ = _("Commits")
%span.badge.badge-pill
= @search_results.commits_count
+ = users
- elsif @show_snippets
%li{ class: active_when(@scope == 'snippet_blobs') }
= link_to search_filter_path(scope: 'snippet_blobs', snippets: true, group_id: nil, project_id: nil) do
- Snippet Contents
+ = _("Snippet Contents")
%span.badge.badge-pill
= @search_results.snippet_blobs_count
%li{ class: active_when(@scope == 'snippet_titles') }
= link_to search_filter_path(scope: 'snippet_titles', snippets: true, group_id: nil, project_id: nil) do
- Titles and Filenames
+ = _("Titles and Filenames")
%span.badge.badge-pill
= @search_results.snippet_titles_count
- else
%li{ class: active_when(@scope == 'projects') }
= link_to search_filter_path(scope: 'projects') do
- Projects
+ = _("Projects")
%span.badge.badge-pill
= limited_count(@search_results.limited_projects_count)
%li{ class: active_when(@scope == 'issues') }
= link_to search_filter_path(scope: 'issues') do
- Issues
+ = _("Issues")
%span.badge.badge-pill
= limited_count(@search_results.limited_issues_count)
%li{ class: active_when(@scope == 'merge_requests') }
= link_to search_filter_path(scope: 'merge_requests') do
- Merge requests
+ = _("Merge requests")
%span.badge.badge-pill
= limited_count(@search_results.limited_merge_requests_count)
%li{ class: active_when(@scope == 'milestones') }
= link_to search_filter_path(scope: 'milestones') do
- Milestones
+ = _("Milestones")
%span.badge.badge-pill
= limited_count(@search_results.limited_milestones_count)
+ = users
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index 6837f64f132..c8b6a3258ab 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -3,31 +3,31 @@
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
.dropdown
- %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Group:", group_id: params[:group_id] } }
+ %button.dropdown-menu-toggle.js-search-group-dropdown{ type: "button", data: { toggle: "dropdown", default_label: _('Group:'), group_id: params[:group_id] } }
%span.dropdown-toggle-text
- Group:
+ = _("Group:")
- if @group.present?
= @group.name
- else
- Any
+ = _("Any")
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
- = dropdown_title("Filter results by group")
- = dropdown_filter("Search groups")
+ = dropdown_title(_("Filter results by group"))
+ = dropdown_filter(_("Search groups"))
= dropdown_content
= dropdown_loading
.dropdown.project-filter
- %button.dropdown-menu-toggle.js-search-project-dropdown{ type: "button", data: { toggle: "dropdown", default_label: "Project:" } }
+ %button.dropdown-menu-toggle.js-search-project-dropdown{ type: "button", data: { toggle: "dropdown", default_label: _('Project:') } }
%span.dropdown-toggle-text
- Project:
+ = _("Project:")
- if @project.present?
= @project.full_name
- else
- Any
+ = _("Any")
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
- = dropdown_title("Filter results by project")
- = dropdown_filter("Search projects")
+ = dropdown_title(_("Filter results by project"))
+ = dropdown_filter(_("Search projects"))
= dropdown_content
= dropdown_loading
diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml
index a4a5cec1314..4af0c6bf84a 100644
--- a/app/views/search/_form.html.haml
+++ b/app/views/search/_form.html.haml
@@ -4,12 +4,12 @@
.search-holder
.search-field-holder
- = search_field_tag :search, params[:search], placeholder: "Search for projects, issues etc", class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
+ = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false
= icon("search", class: "search-icon")
%button.search-clear.js-search-clear{ class: ("hidden" if !params[:search].present?), type: "button", tabindex: "-1" }
= icon("times-circle")
%span.sr-only
- Clear search
+ = _("Clear search")
- unless params[:snippets].eql? 'true'
= render 'filter'
- = button_tag "Search", class: "btn btn-success btn-search"
+ = button_tag _("Search"), class: "btn btn-success btn-search"
diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml
index ab56f48ba4d..8ae2807729b 100644
--- a/app/views/search/_results.html.haml
+++ b/app/views/search/_results.html.haml
@@ -1,4 +1,4 @@
-- if @search_objects.empty?
+- if @search_objects.to_a.empty?
= render partial: "search/results/empty"
- else
.row-content-block
@@ -6,9 +6,11 @@
= search_entries_info(@search_objects, @scope, @search_term)
- unless @show_snippets
- if @project
- in project #{link_to @project.full_name, [@project.namespace.becomes(Namespace), @project]}
+ - link_to_project = link_to(@project.full_name, [@project.namespace.becomes(Namespace), @project])
+ = _("in project %{link_to_project}").html_safe % { link_to_project: link_to_project }
- elsif @group
- in group #{link_to @group.name, @group}
+ - link_to_group = link_to(@group.name, @group)
+ = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group }
.results.prepend-top-10
- if @scope == 'commits'
@@ -18,9 +20,10 @@
.search-results
- if @scope == 'projects'
.term
- = render 'shared/projects/list', projects: @search_objects
+ = render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
- = render partial: "search/results/#{@scope.singularize}", collection: @search_objects
+ - locals = { projects: blob_projects(@search_objects) } if %w[blobs wiki_blobs].include?(@scope)
+ = render partial: "search/results/#{@scope.singularize}", collection: @search_objects, locals: locals
- if @scope != 'projects'
= paginate_collection(@search_objects)
diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml
index 2a602095845..bdad07f36d1 100644
--- a/app/views/search/results/_blob.html.haml
+++ b/app/views/search/results/_blob.html.haml
@@ -1,4 +1,4 @@
-- project = find_project_for_result_blob(blob)
+- project = find_project_for_result_blob(projects, blob)
- return unless project
- blob = parse_search_result(blob)
diff --git a/app/views/search/results/_empty.html.haml b/app/views/search/results/_empty.html.haml
index 821a39d61f5..9d15995bb51 100644
--- a/app/views/search/results/_empty.html.haml
+++ b/app/views/search/results/_empty.html.haml
@@ -2,5 +2,5 @@
.search_glyph
%h4
= icon('search')
- We couldn't find any results matching
+ = _("We couldn't find any results matching")
%code= @search_term
diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml
index c413c3d4abb..796782035f2 100644
--- a/app/views/search/results/_issue.html.haml
+++ b/app/views/search/results/_issue.html.haml
@@ -4,7 +4,7 @@
= link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do
%span.term.str-truncated= issue.title
- if issue.closed?
- %span.badge.badge-danger.prepend-left-5 Closed
+ %span.badge.badge-danger.prepend-left-5= _("Closed")
.float-right ##{issue.iid}
- if issue.description.present?
.description.term
diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml
index 519176af108..f0e0af11f27 100644
--- a/app/views/search/results/_merge_request.html.haml
+++ b/app/views/search/results/_merge_request.html.haml
@@ -3,9 +3,9 @@
= link_to [merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request] do
%span.term.str-truncated= merge_request.title
- if merge_request.merged?
- %span.badge.badge-primary.prepend-left-5 Merged
+ %span.badge.badge-primary.prepend-left-5= _("Merged")
- elsif merge_request.closed?
- %span.badge.badge-danger.prepend-left-5 Closed
+ %span.badge.badge-danger.prepend-left-5= _("Closed")
.float-right= merge_request.to_reference
- if merge_request.description.present?
.description.term
diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml
index e4ab7b0541f..6717939d034 100644
--- a/app/views/search/results/_note.html.haml
+++ b/app/views/search/results/_note.html.haml
@@ -6,14 +6,14 @@
%h5.note-search-caption.str-truncated
%i.fa.fa-comment
= link_to_member(project, note.author, avatar: false)
- commented on
- = link_to project.full_name, project
+ - link_to_project = link_to(project.full_name, project)
+ = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project }
&middot;
- if note.for_commit?
- = link_to_if(noteable_identifier, "Commit #{truncate_sha(note.commit_id)}", note_url) do
+ = link_to_if(noteable_identifier, _("Commit %{commit_id}") % { commit_id: truncate_sha(note.commit_id) }, note_url) do
= truncate_sha(note.commit_id)
- %span.light Commit deleted
+ %span.light= _("Commit deleted")
- else
%span #{note.noteable_type.titleize} ##{noteable_identifier}
diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml
index b4ecd7bbae9..f17dae0a94c 100644
--- a/app/views/search/results/_snippet_blob.html.haml
+++ b/app/views/search/results/_snippet_blob.html.haml
@@ -18,13 +18,13 @@
%i.fa.fa-file
%strong= snippet.file_name
- if markup?(snippet.file_name)
- .file-content.wiki
+ .file-content.md.md-file
- snippet_chunks.each do |chunk|
- unless chunk[:data].empty?
- = markup(snippet.file_name, chunk[:data], legacy_render_context(params))
+ = markup(snippet.file_name, chunk[:data])
- else
.file-content.code
- .nothing-here-block Empty file
+ .nothing-here-block= _("Empty file")
- else
.file-content.code.js-syntax-highlight
.line-numbers
@@ -42,4 +42,4 @@
= highlight(snippet.file_name, chunk[:data])
- else
.file-content.code
- .nothing-here-block Empty file
+ .nothing-here-block= _("Empty file")
diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml
index 9c8afb2165b..1e01088d9e6 100644
--- a/app/views/search/results/_snippet_title.html.haml
+++ b/app/views/search/results/_snippet_title.html.haml
@@ -5,7 +5,7 @@
- if snippet_title.private?
%span.badge.badge-gray
%i.fa.fa-lock
- private
+ = _("private")
%span.cgray.monospace.tiny.float-right.term
= snippet_title.file_name
diff --git a/app/views/search/results/_user.html.haml b/app/views/search/results/_user.html.haml
new file mode 100644
index 00000000000..8060a1577e4
--- /dev/null
+++ b/app/views/search/results/_user.html.haml
@@ -0,0 +1,10 @@
+%ul.content-list
+ %li
+ .avatar-cell.d-none.d-sm-block
+ = user_avatar(user: user, user_name: user.name, css_class: 'd-none d-sm-inline avatar s40')
+ .user-info
+ = link_to user_path(user), class: 'd-none d-sm-inline' do
+ .item-title
+ = user.name
+ = user_status(user)
+ .cgray= user.to_reference
diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml
index 389e4cc75b9..5847751b268 100644
--- a/app/views/search/results/_wiki_blob.html.haml
+++ b/app/views/search/results/_wiki_blob.html.haml
@@ -1,5 +1,5 @@
-- project = find_project_for_result_blob(wiki_blob)
+- project = find_project_for_result_blob(projects, wiki_blob)
- wiki_blob = parse_search_result(wiki_blob)
-- wiki_blob_link = project_wiki_path(project, wiki_blob.basename)
+- wiki_blob_link = project_wiki_path(project, Pathname.new(wiki_blob.filename).sub_ext(''))
= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link }
diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml
index 499697f2777..3260d05f509 100644
--- a/app/views/search/show.html.haml
+++ b/app/views/search/show.html.haml
@@ -1,5 +1,5 @@
- @hide_top_links = true
-- breadcrumb_title "Search"
+- breadcrumb_title _("Search")
- page_title @search_term
.prepend-top-default
diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml
index 7d3e243495f..ca392e1adfc 100644
--- a/app/views/sent_notifications/unsubscribe.html.haml
+++ b/app/views/sent_notifications/unsubscribe.html.haml
@@ -1,17 +1,16 @@
- noteable = @sent_notification.noteable
- noteable_type = @sent_notification.noteable_type.titleize.downcase
- noteable_text = %(#{noteable.title} (#{noteable.to_reference}))
-- page_title "Unsubscribe", noteable_text, noteable_type.pluralize, @sent_notification.project.full_name
+- page_title _("Unsubscribe"), noteable_text, noteable_type.pluralize, @sent_notification.project.full_name
%h3.page-title
- Unsubscribe from #{noteable_type}
+ = _("Unsubscribe from %{type}") % { type: noteable_type }
%p
- = succeed '?' do
- Are you sure you want to unsubscribe from the #{noteable_type}:
- = link_to noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable])
+ - link_to_noteable_text = link_to(noteable_text, url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]))
+ = _("Are you sure you want to unsubscribe from the %{type}: %{link_to_noteable_text}?").html_safe % { type: noteable_type, link_to_noteable_text: link_to_noteable_text }
%p
- = link_to 'Unsubscribe', unsubscribe_sent_notification_path(@sent_notification, force: true),
+ = link_to _('Unsubscribe'), unsubscribe_sent_notification_path(@sent_notification, force: true),
class: 'btn btn-primary append-right-10'
- = link_to 'Cancel', new_user_session_path, class: 'btn append-right-10'
+ = link_to _('Cancel'), new_user_session_path, class: 'btn append-right-10'
diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
index 0d0a3c1aa64..755fd3a17d3 100644
--- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
+++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml
@@ -1,5 +1,5 @@
-- if show_auto_devops_implicitly_enabled_banner?(project)
- .auto-devops-implicitly-enabled-banner.alert.alert-warning
+- if show_auto_devops_implicitly_enabled_banner?(project, current_user)
+ .qa-auto-devops-banner.auto-devops-implicitly-enabled-banner.alert.alert-warning
- more_information_link = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link'
- auto_devops_message = s_("AutoDevOps|The Auto DevOps pipeline has been enabled and will be used if no alternative CI configuration file is found. %{more_information_link}") % { more_information_link: more_information_link }
= auto_devops_message.html_safe
diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml
new file mode 100644
index 00000000000..0d46d047134
--- /dev/null
+++ b/app/views/shared/_choose_avatar_button.html.haml
@@ -0,0 +1,4 @@
+%button.btn.js-choose-avatar-button{ type: 'button' }= _("Choose file…")
+%span.file_name.js-avatar-filename= _("No file chosen")
+= f.file_field :avatar, class: "js-avatar-input hidden"
+.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml
deleted file mode 100644
index 0552fe62090..00000000000
--- a/app/views/shared/_choose_group_avatar_button.html.haml
+++ /dev/null
@@ -1,4 +0,0 @@
-%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...")
-%span.file_name.js-avatar-filename= _("No file chosen")
-= f.file_field :avatar, class: "js-group-avatar-input hidden"
-.form-text.text-muted= _("The maximum file size allowed is 200KB.")
diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml
index a2df0347fd6..1e509ea0d1f 100644
--- a/app/views/shared/_clone_panel.html.haml
+++ b/app/views/shared/_clone_panel.html.haml
@@ -16,7 +16,12 @@
= ssh_clone_button(project)
%li
= http_clone_button(project)
+ = render_if_exists 'shared/kerberos_clone_button', project: project
= text_field_tag :project_clone, default_url_to_repo(project), class: "js-select-on-focus form-control", readonly: true, aria: { label: 'Project clone URL' }
.input-group-append
= clipboard_button(target: '#project_clone', title: _("Copy URL to clipboard"), class: "input-group-text btn-default btn-clipboard")
+
+ = render_if_exists 'shared/geo_modal_button'
+
+= render_if_exists 'shared/geo_modal', project: project
diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml
index 1dcf4369253..3967c8148d2 100644
--- a/app/views/shared/_confirm_modal.html.haml
+++ b/app/views/shared/_confirm_modal.html.haml
@@ -2,8 +2,7 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title
- Confirmation required
+ %h3.page-title= _('Confirmation required')
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
@@ -11,8 +10,7 @@
%p.text-danger.js-confirm-text
%p
- This action can lead to data loss.
- To prevent accidental actions we ask you to confirm your intention.
+ %span.js-warning-text= _('This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.')
%br
Please type
%code.js-confirm-danger-match= phrase
@@ -21,4 +19,4 @@
.form-group
= text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input'
.form-actions
- = submit_tag 'Confirm', class: "btn btn-danger js-confirm-danger-submit"
+ = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit"
diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml
index b96380923ac..f37dd2cdf02 100644
--- a/app/views/shared/_delete_label_modal.html.haml
+++ b/app/views/shared/_delete_label_modal.html.haml
@@ -2,20 +2,20 @@
.modal-dialog
.modal-content
.modal-header
- %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ?
+ %h3.page-title Delete #{render_label(label, tooltip: false)} ?
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times;
.modal-body
%p
%strong= label.name
- %span will be permanently deleted from #{label.is_a?(ProjectLabel)? label.project.name : label.group.name}. This cannot be undone.
+ %span will be permanently deleted from #{label.subject_name}. This cannot be undone.
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
= link_to 'Delete label',
- destroy_label_path(label),
+ label.destroy_path,
title: 'Delete',
method: :delete,
class: 'btn btn-remove'
diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml
index 5073e6ad48f..d7e57fc0d01 100644
--- a/app/views/shared/_file_highlight.html.haml
+++ b/app/views/shared/_file_highlight.html.haml
@@ -1,4 +1,4 @@
-.file-content.code.js-syntax-highlight
+.file-content.code.js-syntax-highlight.qa-file-content
.line-numbers
- if blob.data.present?
- link_icon = icon('link')
diff --git a/app/views/shared/_flash_user_callout.html.haml b/app/views/shared/_flash_user_callout.html.haml
new file mode 100644
index 00000000000..fe175195e66
--- /dev/null
+++ b/app/views/shared/_flash_user_callout.html.haml
@@ -0,0 +1,11 @@
+- callout_data = { uid: "callout_feature_#{feature_name}_dismissed", feature_id: feature_name, dismiss_endpoint: user_callouts_path }
+- extra_flash_class = local_assigns.fetch(:extra_flash_class, nil)
+
+.flash-container.flash-container-page.user-callout{ data: callout_data }
+ -# We currently only support `alert`, `warning`, `notice`, `success`
+ %div{ class: "flash-#{flash_type}" }
+ %div{ class: "#{(container_class unless fluid_layout)} #{(extra_flash_class unless @no_container)} #{@content_class}" }
+ %span= message
+ %button.btn.btn-default.close.js-close{ type: 'button',
+ 'aria-label' => _('Dismiss') }
+ = sprite_icon('close', css_class: 'dismiss-icon')
diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml
index 7b593ca4f76..3ee713cf499 100644
--- a/app/views/shared/_import_form.html.haml
+++ b/app/views/shared/_import_form.html.haml
@@ -18,3 +18,6 @@
= import_will_timeout_message(ci_cd_only)
%li
= import_svn_message(ci_cd_only)
+ = render_if_exists 'shared/ci_cd_only_link', ci_cd_only: ci_cd_only
+
+= render_if_exists 'shared/ee/import_form', f: f, ci_cd_only: ci_cd_only
diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml
index 6cc8c485666..31a5370a5f8 100644
--- a/app/views/shared/_issuable_meta_data.html.haml
+++ b/app/views/shared/_issuable_meta_data.html.haml
@@ -1,4 +1,4 @@
-- note_count = @issuable_meta_data[issuable.id].notes_count
+- note_count = @issuable_meta_data[issuable.id].user_notes_count
- issue_votes = @issuable_meta_data[issuable.id]
- upvotes, downvotes = issue_votes.upvotes, issue_votes.downvotes
- issuable_url = @collection_type == "Issue" ? issue_path(issuable, anchor: 'notes') : merge_request_path(issuable, anchor: 'notes')
diff --git a/app/views/shared/_label.html.haml b/app/views/shared/_label.html.haml
index 21ea188d7b3..c4b7ef481fd 100644
--- a/app/views/shared/_label.html.haml
+++ b/app/views/shared/_label.html.haml
@@ -1,13 +1,13 @@
+- label = label.present(issuable_subject: local_assigns[:subject])
- label_css_id = dom_id(label)
- status = label_subscription_status(label, @project).inquiry if current_user
-- subject = local_assigns[:subject]
- use_label_priority = local_assigns.fetch(:use_label_priority, false)
- force_priority = local_assigns.fetch(:force_priority, use_label_priority ? label.priority.present? : false)
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- tooltip_title = label_status_tooltip(label, status) if status
%li.label-list-item{ id: label_css_id, data: { id: label.id } }
- = render "shared/label_row", label: label, subject: subject, force_priority: force_priority
+ = render "shared/label_row", label: label, force_priority: force_priority
%ul.label-actions-list
- if @project
%li.inline
@@ -21,7 +21,7 @@
= sprite_icon('star')
- if can?(current_user, :admin_label, label)
%li.inline
- = link_to edit_label_path(label), class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
+ = link_to label.edit_path, class: 'btn btn-transparent label-action edit has-tooltip', title: _('Edit'), data: { placement: 'bottom' }, aria_label: _('Edit') do
= sprite_icon('pencil')
- if can?(current_user, :admin_label, label)
%li.inline
@@ -30,7 +30,7 @@
= sprite_icon('ellipsis_v')
.dropdown-menu.dropdown-open-left
%ul
- - if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
+ - if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
%button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label),
@@ -48,7 +48,7 @@
%button.text-danger.remove-row{ type: 'button' }= _('Delete')
- if current_user
%li.inline.label-subscription
- - if can_subscribe_to_label_in_different_levels?(label)
+ - if label.can_subscribe_to_label_in_different_levels?
%button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ class: ('hidden' if status.unsubscribed?), data: { url: toggle_subscription_path, toggle: 'tooltip' }, title: tooltip_title }
%span= _('Unsubscribe')
.dropdown.dropdown-group-label{ class: ('hidden' unless status.unsubscribed?) }
diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml
index c5ea15a7f63..af11ce94ec5 100644
--- a/app/views/shared/_label_row.html.haml
+++ b/app/views/shared/_label_row.html.haml
@@ -1,13 +1,10 @@
-- subject = local_assigns[:subject]
- force_priority = local_assigns.fetch(:force_priority, false)
-- show_label_issues_link = defined?(@project) && show_label_issuables_link?(label, :issues, project: @project)
-- show_label_merge_requests_link = defined?(@project) && show_label_issuables_link?(label, :merge_requests, project: @project)
+- subject_or_group_defined = defined?(@project) || defined?(@group)
+- show_label_issues_link = subject_or_group_defined && show_label_issuables_link?(label, :issues, project: @project)
+- show_label_merge_requests_link = subject_or_group_defined && show_label_issuables_link?(label, :merge_requests, project: @project)
.label-name
- - if defined?(@project)
- = link_to_label(label, subject: @project, tooltip: false)
- - else
- = render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false)
.label-description
.append-right-default.prepend-left-default
- if label.description.present?
@@ -16,11 +13,13 @@
%ul.label-links
- if show_label_issues_link
%li.label-link-item.inline
- = link_to_label(label, subject: subject) { 'Issues' }
+ = link_to_label(label) { 'Issues' }
- if show_label_merge_requests_link
&middot;
%li.label-link-item.inline
- = link_to_label(label, subject: subject, type: :merge_request) { _('Merge requests') }
+ = link_to_label(label, type: :merge_request) { _('Merge requests') }
- if force_priority
+ &middot;
%li.label-link-item.priority-badge.js-priority-badge.inline.prepend-left-10
.label-badge.label-badge-blue= _('Prioritized label')
+ = render_if_exists 'shared/label_row_epics_link', label: label
diff --git a/app/views/shared/_milestones_sort_dropdown.html.haml b/app/views/shared/_milestones_sort_dropdown.html.haml
index bd68a3e4c84..9a1db831ad3 100644
--- a/app/views/shared/_milestones_sort_dropdown.html.haml
+++ b/app/views/shared/_milestones_sort_dropdown.html.haml
@@ -8,15 +8,15 @@
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
- = link_to page_filter_path(sort: sort_value_due_date_soon, label: true) do
+ = link_to page_filter_path(sort: sort_value_due_date_soon) do
= sort_title_due_date_soon
- = link_to page_filter_path(sort: sort_value_due_date_later, label: true) do
+ = link_to page_filter_path(sort: sort_value_due_date_later) do
= sort_title_due_date_later
- = link_to page_filter_path(sort: sort_value_start_date_soon, label: true) do
+ = link_to page_filter_path(sort: sort_value_start_date_soon) do
= sort_title_start_date_soon
- = link_to page_filter_path(sort: sort_value_start_date_later, label: true) do
+ = link_to page_filter_path(sort: sort_value_start_date_later) do
= sort_title_start_date_later
- = link_to page_filter_path(sort: sort_value_name, label: true) do
+ = link_to page_filter_path(sort: sort_value_name) do
= sort_title_name_asc
- = link_to page_filter_path(sort: sort_value_name_desc, label: true) do
+ = link_to page_filter_path(sort: sort_value_name_desc) do
= sort_title_name_desc
diff --git a/app/views/shared/_mini_pipeline_graph.html.haml b/app/views/shared/_mini_pipeline_graph.html.haml
index 28b34e38b15..a1f21c2a83e 100644
--- a/app/views/shared/_mini_pipeline_graph.html.haml
+++ b/app/views/shared/_mini_pipeline_graph.html.haml
@@ -4,15 +4,14 @@
- detailed_status = stage.detailed_status(current_user)
- icon_status = "#{detailed_status.icon}_borderless"
- .stage-container.dropdown{ class: klass }
+ .stage-container.mt-0.ml-1.dropdown{ class: klass }
%button.mini-pipeline-graph-dropdown-toggle.has-tooltip.js-builds-dropdown-button{ class: "ci-status-icon-#{detailed_status.group}", type: 'button', data: { toggle: 'dropdown', title: "#{stage.name}: #{detailed_status.label}", placement: 'top', "stage-endpoint" => stage_ajax_project_pipeline_path(pipeline.project, pipeline, stage: stage.name) } }
= sprite_icon(icon_status)
- = icon('caret-down')
%ul.dropdown-menu.mini-pipeline-graph-dropdown-menu.js-builds-dropdown-container
%li.js-builds-dropdown-list.scrollable-menu
%ul
%li.js-builds-dropdown-loading.hidden
- .text-center
- %i.fa.fa-spinner.fa-spin{ 'aria-hidden': 'true', 'aria-label': 'Loading' }
+ .loading-container.text-center
+ %span.spinner{ 'aria-label': 'Loading' }
diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml
index b43662947a8..1e6b6f7c79b 100644
--- a/app/views/shared/_mobile_clone_panel.html.haml
+++ b/app/views/shared/_mobile_clone_panel.html.haml
@@ -7,7 +7,10 @@
%button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } }
= sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon")
%ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } }
- %li
- = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true)
- %li
- = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' })
+ - if ssh_enabled?
+ %li
+ = dropdown_item_with_description(ssh_copy_label, project.ssh_url_to_repo, href: project.ssh_url_to_repo, data: { clone_type: 'ssh' }, default: true)
+ - if http_enabled?
+ %li
+ = dropdown_item_with_description(http_copy_label, project.http_url_to_repo, href: project.http_url_to_repo, data: { clone_type: 'http' })
+ = render_if_exists 'shared/mobile_kerberos_clone'
diff --git a/app/views/shared/_personal_access_tokens_created_container.html.haml b/app/views/shared/_personal_access_tokens_created_container.html.haml
index 3150d39b84a..a8d3de66418 100644
--- a/app/views/shared/_personal_access_tokens_created_container.html.haml
+++ b/app/views/shared/_personal_access_tokens_created_container.html.haml
@@ -6,7 +6,7 @@
= container_title
.form-group
.input-group
- = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block"
+ = text_field_tag 'created-personal-access-token', new_token_value, readonly: true, class: "qa-created-personal-access-token form-control js-select-on-focus", 'aria-describedby' => "created-token-help-block"
%span.input-group-append
= clipboard_button(text: new_token_value, title: clipboard_button_title, placement: "left", class: "input-group-text btn-default btn-clipboard")
%span#created-token-help-block.form-text.text-muted.text-danger Make sure you save it - you won't be able to access it again.
diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml
index f4df7bdcd83..0891b3459ec 100644
--- a/app/views/shared/_personal_access_tokens_form.html.haml
+++ b/app/views/shared/_personal_access_tokens_form.html.haml
@@ -12,7 +12,7 @@
.row
.form-group.col-md-6
= f.label :name, class: 'label-bold'
- = f.text_field :name, class: "form-control", required: true
+ = f.text_field :name, class: "form-control qa-personal-access-token-name-field", required: true
.row
.form-group.col-md-6
@@ -26,4 +26,4 @@
= render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: token, scopes: scopes
.prepend-top-default
- = f.submit "Create #{type} token", class: "btn btn-success"
+ = f.submit "Create #{type} token", class: "btn btn-success qa-create-token-button"
diff --git a/app/views/shared/_personal_access_tokens_table.html.haml b/app/views/shared/_personal_access_tokens_table.html.haml
index 2efd03d4867..49f3aae0f98 100644
--- a/app/views/shared/_personal_access_tokens_table.html.haml
+++ b/app/views/shared/_personal_access_tokens_table.html.haml
@@ -29,7 +29,7 @@
%span.token-never-expires-label Never
%td= token.scopes.present? ? token.scopes.join(", ") : "<no scopes selected>"
- path = impersonation ? revoke_admin_user_impersonation_token_path(token.user, token) : revoke_profile_personal_access_token_path(token)
- %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." }
+ %td= link_to "Revoke", path, method: :put, class: "btn btn-danger float-right qa-revoke-button", data: { confirm: "Are you sure you want to revoke this #{type} Token? This action cannot be undone." }
- else
.settings-message.text-center
This user has no active #{type} Tokens.
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 721a2af8069..8da2ae5111a 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,6 +1,6 @@
- if remote_mirror.update_in_progress?
%button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') }
= icon("refresh spin")
-- else
+- elsif remote_mirror.enabled?
= link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
= icon("refresh")
diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml
index 2530db986e0..d90a6d43761 100644
--- a/app/views/shared/_sidebar_toggle_button.html.haml
+++ b/app/views/shared/_sidebar_toggle_button.html.haml
@@ -1,8 +1,8 @@
%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" }
= sprite_icon('angle-double-left', css_class: 'icon-angle-double-left')
= sprite_icon('angle-double-right', css_class: 'icon-angle-double-right')
- %span.collapse-text Collapse sidebar
+ %span.collapse-text= _("Collapse sidebar")
= button_tag class: 'close-nav-button', type: 'button' do
= sprite_icon('close', size: 16)
- %span.collapse-text Close sidebar
+ %span.collapse-text= _("Close sidebar")
diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml
index f0d1dd162df..813fccd217b 100644
--- a/app/views/shared/boards/_show.html.haml
+++ b/app/views/shared/boards/_show.html.haml
@@ -13,14 +13,14 @@
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal, show_sorting_dropdown: false
%script#js-board-promotion{ type: "text/x-template" }= render_if_exists "shared/promotions/promote_issue_board"
-#board-app.boards-app{ "v-cloak" => true, data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
+#board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" }
.d-none.d-sm-none.d-md-block
= render 'shared/issuable/search_bar', type: :boards, board: board
- .boards-list
- .boards-app-loading.text-center{ "v-if" => "loading" }
- = icon("spinner spin")
- %board{ "v-cloak" => true,
+ .boards-list.w-100.py-3.px-2.text-nowrap
+ .boards-app-loading.w-100.text-center{ "v-if" => "loading" }
+ = icon("spinner spin 2x")
+ %board{ "v-cloak" => "true",
"v-for" => "list in state.lists",
"ref" => "board",
":list" => "list",
diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml
index c6c5cadc3f5..f9cfcabc015 100644
--- a/app/views/shared/boards/components/_board.html.haml
+++ b/app/views/shared/boards/components/_board.html.haml
@@ -1,8 +1,8 @@
-.board{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
+.board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }',
":data-id" => "list.id" }
- .board-inner
- %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
- %h3.board-title.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset) }' }
+ .board-inner.d-flex.flex-column.position-relative.h-100.rounded
+ %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", "@click" => "toggleExpanded($event)" }
+ %h3.board-title.m-0.d-flex.align-items-center.py-2.px-3.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "p-0 border-bottom-0 justify-content-center": !list.isExpanded }' }
%i.fa.fa-fw.board-title-expandable-toggle{ "v-if": "list.isExpandable",
":class": "{ \"fa-caret-down\": list.isExpanded, \"fa-caret-right\": !list.isExpanded }",
"aria-hidden": "true" }
@@ -31,9 +31,9 @@
%board-delete{ "inline-template" => true,
":list" => "list",
"v-if" => "!list.preset && list.id" }
- %button.board-delete.has-tooltip.float-right{ type: "button", title: _("Delete list"), "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
+ %button.board-delete.p-0.border-0.has-tooltip.float-right{ type: "button", title: _("Delete list"), ":class": "{ 'd-none': !list.isExpanded }", "aria-label" => _("Delete list"), data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
- .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", "v-tooltip": true, data: { placement: "top" } }
+ .issue-count-badge.text-secondary{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"', ":title": "counterTooltip", ":class": "{ 'd-none': !list.isExpanded }", "v-tooltip": true, data: { placement: "top" } }
%span.issue-count-badge-count
%icon.mr-1{ name: "issues" }
{{ list.issuesSize }}
@@ -42,6 +42,7 @@
%button.issue-count-badge-add-button.btn.btn-sm.btn-default.ml-1.has-tooltip.js-no-trigger-collapse{ type: "button",
"@click" => "showNewIssueForm",
"v-if" => "isNewIssueShown",
+ ":class": "{ 'd-none': !list.isExpanded }",
"aria-label" => _("New issue"),
"title" => _("New issue"),
data: { placement: "top", container: "body" } }
diff --git a/app/views/shared/boards/components/_sidebar.html.haml b/app/views/shared/boards/components/_sidebar.html.haml
index 1ff956649ed..b4f75967a67 100644
--- a/app/views/shared/boards/components/_sidebar.html.haml
+++ b/app/views/shared/boards/components/_sidebar.html.haml
@@ -1,17 +1,17 @@
-%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json }
+%board-sidebar{ "inline-template" => true, ":current-user" => (UserSerializer.new.represent(current_user) || {}).to_json }
%transition{ name: "boards-sidebar-slide" }
%aside.right-sidebar.right-sidebar-expanded.issue-boards-sidebar{ "v-show" => "showSidebar" }
.issuable-sidebar
- .block.issuable-sidebar-header
+ .block.issuable-sidebar-header.position-relative
%span.issuable-header-text.hide-collapsed.float-left
- %strong
+ %strong.bold
{{ issue.title }}
%br/
%span
= render_if_exists "shared/boards/components/sidebar/issue_project_path"
= precede "#" do
{{ issue.iid }}
- %a.gutter-toggle.float-right{ role: "button",
+ %a.gutter-toggle.position-absolute.position-top-0.position-right-0{ role: "button",
href: "#",
"@click.prevent" => "closeSidebar",
"aria-label" => "Toggle sidebar" }
@@ -20,6 +20,7 @@
= render "shared/boards/components/sidebar/assignee"
= render_if_exists "shared/boards/components/sidebar/epic"
= render "shared/boards/components/sidebar/milestone"
+ = render "shared/boards/components/sidebar/time_tracker"
= render "shared/boards/components/sidebar/due_date"
= render "shared/boards/components/sidebar/labels"
= render_if_exists "shared/boards/components/sidebar/weight"
diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml
index 1374da9d82c..af6a519a967 100644
--- a/app/views/shared/boards/components/sidebar/_assignee.html.haml
+++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml
@@ -19,7 +19,7 @@
":data-name" => "assignee.name",
":data-username" => "assignee.username" }
.dropdown
- - dropdown_options = issue_assignees_dropdown_options
+ - dropdown_options = assignees_dropdown_options('issue')
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml
index 5630375f428..117d56b30f5 100644
--- a/app/views/shared/boards/components/sidebar/_due_date.html.haml
+++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml
@@ -7,7 +7,7 @@
.value
.value-content
%span.no-value{ "v-if" => "!issue.dueDate" }
- = _("No due date")
+ = _("None")
%span.bold{ "v-if" => "issue.dueDate" }
{{ issue.dueDate | due-date }}
- if can_admin_issue?
diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml
index 19159684420..311dc69d213 100644
--- a/app/views/shared/boards/components/sidebar/_labels.html.haml
+++ b/app/views/shared/boards/components/sidebar/_labels.html.haml
@@ -7,10 +7,17 @@
.value.issuable-show-labels.dont-hide
%span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" }
= _("None")
- %a{ href: "#",
- "v-for" => "label in issue.labels" }
- .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
- {{ label.title }}
+ %span{ "v-for" => "label in issue.labels" }
+ %span.d-inline-block.position-relative.scoped-label-wrapper{ "v-if" => "showScopedLabels(label)" }
+ %a{ href: '#' }
+ %span.badge.color-label.label{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ {{ label.title }}
+ %a.label.scoped-label{ ":href" => "helpLink()" }
+ %i.fa.fa-question-circle{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ %a{ href: "#", "v-else" => true }
+ .badge.color-label.has-tooltip{ ":style" => "{ backgroundColor: label.color, color: label.textColor }" }
+ {{ label.title }}
+
- if can_admin_issue?
.selectbox
%input{ type: "hidden",
@@ -21,17 +28,11 @@
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
":data-selected" => "selectedLabels",
":data-labels" => "issue.assignableLabelsEndpoint",
- data: { toggle: "dropdown",
- field_name: "issue[label_names][]",
- show_no: "true",
- show_any: "true",
- project_id: @project&.try(:id),
- namespace_path: @namespace_path,
- project_path: @project.try(:path) } }
+ data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
%span.dropdown-toggle-text
{{ labelDropdownTitle }}
= icon('chevron-down')
.dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
= render partial: "shared/issuable/label_page_default"
- if can?(current_user, :admin_label, current_board_parent)
- = render partial: "shared/issuable/label_page_create"
+ = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true }
diff --git a/app/views/shared/boards/components/sidebar/_time_tracker.html.haml b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
new file mode 100644
index 00000000000..b76d44c5907
--- /dev/null
+++ b/app/views/shared/boards/components/sidebar/_time_tracker.html.haml
@@ -0,0 +1,6 @@
+.block.time-tracking
+ %time-tracker{ ":time-estimate" => "issue.timeEstimate || 0",
+ ":time-spent" => "issue.timeSpent || 0",
+ ":human-time-estimate" => "issue.humanTimeEstimate",
+ ":human-time-spent" => "issue.humanTimeSpent",
+ "root-path" => "#{root_url}" }
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 913c065e188..bc0dc7f9631 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -13,8 +13,9 @@
= form.label :key, class: 'col-form-label col-sm-2'
.col-sm-10
%p.light
- Paste a machine public key here. Read more about how to generate it
- = link_to 'here', help_page_path('ssh/README')
+ - link_start = "<a href='#{help_page_path('ssh/README')}' target='_blank' rel='noreferrer noopener'>".html_safe
+ - link_end = '</a>'
+ = _('Paste a machine public key here. Read more about how to generate it %{link_start}here%{link_end}').html_safe % { link_start: link_start, link_end: link_end.html_safe }
= form.text_area :key, class: 'form-control thin_area', rows: 5
- else
= form.label :fingerprint, class: 'col-form-label col-sm-2'
@@ -28,6 +29,6 @@
.col-sm-10
= deploy_keys_project_form.label :can_push do
= deploy_keys_project_form.check_box :can_push
- %strong Write access allowed
+ %strong= _('Write access allowed')
%p.light.append-bottom-0
- Allow this key to push to repository as well? (Default only allows pull access.)
+ = _('Allow this key to push to repository as well? (Default only allows pull access.)')
diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml
index 0ddc56dc6c3..9173b802dd4 100644
--- a/app/views/shared/empty_states/_issues.html.haml
+++ b/app/views/shared/empty_states/_issues.html.haml
@@ -1,6 +1,11 @@
- button_path = local_assigns.fetch(:button_path, false)
- project_select_button = local_assigns.fetch(:project_select_button, false)
+- show_import_button = local_assigns.fetch(:show_import_button, false) && can?(current_user, :import_issues, @project)
- has_button = button_path || project_select_button
+- closed_issues_count = issuables_count_for_state(:issues, :closed)
+- opened_issues_count = issuables_count_for_state(:issues, :opened)
+- is_opened_state = params[:state] == 'opened'
+- is_closed_state = params[:state] == 'closed'
.row.empty-state
.col-12
@@ -13,6 +18,20 @@
= _("Sorry, your filter produced no results")
%p.text-center
= _("To widen your search, change or remove filters above")
+ - if show_new_issue_link?(@project)
+ .text-center
+ = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link"
+ - elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0
+ %h4.text-center
+ = _("There are no open issues")
+ %p.text-center
+ = _("To keep this project going, create a new issue")
+ - if show_new_issue_link?(@project)
+ .text-center
+ = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", title: _("New issue"), id: "new_issue_body_link"
+ - elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0
+ %h4.text-center
+ = _("There are no closed issues")
- elsif current_user
%h4
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project")
@@ -21,12 +40,20 @@
- if has_button
.text-center
- if project_select_button
- = render 'shared/new_project_item_select', path: 'issues/new', label: 'New issue', type: :issues, with_feature_enabled: 'issues'
+ = render 'shared/new_project_item_select', path: 'issues/new', label: _('New issue'), type: :issues, with_feature_enabled: 'issues'
- else
- = link_to 'New issue', button_path, class: 'btn btn-success', title: 'New issue', id: 'new_issue_link'
+ = link_to _('New issue'), button_path, class: 'btn btn-success', title: _('New issue'), id: 'new_issue_link'
+
+ - if show_import_button
+ = render 'projects/issues/import_csv/button', type: :text
+
- else
%h4.text-center= _("There are no issues to show")
%p
= _("The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.")
.text-center
= link_to _('Register / Sign In'), new_user_session_path, class: 'btn btn-success'
+
+- if show_import_button
+ = render 'projects/issues/import_csv/modal'
+
diff --git a/app/views/shared/empty_states/_labels.html.haml b/app/views/shared/empty_states/_labels.html.haml
index bee26cd8312..a739103641e 100644
--- a/app/views/shared/empty_states/_labels.html.haml
+++ b/app/views/shared/empty_states/_labels.html.haml
@@ -1,6 +1,6 @@
.row.empty-state.labels
.col-12
- .svg-content
+ .svg-content.qa-label-svg
= image_tag 'illustrations/labels.svg'
.col-12
.text-content
diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml
index 06ceb9738bc..be5b1c6b6ce 100644
--- a/app/views/shared/empty_states/_merge_requests.html.haml
+++ b/app/views/shared/empty_states/_merge_requests.html.haml
@@ -1,6 +1,11 @@
- button_path = local_assigns.fetch(:button_path, false)
- project_select_button = local_assigns.fetch(:project_select_button, false)
- has_button = button_path || project_select_button
+- closed_merged_count = issuables_count_for_state(:merged, :closed)
+- opened_merged_count = issuables_count_for_state(:merged, :opened)
+- is_opened_state = params[:state] == 'opened'
+- is_closed_state = params[:state] == 'closed'
+- can_create_merge_request = merge_request_source_project_for_project(@project)
.row.empty-state.merge-requests
.col-12
@@ -13,6 +18,20 @@
= _("Sorry, your filter produced no results")
%p.text-center
= _("To widen your search, change or remove filters above")
+ .text-center
+ - if can_create_merge_request
+ = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link"
+ - elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0
+ %h4.text-center
+ = _("There are no open merge requests")
+ %p.text-center
+ = _("To keep this project going, create a new merge request")
+ .text-center
+ - if can_create_merge_request
+ = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link"
+ - elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0
+ %h4.text-center
+ = _("There are no closed merge requests")
- else
%h4
= _("Merge requests are a place to propose changes you've made to a project and discuss those changes with others")
diff --git a/app/views/shared/empty_states/_priority_labels.html.haml b/app/views/shared/empty_states/_priority_labels.html.haml
index 555cb4f4af9..bba3475d244 100644
--- a/app/views/shared/empty_states/_priority_labels.html.haml
+++ b/app/views/shared/empty_states/_priority_labels.html.haml
@@ -1,4 +1,4 @@
.text-center
- .svg-content
+ .svg-content.qa-label-svg
= image_tag 'illustrations/priority_labels.svg'
%p Star labels to start sorting by priority
diff --git a/app/views/shared/empty_states/_profile_tabs.html.haml b/app/views/shared/empty_states/_profile_tabs.html.haml
new file mode 100644
index 00000000000..6da40e1b059
--- /dev/null
+++ b/app/views/shared/empty_states/_profile_tabs.html.haml
@@ -0,0 +1,19 @@
+- current_user_empty_message_description = local_assigns.fetch(:current_user_empty_message_description, nil)
+- secondary_button_link = local_assigns.fetch(:secondary_button_link, nil)
+
+.nothing-here-block
+ .svg-content
+ = image_tag illustration_path, size: '75'
+ .text-content
+ - if user_profile? and current_user.present? and current_user.username == params[:username]
+ %h5= current_user_empty_message_header
+
+ - if current_user_empty_message_description.present?
+ %p= current_user_empty_message_description
+
+ - if secondary_button_link.present?
+ = link_to secondary_button_label, secondary_button_link, class: 'btn btn-create btn-inverted'
+
+ = link_to primary_button_label, primary_button_link, class: 'btn btn-success'
+ - else
+ %h5= visitor_empty_message
diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml
new file mode 100644
index 00000000000..a1a16b9d067
--- /dev/null
+++ b/app/views/shared/empty_states/_snippets.html.haml
@@ -0,0 +1,20 @@
+- button_path = local_assigns.fetch(:button_path, false)
+
+.row.empty-state
+ .col-12
+ .svg-content
+ = image_tag 'illustrations/snippets_empty.svg'
+ .text-content
+ - if current_user
+ %h4
+ = s_('SnippetsEmptyState|Snippets are small pieces of code or notes that you want to keep.')
+ %p
+ = s_('SnippetsEmptyState|They can be either public or private.')
+ .text-center
+ = link_to s_('SnippetsEmptyState|New snippet'), button_path, class: 'btn btn-success', title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link'
+ - unless current_page?(dashboard_snippets_path)
+ = link_to s_('SnippetsEmptyState|Explore public snippets'), explore_snippets_path, class: 'btn btn-default', title: s_('SnippetsEmptyState|Explore public snippets')
+ - else
+ %h4.text-center= s_('SnippetsEmptyState|There are no snippets to show.')
+
+
diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml
index df3308abe0d..73eedcc1dc9 100644
--- a/app/views/shared/empty_states/_wikis.html.haml
+++ b/app/views/shared/empty_states/_wikis.html.haml
@@ -2,7 +2,7 @@
- if can?(current_user, :create_wiki, @project)
- create_path = project_wiki_path(@project, params[:id], { view: 'create' })
- - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success', title: s_('WikiEmpty|Create your first page')
+ - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-success qa-create-first-page-link', title: s_('WikiEmpty|Create your first page')
= render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do
%h4.text-left
diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml
index a5f100e3469..d44017299b8 100644
--- a/app/views/shared/empty_states/_wikis_layout.html.haml
+++ b/app/views/shared/empty_states/_wikis_layout.html.haml
@@ -1,6 +1,6 @@
.row.empty-state
.col-12
- .svg-content
+ .svg-content.qa-svg-content
= image_tag image_path
.col-12
.text-content.text-center
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 25df2fe5cd6..b11cb8a3076 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -5,7 +5,7 @@
- supports_quick_actions = model.new_record?
- if supports_quick_actions
- - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name)
+ - preview_url = preview_markdown_path(project, target_type: model.class.name)
- else
- preview_url = preview_markdown_path(project)
diff --git a/app/views/shared/groups/_dropdown.html.haml b/app/views/shared/groups/_dropdown.html.haml
index 1ae6d1f5ee3..f4915440cb2 100644
--- a/app/views/shared/groups/_dropdown.html.haml
+++ b/app/views/shared/groups/_dropdown.html.haml
@@ -24,10 +24,10 @@
%li.divider
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
- Hide archived projects
+ = _("Hide archived projects")
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
- Show archived projects
+ = _("Show archived projects")
%li.js-filter-archived-projects
= link_to filter_groups_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
- Show archived projects only
+ = _("Show archived projects only")
diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml
index a1b901aaffa..609b8dce21a 100644
--- a/app/views/shared/groups/_group.html.haml
+++ b/app/views/shared/groups/_group.html.haml
@@ -14,7 +14,7 @@
%span.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) }
= visibility_level_icon(group.visibility_level, fw: false)
- .avatar-container.s40
+ .avatar-container.rect-avatar.s40
= link_to group do
= group_icon(group, class: "avatar s40 d-none d-sm-block")
.title
diff --git a/app/views/shared/groups/_list.html.haml b/app/views/shared/groups/_list.html.haml
index f50a6bd4d6a..c5b39c7db08 100644
--- a/app/views/shared/groups/_list.html.haml
+++ b/app/views/shared/groups/_list.html.haml
@@ -1,3 +1,10 @@
+- illustration_path = 'illustrations/profile-page/groups.svg'
+- current_user_empty_message_header = s_('UserProfile|You can create a group for several dependent projects.')
+- current_user_empty_message_description = s_('UserProfile|Groups are the best way to manage projects and members.')
+- primary_button_label = _('New group')
+- primary_button_link = new_group_path
+- visitor_empty_message = s_('GroupsEmptyState|No groups found')
+
- if groups.any?
- user = local_assigns[:user]
@@ -5,4 +12,9 @@
- groups.each_with_index do |group, i|
= render "shared/groups/group", group: group, user: user
- else
- .nothing-here-block= s_("GroupsEmptyState|No groups found")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
diff --git a/app/views/shared/icons/_emoji_slightly_smiling_face.svg b/app/views/shared/icons/_emoji_slightly_smiling_face.svg
deleted file mode 100644
index 56dbad91554..00000000000
--- a/app/views/shared/icons/_emoji_slightly_smiling_face.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_emoji_smile.svg b/app/views/shared/icons/_emoji_smile.svg
deleted file mode 100644
index ce645fee46f..00000000000
--- a/app/views/shared/icons/_emoji_smile.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
diff --git a/app/views/shared/icons/_emoji_smiley.svg b/app/views/shared/icons/_emoji_smiley.svg
deleted file mode 100644
index ddfae50e566..00000000000
--- a/app/views/shared/icons/_emoji_smiley.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568h.001zM7.5 6c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 6 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 6 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06A1.44 1.44 0 0 1 12 7.5a1.44 1.44 0 0 1-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06A1.44 1.44 0 0 1 12 4.5c.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6c.92.397 1.91.6 2.912.598a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39c.397-.92.6-1.91.598-2.912zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="nonzero"/></svg>
diff --git a/app/views/shared/icons/_express.svg b/app/views/shared/icons/_express.svg
deleted file mode 100644
index a51e81e5568..00000000000
--- a/app/views/shared/icons/_express.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="27" height="32" viewBox="0 0 27 32" class="btn-template-icon icon-node-express"><g fill="none" fill-rule="evenodd"><path d="M-3 0h32v32H-3z"/><path fill="#353535" d="M1.192 16.267c.04 2.065.288 3.982.745 5.75.456 1.767 1.16 3.307 2.115 4.618.953 1.31 2.185 2.343 3.694 3.098 1.51.755 3.357 1.132 5.54 1.132 3.22 0 5.89-.844 8.016-2.532 2.125-1.69 3.446-4.22 3.962-7.597h1.192c-.437 3.575-1.847 6.345-4.23 8.312-2.384 1.966-5.324 2.95-8.82 2.95-2.383.04-4.42-.338-6.107-1.133-1.69-.794-3.07-1.917-4.142-3.367-1.073-1.45-1.867-3.158-2.383-5.124C.258 20.408 0 18.294 0 16.028c0-2.542.377-4.806 1.132-6.792C1.887 7.25 2.88 5.57 4.112 4.2 5.34 2.83 6.77 1.79 8.4 1.074 10.03.358 11.698 0 13.406 0c2.383 0 4.44.457 6.167 1.37 1.728.914 3.138 2.126 4.23 3.635 1.093 1.51 1.887 3.238 2.384 5.184.496 1.945.705 3.97.625 6.077H1.193zm24.43-1.192c0-1.867-.26-3.645-.775-5.333-.516-1.688-1.28-3.168-2.294-4.44-1.013-1.27-2.274-2.273-3.784-3.008-1.51-.735-3.258-1.102-5.244-1.102-1.67 0-3.228.317-4.678.953-1.45.636-2.72 1.56-3.813 2.77-1.092 1.212-1.976 2.672-2.652 4.38-.675 1.708-1.072 3.635-1.19 5.78h24.43z"/></g></svg>
diff --git a/app/views/shared/icons/_gitea_logo.svg.erb b/app/views/shared/icons/_gitea_logo.svg.erb
new file mode 100644
index 00000000000..c8ddbc5535e
--- /dev/null
+++ b/app/views/shared/icons/_gitea_logo.svg.erb
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?><!-- Created with Inkscape (http://www.inkscape.org/) --><svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="<%= size %>" height="<%= size %>" viewBox="0 0 135.46667 135.46667" version="1.1" id="svg8" sodipodi:docname="logo.svg" inkscape:version="0.92.1 r15371" inkscape:export-filename="" inkscape:export-xdpi="48.000004" inkscape:export-ydpi="48.000004" style="zoom: 1;"><defs id="defs2"></defs><sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" inkscape:pageopacity="0" inkscape:pageshadow="2" inkscape:zoom="0.70710678" inkscape:cx="418.13805" inkscape:cy="177.57445" inkscape:document-units="mm" inkscape:current-layer="layer2" showgrid="false" units="px" width="256px" showguides="false" inkscape:window-width="1920" inkscape:window-height="1137" inkscape:window-x="1912" inkscape:window-y="-8" inkscape:window-maximized="1" inkscape:pagecheckerboard="false" inkscape:measure-start="283.373,243.952" inkscape:measure-end="290.267,236.527"><sodipodi:guide position="0,0" orientation="0,512" id="guide3699" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,0" orientation="-512,0" id="guide3701" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="135.46667,135.46667" orientation="0,-512" id="guide3703" inkscape:locked="false"></sodipodi:guide><sodipodi:guide position="0,135.46667" orientation="512,0" id="guide3705" inkscape:locked="false"></sodipodi:guide></sodipodi:namedview><metadata id="metadata5"><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"></dc:type><dc:title></dc:title></cc:Work></rdf:RDF></metadata><g inkscape:label="Layer 1" inkscape:groupmode="layer" id="layer1" transform="translate(0,-161.53334)" style="display:inline"><path d="M27.709937,195.15095 c-9.546573,-0.0272 -22.3392732,6.79805 -21.6317552,23.90397 c1.105534,26.72889 25.4565952,29.20839 35.1916502,29.42301 c1.068023,5.01357 12.521798,22.30563 21.001818,23.21667 h37.15277 c22.27763,-1.66785 38.9607,-75.75671 26.59321,-76.03825 c-46.781583,2.47691 -49.995146,2.13838 -88.599758,0 c-2.495053,-0.0266 -5.972321,-0.49474 -9.707935,-0.5054 z m2.491319,9.45886 c1.351378,13.69267 3.555849,21.70359 8.018216,33.94345 c-11.382872,-1.50473 -21.069822,-5.22443 -22.851515,-19.10984 c-0.950962,-7.4112 2.390428,-15.16769 14.833299,-14.83361 z " id="path3722" sodipodi:nodetypes="sscccccsccsc" inkscape:connector-curvature="0" style="fill:#000;fill-opacity:1;stroke:#000;stroke-width:1;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;stroke-miterlimit:4;stroke-dasharray:none"></path></g><g inkscape:groupmode="layer" id="layer2" inkscape:label="Layer 2" style="display:inline"><rect style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.24757317;stroke-opacity:1" id="rect4599" width="34.762054" height="34.762054" x="87.508659" y="18.291576" transform="rotate(25.914715)" ry="5.4825778"></rect><path style="display:inline;fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.26644793px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" d="m 79.804947,57.359056 3.241146,1.609954 V 35.255731 h -3.262698 z" id="path4525" inkscape:connector-curvature="0" sodipodi:nodetypes="ccccc"></path></g><g inkscape:groupmode="layer" id="layer3" inkscape:label="Layer 3" style="display:inline"><g style="display:inline" id="g4539"><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606" cy="90.077766" r="3.4745038" cx="49.064713" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-3" cy="102.1049" r="3.4745038" cx="36.810425" transform="rotate(-19.796137)"></circle><circle style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.26458332;stroke-opacity:1" id="path4606-1" cy="111.43928" r="3.4745038" cx="46.484283" transform="rotate(-19.796137)"></circle><rect height="27.261492" style="fill:#000;fill-opacity:1;stroke:none;stroke-width:0.27444693;stroke-opacity:1" x="97.333458" y="18.061695" id="rect4629-8" width="2.6726954" transform="rotate(26.024158)"></rect><path d="M76.558096,68.116343 c12.97589,6.395378 13.012989,4.101862 4.890858,20.907244 " id="path4514" sodipodi:nodetypes="cc" inkscape:connector-curvature="0" style="fill:none;stroke:#000;stroke-width:2.68000007;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"></path></g></g></svg> \ No newline at end of file
diff --git a/app/views/shared/icons/_rails.svg b/app/views/shared/icons/_rails.svg
deleted file mode 100644
index 852bd183cc7..00000000000
--- a/app/views/shared/icons/_rails.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="32" height="20" viewBox="0 0 32 20" class="btn-template-icon icon-rails"><g fill="none" fill-rule="evenodd"><path d="M0-6h32v32H0z"/><path fill="#c00" fill-rule="nonzero" d="M.985 19.636s.422-4.163 3.375-9.087c2.954-4.924 7.99-8.65 12.083-9.017 8.144-.816 15.46 6.485 15.46 6.485s-.24.168-.494.38C23.42 2.49 18.54 5.274 17.005 6.02c-7.033 3.925-4.91 13.616-4.91 13.616H.987zM24.137 2.32c-.45-.182-.9-.35-1.364-.505l.056-.93c.885.254 1.237.423 1.363.493l-.056.943zM22.8 5.304c.45.028.915.084 1.393.183l-.056.872c-.464-.1-.928-.155-1.392-.17l.056-.885zM17.597.913c-.407 0-.815.015-1.223.058l-.268-.83c.465-.056.915-.084 1.35-.084l.282.858h-.14zm.676 5.178c.35-.154.76-.31 1.237-.45l.31.93c-.41.125-.817.294-1.225.49l-.323-.97zm-6.386-3.7c-.366.184-.718.395-1.083.62l-.647-.985c.38-.225.745-.42 1.097-.604l.633.97zm2.883 6.33c.252-.323.548-.646.87-.942l.634.957c-.31.323-.59.647-.83 1L14.77 8.72zm-2.04 4.53c.112-.506.24-1.027.422-1.547l1.012.802c-.14.548-.24 1.097-.295 1.645l-1.14-.9zM6.57 6.57c-.34.35-.662.73-.958 1.11L4.53 6.752c.323-.352.674-.704 1.04-1.055l1 .872zm-4.25 6.286c-.224.52-.52 1.21-.702 1.688L0 13.954c.14-.38.436-1.084.703-1.69l1.618.592zm10.2 3.967l1.518.548c.084.663.21 1.28.337 1.83l-1.688-.605c-.07-.422-.14-1.027-.168-1.772z"/></g></svg>
diff --git a/app/views/shared/icons/_spring.svg b/app/views/shared/icons/_spring.svg
deleted file mode 100644
index ccf18749029..00000000000
--- a/app/views/shared/icons/_spring.svg
+++ /dev/null
@@ -1 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" class="btn-template-icon icon-java-spring"><g fill="none" fill-rule="evenodd"><path d="M0 0h32v32H0z"/><path fill="#70AD51" d="M5.466 27.993c.586.473 1.446.385 1.918-.202.475-.585.386-1.445-.2-1.92-.585-.474-1.444-.383-1.92.202-.45.555-.392 1.356.115 1.844l-.266-.234C1.972 24.762 0 20.597 0 15.978 0 7.168 7.168 0 15.98 0c4.48 0 8.53 1.857 11.435 4.836.66-.898 1.232-1.902 1.7-3.015 2.036 6.118 3.233 11.26 2.795 15.31-.592 8.274-7.508 14.83-15.93 14.83-3.912 0-7.496-1.416-10.276-3.757l-.238-.21zm23.58-4.982c4.01-5.336 1.775-13.965-.085-19.48-1.657 3.453-5.738 6.094-9.262 6.93-3.303.788-6.226.142-9.283 1.318-6.97 2.68-6.86 10.992-3.02 12.86.002 0 .23.124.227.12 0-.002 5.644-1.122 8.764-2.274 4.56-1.684 9.566-5.835 11.213-10.657-.877 5.015-5.182 9.84-9.507 12.056-2.302 1.182-4.092 1.445-7.88 2.756-.464.158-.828.314-.828.314.96-.16 1.917-.212 1.917-.212 5.393-.255 13.807 1.516 17.745-3.73z"/></g></svg>
diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml
index ef3d44a9241..24734ed66cf 100644
--- a/app/views/shared/issuable/_assignees.html.haml
+++ b/app/views/shared/issuable/_assignees.html.haml
@@ -1,9 +1,9 @@
- max_render = 4
-- assignees_rendering_overflow = issue.assignees.size > max_render
+- assignees_rendering_overflow = issuable.assignees.size > max_render
- render_count = assignees_rendering_overflow ? max_render - 1 : max_render
-- more_assignees_count = issue.assignees.size - render_count
+- more_assignees_count = issuable.assignees.size - render_count
-- issue.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
+- issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord
= link_to_member(@project, assignee, name: false, title: "Assigned to :name")
- if more_assignees_count.positive?
diff --git a/app/views/shared/issuable/_board_create_list_dropdown.html.haml b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
index 4597d9439fa..416b4a34651 100644
--- a/app/views/shared/issuable/_board_create_list_dropdown.html.haml
+++ b/app/views/shared/issuable/_board_create_list_dropdown.html.haml
@@ -1,8 +1,8 @@
.dropdown.prepend-left-10#js-add-list
%button.btn.btn-success.btn-inverted.js-new-board-list{ type: "button", data: board_list_data }
Add list
- .dropdown-menu.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
+ .dropdown-menu.dropdown-extended-height.dropdown-menu-paging.dropdown-menu-right.dropdown-menu-issues-board-new.dropdown-menu-selectable.js-tab-container-labels
= render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" }
- if can?(current_user, :admin_label, board.parent)
- = render partial: "shared/issuable/label_page_create"
+ = render partial: "shared/issuable/label_page_create", locals: { show_add_list: true, add_list: true, add_list_class: 'd-none' }
= dropdown_loading
diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
index ca02424215c..a05a13814ac 100644
--- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml
+++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml
@@ -2,7 +2,7 @@
%aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } }
.issuable-sidebar.hidden
- = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
+ = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do
.block.issuable-sidebar-header
.filter-item.inline.update-issues-btn.float-left
= button_tag "Update all", class: "btn update-selected-issues btn-info", disabled: true
@@ -21,10 +21,7 @@
.title
Assignee
.filter-item
- - if type == :issues
- - field_name = "update[assignee_ids][]"
- - else
- - field_name = "update[assignee_id]"
+ - field_name = "update[assignee_ids][]"
= dropdown_tag("Select 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",
placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: field_name } })
.block
@@ -50,4 +47,3 @@
= hidden_field_tag "update[issuable_ids]", []
= hidden_field_tag :state_event, params[:state_event]
-
diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml
index d4834090413..83f60fa6fe2 100644
--- a/app/views/shared/issuable/_feed_buttons.html.haml
+++ b/app/views/shared/issuable/_feed_buttons.html.haml
@@ -1,4 +1,4 @@
-= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to RSS feed' do
+= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to RSS feed') do
= icon('rss')
-= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: 'Subscribe to calendar' do
+= link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do
= custom_icon('icon_calendar')
diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml
deleted file mode 100644
index 2ca4657851c..00000000000
--- a/app/views/shared/issuable/_filter.html.haml
+++ /dev/null
@@ -1,32 +0,0 @@
-.issues-filters
- .issues-details-filters.row-content-block.second-block
- = form_tag page_filter_path(without: [:assignee_id, :author_id, :milestone_title, :label_name, :search]), method: :get, class: 'filter-form js-filter-form' do
- - if params[:search].present?
- = hidden_field_tag :search, params[:search]
- .issues-other-filters
- .filter-item.inline
- - if params[:author_id].present?
- = hidden_field_tag(:author_id, params[:author_id])
- = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit",
- placeholder: "Search authors", data: { any_user: "Any Author", first_user: current_user&.username, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:author_id], field_name: "author_id", default_label: "Author" } })
-
- .filter-item.inline
- - if params[:assignee_id].present?
- = hidden_field_tag(:assignee_id, params[:assignee_id])
- = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
- placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: current_user&.username, null_user: true, current_user: true, project_id: @project&.id, group_id: @group&.id, selected: params[:assignee_id], field_name: "assignee_id", default_label: "Assignee" } })
-
- .filter-item.inline.milestone-filter
- = render "shared/issuable/milestone_dropdown", selected: finder.milestones.try(:first), name: :milestone_title, show_any: true, show_upcoming: true, show_started: true
-
- .filter-item.inline.labels-filter
- = render "shared/issuable/label_dropdown", selected: selected_labels, use_id: false, selected_toggle: params[:label_name], data_options: { field_name: "label_name[]" }
-
- - unless @no_filters_set
- .float-right
- = render 'shared/issuable/sort_dropdown'
-
- - has_labels = @labels && @labels.any?
- .row-content-block.second-block.filtered-labels{ class: ("hidden" unless has_labels) }
- - if has_labels
- = render 'shared/labels_row', labels: @labels
diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml
index d5fb85ba0f3..f2c0c77a583 100644
--- a/app/views/shared/issuable/_label_dropdown.html.haml
+++ b/app/views/shared/issuable/_label_dropdown.html.haml
@@ -8,7 +8,7 @@
- classes = local_assigns.fetch(:classes, [])
- selected = local_assigns.fetch(:selected, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
-- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"}
+- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels")
- dropdown_data.merge!(data_options)
- label_name = local_assigns.fetch(:label_name, "Labels")
- no_default_styles = local_assigns.fetch(:no_default_styles, false)
diff --git a/app/views/shared/issuable/_label_page_create.html.haml b/app/views/shared/issuable/_label_page_create.html.haml
index 55edaa7eda4..d173e3c0192 100644
--- a/app/views/shared/issuable/_label_page_create.html.haml
+++ b/app/views/shared/issuable/_label_page_create.html.haml
@@ -1,4 +1,7 @@
- show_close = local_assigns.fetch(:show_close, true)
+- show_add_list = local_assigns.fetch(:show_add_list, false)
+- add_list = local_assigns.fetch(:add_list, false)
+- add_list_class = local_assigns.fetch(:add_list_class, '')
- subject = @project || @group
.dropdown-page-two.dropdown-new-label
= dropdown_title(create_label_title(subject), options: { back: true, close: show_close })
@@ -12,6 +15,11 @@
.dropdown-label-color-input
.dropdown-label-color-preview.js-dropdown-label-color-preview
%input#new_label_color.default-dropdown-input{ type: "text", placeholder: _('Assign custom color like #FF0000') }
+ - if show_add_list
+ .dropdown-label-input{ class: add_list_class }
+ %label
+ %input.js-add-list{ type: "checkbox", name: "add_list", checked: add_list }
+ %span= _('Add list')
.clearfix
%button.btn.btn-primary.float-left.js-new-label-btn{ type: "button" }
= _('Create')
diff --git a/app/views/shared/issuable/_label_page_default.html.haml b/app/views/shared/issuable/_label_page_default.html.haml
index aa4a5f0e0d3..a0fb5229fc3 100644
--- a/app/views/shared/issuable/_label_page_default.html.haml
+++ b/app/views/shared/issuable/_label_page_default.html.haml
@@ -11,7 +11,7 @@
= dropdown_title(title)
- if show_boards_content
.issue-board-dropdown-content
- %p
+ %p.m-0
= content_title
= dropdown_filter(filter_placeholder)
= dropdown_content
diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml
index 157637dbd11..71123740ee4 100644
--- a/app/views/shared/issuable/_nav.html.haml
+++ b/app/views/shared/issuable/_nav.html.haml
@@ -4,20 +4,20 @@
%ul.nav-links.issues-state-filters.mobile-separator.nav.nav-tabs
%li{ class: active_when(params[:state] == 'opened') }>
- = link_to page_filter_path(state: 'opened', label: true), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
+ = link_to page_filter_path(state: 'opened'), id: 'state-opened', title: "Filter by #{page_context_word} that are currently opened.", data: { state: 'opened' } do
#{issuables_state_counter_text(type, :opened, display_count)}
- if type == :merge_requests
%li{ class: active_when(params[:state] == 'merged') }>
- = link_to page_filter_path(state: 'merged', label: true), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
+ = link_to page_filter_path(state: 'merged'), id: 'state-merged', title: 'Filter by merge requests that are currently merged.', data: { state: 'merged' } do
#{issuables_state_counter_text(type, :merged, display_count)}
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by merge requests that are currently closed and unmerged.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed, display_count)}
- else
%li{ class: active_when(params[:state] == 'closed') }>
- = link_to page_filter_path(state: 'closed', label: true), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
+ = link_to page_filter_path(state: 'closed'), id: 'state-closed', title: 'Filter by issues that are currently closed.', data: { state: 'closed' } do
#{issuables_state_counter_text(type, :closed, display_count)}
= render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count)
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index 46634693067..3d6c5d29d44 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -3,15 +3,15 @@
- block_css_class = type != :boards_modal ? 'row-content-block second-block' : ''
- user_can_admin_list = board && can?(current_user, :admin_list, board.parent)
-.issues-filters
- .issues-details-filters.filtered-search-block{ class: block_css_class, "v-pre" => type == :boards_modal }
+.issues-filters{ class: ("w-100" if type == :boards_modal) }
+ .issues-details-filters.filtered-search-block.d-flex{ class: block_css_class, "v-pre" => type == :boards_modal }
- if type == :boards
= render_if_exists "shared/boards/switcher", board: board
- = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form' do
+ = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do
- if params[:search].present?
= hidden_field_tag :search, params[:search]
- if @can_bulk_update
- .check-all-holder.hidden
+ .check-all-holder.d-none.d-sm-block.hidden
= check_box_tag "check-all-issues", nil, false, class: "check-all-issues left"
.issues-other-filters.filtered-search-wrapper
.filtered-search-box
@@ -57,10 +57,10 @@
avatar: { lazy: true, url: '{{avatar_url}}' }
#js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
%button.btn.btn-link{ type: 'button' }
= _('None')
- %li.filter-dropdown-item{ data: { value: 'any' } }
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
@@ -71,18 +71,19 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
+ = render_if_exists 'shared/issuable/approver_dropdown'
#js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
%button.btn.btn-link{ type: 'button' }
= _('None')
- %li.filter-dropdown-item{ data: { value: 'any' } }
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
- %li.filter-dropdown-item{ data: { value: 'upcoming' } }
+ %li.filter-dropdown-item{ data: { value: 'Upcoming' } }
%button.btn.btn-link{ type: 'button' }
= _('Upcoming')
- %li.filter-dropdown-item{ data: { value: 'started' } }
+ %li.filter-dropdown-item{ data: { value: 'Started' } }
%button.btn.btn-link{ type: 'button' }
= _('Started')
%li.divider.droplab-item-ignore
@@ -92,10 +93,10 @@
{{title}}
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
%button.btn.btn-link{ type: 'button' }
= _('None')
- %li.filter-dropdown-item{ data: { value: 'any' } }
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
@@ -107,10 +108,10 @@
{{title}}
#js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- %li.filter-dropdown-item{ data: { value: 'none' } }
+ %li.filter-dropdown-item{ data: { value: 'None' } }
%button.btn.btn-link{ type: 'button' }
= _('None')
- %li.filter-dropdown-item{ data: { value: 'any' } }
+ %li.filter-dropdown-item{ data: { value: 'Any' } }
%button.btn.btn-link{ type: 'button' }
= _('Any')
%li.divider.droplab-item-ignore
@@ -128,6 +129,19 @@
%li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
%button.btn.btn-link{ type: 'button' }
= _('No')
+ #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dropdown: true } }
+ %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('Yes')
+ %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } }
+ %button.btn.btn-link{ type: 'button' }
+ = _('No')
+ #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu
+ %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
+ %li.filter-dropdown-item
+ %button.btn.btn-link.js-data-value.monospace
+ {{title}}
= render_if_exists 'shared/issuable/filter_weight', type: type
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 9eecfa39390..63557c882f4 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,32 +1,37 @@
-- todo = issuable_todo(issuable)
+-# `assignees` is being passed in for populating selected assignee values in the select box and rendering the assignee link
+ This should be removed when this sidebar is converted to Vue since assignee data is also available in the `issuable_sidebar` hash
-%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
- .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
+- issuable_type = issuable_sidebar[:type]
+- signed_in = !!issuable_sidebar.dig(:current_user, :id)
+- can_edit_issuable = issuable_sidebar.dig(:current_user, :can_edit)
+
+%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: signed_in } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
+ .issuable-sidebar
.block.issuable-sidebar-header
- - if current_user
+ - if signed_in
%span.issuable-header-text.hide-collapsed.float-left
= _('Todo')
%a.gutter-toggle.float-right.js-sidebar-toggle.has-tooltip{ role: "button", href: "#", "aria-label" => "Toggle sidebar", title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }
= sidebar_gutter_toggle_icon
- - if current_user
- = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable
+ - if signed_in
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar
- = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
- - if current_user
+ = form_for issuable_type, url: issuable_sidebar[:issuable_json_path], remote: true, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
+ - if signed_in
.block.todo.hide-expanded
- = render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
+ = render "shared/issuable/sidebar_todo", issuable_sidebar: issuable_sidebar, is_collapsed: true
.block.assignee.qa-assignee-block
- = render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
+ = render "shared/issuable/sidebar_assignees", issuable_sidebar: issuable_sidebar, assignees: assignees
- = render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable
+ = render_if_exists 'shared/issuable/sidebar_item_epic', issuable_sidebar: issuable_sidebar
+ - milestone = issuable_sidebar[:milestone] || {}
.block.milestone
- .sidebar-collapsed-icon.has-tooltip{ title: milestone_tooltip_title(issuable.milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
+ .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } }
= icon('clock-o', 'aria-hidden': 'true')
%span.milestone-title.collapse-truncated-title
- - if issuable.milestone
- = issuable.milestone.title
+ - if milestone.present?
+ = milestone[:title]
- else
= _('None')
.title.hide-collapsed
@@ -35,49 +40,50 @@
- if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.hide-collapsed
- - if issuable.milestone
- = link_to issuable.milestone.title, milestone_path(issuable.milestone), class: "bold has-tooltip", title: milestone_tooltip_due_date(issuable.milestone), data: { container: "body", html: 'true', boundary: 'viewport' }
+ - if milestone.present?
+ = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport' }
- else
%span.no-value
= _('None')
.selectbox.hide-collapsed
- = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
- = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: project_milestones_path(@project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true, default_no: true, selected: (issuable.milestone.name if issuable.milestone), null_default: true, display: 'static' }})
- - if issuable.has_attribute?(:time_estimate)
- #issuable-time-tracker.block
- // Fallback while content is loading
- .title.hide-collapsed
- = _('Time tracking')
- = icon('spinner spin', 'aria-hidden': 'true')
- - if issuable.has_attribute?(:due_date)
+ = f.hidden_field 'milestone_id', value: milestone[:id], id: nil
+ = dropdown_tag('Milestone', options: { title: _('Assign milestone'), toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: _('Search milestones'), data: { show_no: true, field_name: "#{issuable_type}[milestone_id]", project_id: issuable_sidebar[:project_id], issuable_id: issuable_sidebar[:id], milestones: issuable_sidebar[:project_milestones_path], ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], use_id: true, default_no: true, selected: milestone[:title], null_default: true, display: 'static' }})
+
+ #issuable-time-tracker.block
+ // Fallback while content is loading
+ .title.hide-collapsed
+ = _('Time tracking')
+ = icon('spinner spin', 'aria-hidden': 'true')
+
+ - if issuable_sidebar.has_key?(:due_date)
.block.due_date
- .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable) }
+ .sidebar-collapsed-icon.has-tooltip{ data: { placement: 'left', container: 'body', html: 'true', boundary: 'viewport' }, title: sidebar_due_date_tooltip_label(issuable_sidebar[:due_date]) }
= icon('calendar', 'aria-hidden': 'true')
%span.js-due-date-sidebar-value
- = issuable.due_date.try(:to_s, :medium) || 'None'
+ = issuable_sidebar[:due_date].try(:to_s, :medium) || 'None'
.title.hide-collapsed
= _('Due date')
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
+ - if can_edit_issuable
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.hide-collapsed
%span.value-content
- - if issuable.due_date
- %span.bold= issuable.due_date.to_s(:medium)
+ - if issuable_sidebar[:due_date]
+ %span.bold= issuable_sidebar[:due_date].to_s(:medium)
- else
%span.no-value
- = _('No due date')
- - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
- %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) }
+ = _('None')
+ - if can_edit_issuable
+ %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable_sidebar[:due_date].nil?) }
\-
%a.js-remove-due-date{ href: "#", role: "button" }
= _('remove due date')
- - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
+ - if can_edit_issuable
.selectbox.hide-collapsed
- = f.hidden_field :due_date, value: issuable.due_date.try(:strftime, 'yy-mm-dd')
+ = f.hidden_field :due_date, value: issuable_sidebar[:due_date].try(:strftime, 'yy-mm-dd')
.dropdown
- %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable.to_ability_name}[due_date]", ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), display: 'static' } }
+ %button.dropdown-menu-toggle.js-due-date-select{ type: 'button', data: { toggle: 'dropdown', field_name: "#{issuable_type}[due_date]", ability_name: issuable_type, issue_update: issuable_sidebar[:issuable_json_path], display: 'static' } }
%span.dropdown-toggle-text
= _('Due date')
= icon('chevron-down', 'aria-hidden': 'true')
@@ -86,56 +92,54 @@
= dropdown_content do
.js-due-date-calendar
- - if @labels
- - selected_labels = issuable.labels
- .block.labels
- .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(issuable.labels_array), data: { placement: "left", container: "body", boundary: 'viewport' } }
- = icon('tags', 'aria-hidden': 'true')
- %span
- = selected_labels.size
- .title.hide-collapsed
- = _('Labels')
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
- .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
- - if selected_labels.any?
- - selected_labels.each do |label|
- = link_to_label(label, subject: issuable.project, type: issuable.to_ability_name)
- - else
- %span.no-value
- = _('None')
- .selectbox.hide-collapsed
- - selected_labels.each do |label|
- = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
- .dropdown
- %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path_with_defaults if @project), display: 'static' } }
- %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
- = multi_label_name(selected_labels, "Labels")
- = icon('chevron-down', 'aria-hidden': 'true')
- .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
- = render partial: "shared/issuable/label_page_default"
- - if can? current_user, :admin_label, @project and @project
- = render partial: "shared/issuable/label_page_create"
-
- = render_if_exists 'shared/issuable/sidebar_weight', issuable: issuable
-
- - if issuable.has_attribute?(:confidential)
+ - selected_labels = issuable_sidebar[:labels]
+ .block.labels
+ .sidebar-collapsed-icon.js-sidebar-labels-tooltip{ title: issuable_labels_tooltip(selected_labels), data: { placement: "left", container: "body", boundary: 'viewport' } }
+ = icon('tags', 'aria-hidden': 'true')
+ %span
+ = selected_labels.size
+ .title.hide-collapsed
+ = _('Labels')
+ = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
+ - if can_edit_issuable
+ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
+ .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
+ - if selected_labels.any?
+ - selected_labels.each do |label_hash|
+ = render_label(label_from_hash(label_hash).present(issuable_subject: nil), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
+ - else
+ %span.no-value
+ = _('None')
+ .selectbox.hide-collapsed
+ - selected_labels.each do |label|
+ = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
+ .dropdown
+ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
+ %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
+ = multi_label_name(selected_labels, "Labels")
+ = icon('chevron-down', 'aria-hidden': 'true')
+ .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable
+ = render partial: "shared/issuable/label_page_default"
+ - if issuable_sidebar.dig(:current_user, :can_admin_label)
+ = render partial: "shared/issuable/label_page_create"
+
+ = render_if_exists 'shared/issuable/sidebar_weight', issuable_sidebar: issuable_sidebar
+
+ - if issuable_sidebar.has_key?(:confidential)
-# haml-lint:disable InlineJavaScript
- %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe
+ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: issuable_sidebar[:confidential], is_editable: can_edit_issuable }.to_json.html_safe
#js-confidential-entry-point
- - if issuable.has_attribute?(:discussion_locked)
- -# haml-lint:disable InlineJavaScript
- %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe
- #js-lock-entry-point
+ -# haml-lint:disable InlineJavaScript
+ %script#js-lock-issue-data{ type: "application/json" }= { is_locked: !!issuable_sidebar[:discussion_locked], is_editable: can_edit_issuable }.to_json.html_safe
+ #js-lock-entry-point
.js-sidebar-participants-entry-point
- - if current_user
+ - if signed_in
.js-sidebar-subscriptions-entry-point
- - project_ref = cross_project_reference(@project, issuable)
+ - project_ref = issuable_sidebar[:reference]
.block.project-reference
.sidebar-collapsed-icon.dont-change-state
= clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport')
@@ -145,7 +149,8 @@
%cite{ title: project_ref }
= project_ref
= clipboard_button(text: project_ref, title: _('Copy reference to clipboard'), placement: "left", boundary: 'viewport')
- - if current_user && issuable.can_move?(current_user)
+
+ - if issuable_sidebar.dig(:current_user, :can_move)
.block.js-sidebar-move-issue-block
.sidebar-collapsed-icon{ data: { toggle: 'tooltip', placement: 'left', container: 'body', boundary: 'viewport' }, title: _('Move issue') }
= custom_icon('icon_arrow_right')
@@ -159,9 +164,9 @@
= dropdown_content
= dropdown_loading
= dropdown_footer add_content_class: true do
- %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ disabled: true }
+ %button.btn.btn-success.sidebar-move-issue-confirmation-button.js-move-issue-confirmation-button{ type: 'button', disabled: true }
= _('Move')
= icon('spinner spin', class: 'sidebar-move-issue-confirmation-loading-icon')
-# haml-lint:disable InlineJavaScript
- %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable, can_edit_issuable).to_json.html_safe
+ %script.js-sidebar-options{ type: "application/json" }= issuable_sidebar_options(issuable_sidebar).to_json.html_safe
diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml
index 8a13c7a3b83..ab01094ed6e 100644
--- a/app/views/shared/issuable/_sidebar_assignees.html.haml
+++ b/app/views/shared/issuable/_sidebar_assignees.html.haml
@@ -1,56 +1,41 @@
-- if issuable.is_a?(Issue)
- #js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]", signed_in: signed_in } }
- .title.hide-collapsed
- = _('Assignee')
- = icon('spinner spin')
-- else
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body", boundary: 'viewport' }, title: sidebar_assignee_tooltip_label(issuable) }
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
+- issuable_type = issuable_sidebar[:type]
+- signed_in = !!issuable_sidebar.dig(:current_user, :id)
+
+#js-vue-sidebar-assignees{ data: { field: "#{issuable_type}[assignee_ids]", signed_in: signed_in } }
.title.hide-collapsed
= _('Assignee')
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
- - if !signed_in
- %a.gutter-toggle.float-right.js-sidebar-toggle{ role: "button", href: "#", "aria-label" => _('Toggle sidebar') }
- = sidebar_gutter_toggle_icon
- .value.hide-collapsed
- - if issuable.assignee
- = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
- - if !issuable.can_be_merged_by?(issuable.assignee)
- %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: _('Not allowed to merge') }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- = issuable.assignee.to_reference
- - else
- %span.assign-yourself.no-value
- = _('No assignee')
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- = _('assign yourself')
+ = icon('spinner spin')
.selectbox.hide-collapsed
- - issuable.assignees.each do |assignee|
- = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
+ - if assignees.none?
+ = hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil
+ - else
+ - assignees.each do |assignee|
+ = hidden_field_tag "#{issuable_type}[assignee_ids][]", assignee.id, id: nil, data: { avatar_url: assignee.avatar_url, name: assignee.name, username: assignee.username }
- - options = { toggle_class: 'js-user-search js-author-search', title: _('Assign to'), filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: _('Search users'), data: { first_user: current_user&.username, current_user: true, project_id: @project&.id, author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_ids][]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true, display: 'static' } }
- - title = _('Select assignee')
+ - options = { toggle_class: 'js-user-search js-author-search',
+ title: _('Assign to'),
+ filter: true,
+ dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author',
+ placeholder: _('Search users'),
+ data: { first_user: issuable_sidebar.dig(:current_user, :username),
+ current_user: true,
+ project_id: issuable_sidebar[:project_id],
+ author_id: issuable_sidebar[:author_id],
+ field_name: "#{issuable_type}[assignee_ids][]",
+ issue_update: issuable_sidebar[:issuable_json_path],
+ ability_name: issuable_type,
+ null_user: true,
+ display: 'static' } }
- - if issuable.is_a?(Issue)
- - unless issuable.assignees.any?
- = hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
- - dropdown_options = issue_assignees_dropdown_options
- - title = dropdown_options[:title]
- - options[:toggle_class] += ' js-multiselect js-save-user-data'
- - data = { field_name: "#{issuable.to_ability_name}[assignee_ids][]" }
- - data[:multi_select] = true
- - data['dropdown-title'] = title
- - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
- - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
- - options[:data].merge!(data)
+ - dropdown_options = assignees_dropdown_options(issuable_type)
+ - title = dropdown_options[:title]
+ - options[:toggle_class] += ' js-multiselect js-save-user-data'
+ - data = { field_name: "#{issuable_type}[assignee_ids][]" }
+ - data[:multi_select] = true
+ - data['dropdown-title'] = title
+ - data['dropdown-header'] = dropdown_options[:data][:'dropdown-header']
+ - data['max-select'] = dropdown_options[:data][:'max-select'] if dropdown_options[:data][:'max-select']
+ - options[:data].merge!(data)
= dropdown_tag(title, options: options)
diff --git a/app/views/shared/issuable/_sidebar_todo.html.haml b/app/views/shared/issuable/_sidebar_todo.html.haml
index 660ee6d5777..de4df016cfb 100644
--- a/app/views/shared/issuable/_sidebar_todo.html.haml
+++ b/app/views/shared/issuable/_sidebar_todo.html.haml
@@ -1,15 +1,15 @@
- is_collapsed = local_assigns.fetch(:is_collapsed, false)
-- mark_content = is_collapsed ? sprite_icon('todo-done', css_class: 'todo-undone') : _('Mark todo as done')
-- todo_content = is_collapsed ? sprite_icon('todo-add') : _('Add todo')
+- has_todo = !!issuable_sidebar.dig(:current_user, :todo, :id)
+
+- todo_button_data = issuable_todo_button_data(issuable_sidebar, is_collapsed)
+- button_title = has_todo ? todo_button_data[:mark_text] : todo_button_data[:todo_text]
+- button_icon = has_todo ? todo_button_data[:mark_icon] : todo_button_data[:todo_icon]
%button.issuable-todo-btn.js-issuable-todo{ type: 'button',
class: (is_collapsed ? 'btn-blank sidebar-collapsed-icon dont-change-state has-tooltip' : 'btn btn-default issuable-header-btn float-right'),
- title: (todo.nil? ? _('Add todo') : _('Mark todo as done')),
- 'aria-label' => (todo.nil? ? _('Add todo') : _('Mark todo as done')),
- data: issuable_todo_button_data(issuable, todo, is_collapsed) }
+ title: button_title,
+ 'aria-label' => button_title,
+ data: todo_button_data }
%span.issuable-todo-inner.js-issuable-todo-inner<
- - if todo
- = mark_content
- - else
- = todo_content
+ = is_collapsed ? button_icon : button_title
= icon('spin spinner', 'aria-hidden': 'true')
diff --git a/app/views/shared/issuable/_sort_dropdown.html.haml b/app/views/shared/issuable/_sort_dropdown.html.haml
index c211b9fcaa2..967f31c8325 100644
--- a/app/views/shared/issuable/_sort_dropdown.html.haml
+++ b/app/views/shared/issuable/_sort_dropdown.html.haml
@@ -5,16 +5,17 @@
.dropdown.inline.prepend-left-10.issue-sort-dropdown
.btn-group{ role: 'group' }
.btn-group{ role: 'group' }
- %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
+ %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' }
= sort_title
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
- = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority, label: true), sort_title)
- = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sort_title)
- = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated, label: true), sort_title)
- = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone, label: true), sort_title)
- = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date, label: true), sort_title) if viewing_issues
- = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity, label: true), sort_title)
- = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority, label: true), sort_title)
+ = sortable_item(sort_title_priority, page_filter_path(sort: sort_value_priority), sort_title)
+ = sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date), sort_title)
+ = sortable_item(sort_title_recently_updated, page_filter_path(sort: sort_value_recently_updated), sort_title)
+ = sortable_item(sort_title_milestone, page_filter_path(sort: sort_value_milestone), sort_title)
+ = sortable_item(sort_title_due_date, page_filter_path(sort: sort_value_due_date), sort_title) if viewing_issues
+ = sortable_item(sort_title_popularity, page_filter_path(sort: sort_value_popularity), sort_title)
+ = sortable_item(sort_title_label_priority, page_filter_path(sort: sort_value_label_priority), sort_title)
+ = render_if_exists('shared/ee/issuable/sort_dropdown', viewing_issues: viewing_issues, sort_title: sort_title)
= issuable_sort_direction_button(sort_value)
diff --git a/app/views/shared/issuable/form/_contribution.html.haml b/app/views/shared/issuable/form/_contribution.html.haml
index bc9a1edc39c..a78231b37ce 100644
--- a/app/views/shared/issuable/form/_contribution.html.haml
+++ b/app/views/shared/issuable/form/_contribution.html.haml
@@ -15,6 +15,6 @@
= form.check_box :allow_collaboration, disabled: !issuable.can_allow_collaboration?(current_user), class: 'form-check-input'
= form.label :allow_collaboration, class: 'form-check-label' do
= _('Allow commits from members who can merge to the target branch.')
- = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration')
+ = link_to 'About this feature', help_page_path('user/project/merge_requests/allow_collaboration'), target: '_blank', rel: 'noopener noreferrer nofollow'
.form-text.text-muted
= allow_collaboration_unavailable_reason(issuable)
diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml
index 1881875b7c0..f0c4acdd07f 100644
--- a/app/views/shared/issuable/form/_merge_params.html.haml
+++ b/app/views/shared/issuable/form/_merge_params.html.haml
@@ -10,7 +10,7 @@
= hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil
= check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input'
= label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do
- Remove source branch when merge request is accepted.
+ Delete source branch when merge request is accepted.
.form-group.row
.col-sm-10.offset-sm-2
@@ -19,4 +19,4 @@
= check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input'
= label_tag 'merge_request[squash]', class: 'form-check-label' do
Squash commits when merge request is accepted.
- = link_to 'About this feature', help_page_path('user/project/merge_requests/squash_and_merge')
+ = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank'
diff --git a/app/views/shared/issuable/form/_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_merge_request_assignee.html.haml
deleted file mode 100644
index 05c03dedd91..00000000000
--- a/app/views/shared/issuable/form/_merge_request_assignee.html.haml
+++ /dev/null
@@ -1,31 +0,0 @@
-- merge_request = issuable
-.block.assignee
- .sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: sidebar_assignee_tooltip_label(issuable) }
- - if merge_request.assignee
- = link_to_member(@project, merge_request.assignee, size: 24)
- - else
- = icon('user', 'aria-hidden': 'true')
- .title.hide-collapsed
- Assignee
- = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- - if can_edit_issuable
- = link_to 'Edit', '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
- .value.hide-collapsed
- - if merge_request.assignee
- = link_to_member(@project, merge_request.assignee, size: 32, extra_class: 'bold') do
- - unless merge_request.can_be_merged_by?(merge_request.assignee)
- %span.float-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
- = icon('exclamation-triangle', 'aria-hidden': 'true')
- %span.username
- = merge_request.assignee.to_reference
- - else
- %span.assign-yourself.no-value
- No assignee
- - if can_edit_issuable
- \-
- %a.js-assign-yourself{ href: '#' }
- assign yourself
-
- .selectbox.hide-collapsed
- = f.hidden_field 'assignee_id', value: merge_request.assignee_id, id: 'issue_assignee_id'
- = dropdown_tag('Select assignee', options: { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project&.id, author_id: merge_request.author_id, field_name: 'merge_request[assignee_id]', issue_update: issuable_json_path(merge_request), ability_name: 'merge_request', null_user: true } })
diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml
index ac8d58c0bfe..1e03440a5dc 100644
--- a/app/views/shared/issuable/form/_metadata.html.haml
+++ b/app/views/shared/issuable/form/_metadata.html.haml
@@ -8,21 +8,17 @@
%hr
.row
%div{ class: (has_due_date ? "col-lg-6" : "col-12") }
- .form-group.row.issue-assignee
- - if issuable.is_a?(Issue)
- = render "shared/issuable/form/metadata_issue_assignee", issuable: issuable, form: form, has_due_date: has_due_date
- - else
- = render "shared/issuable/form/metadata_merge_request_assignee", issuable: issuable, form: form, has_due_date: has_due_date
+ .form-group.row.merge-request-assignee
+ = render "shared/issuable/form/metadata_issuable_assignee", issuable: issuable, form: form, has_due_date: has_due_date
.form-group.row.issue-milestone
= form.label :milestone_id, "Milestone", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, show_started: false, extra_class: "qa-issuable-milestone-dropdown js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
.form-group.row
- - has_labels = @labels && @labels.any?
= form.label :label_ids, "Labels", class: "col-form-label #{has_due_date ? "col-md-2 col-lg-4" : "col-sm-2"}"
= form.hidden_field :label_ids, multiple: true, value: ''
- .col-sm-10{ class: "#{"col-md-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" }
+ .col-sm-10{ class: "#{"col-md-8" if has_due_date}" }
.issuable-form-select-holder
= render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
diff --git a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
index 6d4f9ccd66f..5336159e762 100644
--- a/app/views/shared/issuable/form/_metadata_issue_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_issuable_assignee.html.haml
@@ -1,4 +1,4 @@
-= form.label :assignee_ids, "Assignee", class: "col-form-label #{"col-md-2 col-lg-4" if has_due_date}"
+= form.label :assignee_id, "Assignee", class: "col-form-label #{has_due_date ? "col-lg-4" : "col-sm-2"}"
.col-sm-10{ class: ("col-md-8" if has_due_date) }
.issuable-form-select-holder.selectbox
- issuable.assignees.each do |assignee|
@@ -7,5 +7,5 @@
- if issuable.assignees.length === 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil, data: { meta: '' }
- = dropdown_tag(users_dropdown_label(issuable.assignees), options: issue_assignees_dropdown_options)
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
+ = dropdown_tag(users_dropdown_label(issuable.assignees), options: assignees_dropdown_options(issuable.to_ability_name))
+ = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignees.include?(current_user)}"
diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml
index 56c4b021eab..75e9ab547ce 100644
--- a/app/views/shared/issuable/form/_title.html.haml
+++ b/app/views/shared/issuable/form/_title.html.haml
@@ -6,7 +6,7 @@
%div{ class: div_class }
= form.text_field :title, required: true, maxlength: 255, autofocus: true,
- autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title')
+ autocomplete: 'off', class: 'form-control pad qa-issuable-form-title', placeholder: _('Title'), dir: 'auto'
- if issuable.respond_to?(:work_in_progress?)
.form-text.text-muted
diff --git a/app/views/shared/issuable/nav_links/_all.html.haml b/app/views/shared/issuable/nav_links/_all.html.haml
index d7ad7090a45..c92a50bcb70 100644
--- a/app/views/shared/issuable/nav_links/_all.html.haml
+++ b/app/views/shared/issuable/nav_links/_all.html.haml
@@ -2,5 +2,5 @@
- counter = local_assigns.fetch(:counter)
%li{ class: active_when(params[:state] == 'all') }>
- = link_to page_filter_path(state: 'all', label: true), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
+ = link_to page_filter_path(state: 'all'), id: 'state-all', title: "Show all #{page_context_word}.", data: { state: 'all' } do
#{counter}
diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml
index 7619d0a2e9c..4b88aff3313 100644
--- a/app/views/shared/labels/_form.html.haml
+++ b/app/views/shared/labels/_form.html.haml
@@ -2,15 +2,20 @@
= form_errors(@label)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
- = f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true
+ = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true
+ = render_if_exists 'shared/labels/create_label_help_text'
+
.form-group.row
- = f.label :description, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit qa-label-description"
.form-group.row
- = f.label :color, "Background color", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :color, "Background color"
.col-sm-10
.input-group
.input-group-prepend
diff --git a/app/views/shared/labels/_nav.html.haml b/app/views/shared/labels/_nav.html.haml
index 98572db738b..e69246dd0eb 100644
--- a/app/views/shared/labels/_nav.html.haml
+++ b/app/views/shared/labels/_nav.html.haml
@@ -18,3 +18,7 @@
%button.btn.btn-default{ type: "submit", "aria-label" => _('Submit search') }
= icon("search")
= render 'shared/labels/sort_dropdown'
+ - if labels_or_filters && can_admin_label && @project
+ = link_to _('New label'), new_project_label_path(@project), class: "btn btn-success qa-label-create-new"
+ - if labels_or_filters && can_admin_label && @group
+ = link_to _('New label'), new_group_label_path(@group), class: "btn btn-success qa-label-create-new"
diff --git a/app/views/shared/labels/_sort_dropdown.html.haml b/app/views/shared/labels/_sort_dropdown.html.haml
index d664ef1cc2f..07e96eea062 100644
--- a/app/views/shared/labels/_sort_dropdown.html.haml
+++ b/app/views/shared/labels/_sort_dropdown.html.haml
@@ -6,4 +6,4 @@
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-sort
%li
- label_sort_options_hash.each do |value, title|
- = sortable_item(title, page_filter_path(sort: value, label: true, subscribed: params[:subscribed]), sort_title)
+ = sortable_item(title, page_filter_path(sort: value), sort_title)
diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml
deleted file mode 100644
index ebae58f28ba..00000000000
--- a/app/views/shared/members/_access_request_buttons.html.haml
+++ /dev/null
@@ -1,20 +0,0 @@
-- model_name = source.model_name.to_s.downcase
-
-- if can?(current_user, :"destroy_#{model_name}_member", source.members.find_by(user_id: current_user.id)) # rubocop: disable CodeReuse/ActiveRecord
- .project-action-button.inline
- - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project')
- = link_to link_text, polymorphic_path([:leave, source, :members]),
- method: :delete,
- data: { confirm: leave_confirmation_message(source) },
- class: 'btn'
-- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
- .project-action-button.inline
- = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
- method: :delete,
- data: { confirm: remove_member_message(requester) },
- class: 'btn'
-- elsif source.request_access_enabled && can?(current_user, :request_access, source)
- .project-action-button.inline
- = link_to _('Request Access'), polymorphic_path([:request_access, source, :members]),
- method: :post,
- class: 'btn'
diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml
index f7227b9101e..eac743b5206 100644
--- a/app/views/shared/members/_access_request_links.html.haml
+++ b/app/views/shared/members/_access_request_links.html.haml
@@ -5,7 +5,7 @@
= link_to link_text, polymorphic_path([:leave, source, :members]),
method: :delete,
data: { confirm: leave_confirmation_message(source) },
- class: 'access-request-link'
+ class: 'access-request-link js-leave-link'
- elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord
= link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]),
method: :delete,
diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml
index 6b3841ebbc4..2db1f67a793 100644
--- a/app/views/shared/members/_member.html.haml
+++ b/app/views/shared/members/_member.html.haml
@@ -10,7 +10,7 @@
- if user
= image_tag avatar_icon_for_user(user, 40), class: "avatar s40", alt: ''
.user-info
- = link_to user.name, user_path(user), class: 'member'
+ = link_to user.name, user_path(user), class: 'member js-user-link', data: { user_id: user.id }
= user_status(user)
%span.cgray= user.to_reference
diff --git a/app/views/shared/milestones/_form_dates.html.haml b/app/views/shared/milestones/_form_dates.html.haml
index 922805958a5..4de89d7c7a0 100644
--- a/app/views/shared/milestones/_form_dates.html.haml
+++ b/app/views/shared/milestones/_form_dates.html.haml
@@ -1,11 +1,13 @@
.col-md-6
.form-group.row
- = f.label :start_date, "Start Date", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :start_date, "Start Date"
.col-sm-10
= f.text_field :start_date, class: "datepicker form-control", placeholder: "Select start date", autocomplete: 'off'
%a.inline.float-right.prepend-top-5.js-clear-start-date{ href: "#" } Clear start date
.form-group.row
- = f.label :due_date, "Due Date", class: "col-form-label col-sm-2"
+ .col-form-label.col-sm-2
+ = f.label :due_date, "Due Date"
.col-sm-10
= f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date", autocomplete: 'off'
%a.inline.float-right.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date
diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml
index eba64daaadc..ae3ab2adfd0 100644
--- a/app/views/shared/milestones/_issuable.html.haml
+++ b/app/views/shared/milestones/_issuable.html.haml
@@ -21,8 +21,7 @@
%span.issuable-number= issuable.to_reference
- labels.each do |label|
- = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do
- - render_colored_label(label)
+ = render_label(label.present(issuable_subject: project), link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }))
%span.assignee-icon
- assignees.each do |assignee|
diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml
index 6797520650d..ecab037e378 100644
--- a/app/views/shared/milestones/_labels_tab.html.haml
+++ b/app/views/shared/milestones/_labels_tab.html.haml
@@ -2,11 +2,10 @@
- labels.each do |label|
- options = { milestone_title: @milestone.title, label_name: label.title }
- %li.is-not-draggable
+ %li.no-border
%span.label-row
%span.label-name
- = link_to milestones_label_path(options) do
- - render_colored_label(label, tooltip: false)
+ = render_label(label, tooltip: false, link: milestones_label_path(options))
%span.prepend-description-left
= markdown_field(label, :description)
diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml
index ed7fefba56d..e99aa3f1ee4 100644
--- a/app/views/shared/milestones/_milestone.html.haml
+++ b/app/views/shared/milestones/_milestone.html.haml
@@ -1,5 +1,5 @@
- dashboard = local_assigns[:dashboard]
-- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
+- custom_dom_id = dom_id(milestone.try(:milestone) ? milestone.milestone : milestone)
- milestone_type = milestone.group_milestone? ? 'Group Milestone' : 'Project Milestone'
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
@@ -21,16 +21,18 @@
= milestone.group.full_name
- if milestone.legacy_group_milestone?
.projects
- - milestone.milestones.each do |milestone|
- = link_to milestone_path(milestone) do
- %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5
- = dashboard ? milestone.project.full_name : milestone.project.name
+ - link_to milestone_path(milestone.milestone) do
+ %span.label-badge.label-badge-blue.d-inline-block.append-bottom-5
+ = dashboard ? milestone.project.full_name : milestone.project.name
+ - if milestone.project
+ .label-badge.label-badge-gray.d-inline-block
+ = milestone.project.full_name
.col-sm-4.milestone-progress
= milestone_progress_bar(milestone)
= link_to pluralize(milestone.total_issues_count(current_user), 'Issue'), issues_path
&middot;
- = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
+ = link_to pluralize(milestone.merge_requests_visible_to_user(current_user).size, 'Merge Request'), merge_requests_path
.float-lg-right.light #{milestone.percent_complete(current_user)}% complete
.col-sm-2
.milestone-actions.d-flex.justify-content-sm-start.justify-content-md-end
@@ -50,7 +52,7 @@
= link_to 'Close Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :close }), method: :put, remote: true, class: "btn btn-sm btn-close btn-grouped"
- unless milestone.active?
- = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
+ = link_to 'Reopen Milestone', project_milestone_path(@project, milestone, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
- if @group
- if can?(current_user, :admin_milestone, @group)
- if milestone.closed?
@@ -58,5 +60,5 @@
- else
= link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-sm btn-grouped btn-close"
- if dashboard
- .status-box.status-box-milestone
+ .label-badge.label-badge-gray
= milestone_type
diff --git a/app/views/shared/milestones/_search_form.html.haml b/app/views/shared/milestones/_search_form.html.haml
new file mode 100644
index 00000000000..403a0224a85
--- /dev/null
+++ b/app/views/shared/milestones/_search_form.html.haml
@@ -0,0 +1,8 @@
+= form_tag request.path, method: :get do |f|
+ = search_field_tag :search_title, params[:search_title],
+ placeholder: _('Filter by milestone name'),
+ class: 'form-control input-short',
+ spellcheck: false
+ = hidden_field_tag :state, params[:state]
+ = hidden_field_tag :sort, params[:sort]
+
diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml
index becd1c4884e..b24075c7849 100644
--- a/app/views/shared/milestones/_sidebar.html.haml
+++ b/app/views/shared/milestones/_sidebar.html.haml
@@ -65,7 +65,7 @@
%span.bold= milestone.due_date.to_s(:medium)
- else
%span.no-value No due date
- - remaining_days = remaining_days_in_words(milestone)
+ - remaining_days = remaining_days_in_words(milestone.due_date, milestone.start_date)
- if remaining_days.present?
= surround '(', ')' do
%span.remaining-days= remaining_days
diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml
index 55460acab8f..b877f66c71e 100644
--- a/app/views/shared/milestones/_tabs.html.haml
+++ b/app/views/shared/milestones/_tabs.html.haml
@@ -12,7 +12,7 @@
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
Merge Requests
- %span.badge.badge-pill= milestone.merge_requests.size
+ %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size
- else
%li.nav-item
= link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do
@@ -21,11 +21,11 @@
%li.nav-item
= link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do
Participants
- %span.badge.badge-pill= milestone.participants.count
+ %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count
%li.nav-item
= link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do
Labels
- %span.badge.badge-pill= milestone.labels.count
+ %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count
- issues = milestone.sorted_issues(current_user)
- show_project_name = local_assigns.fetch(:show_project_name, false)
diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml
index 0499b04a482..43503e1d08a 100644
--- a/app/views/shared/milestones/_top.html.haml
+++ b/app/views/shared/milestones/_top.html.haml
@@ -4,10 +4,7 @@
- group = local_assigns[:group]
- is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone?
-.detail-page-header
- %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" }
- = icon('angle-double-left')
-
+.detail-page-header.milestone-page-header
.status-box{ class: "status-box-#{milestone.closed? ? 'closed' : 'open'}" }
- if milestone.closed?
Closed
@@ -15,14 +12,17 @@
Expired
- else
Open
- %span.identifier
- Milestone #{milestone.title}
- - if milestone.due_date || milestone.start_date
- %span.creator
- &nbsp;&middot;
- = milestone_date_range(milestone)
- - if group
- .float-right
+
+ .header-text-content
+ %span.identifier
+ Milestone #{milestone.title}
+ - if milestone.due_date || milestone.start_date
+ %span.creator
+ &nbsp;&middot;
+ = milestone_date_range(milestone)
+
+ .milestone-buttons
+ - if group
- if can?(current_user, :admin_milestone, group)
- if milestone.group_milestone?
= link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do
@@ -35,6 +35,9 @@
- unless is_dynamic_milestone
= render 'shared/milestones/delete_button'
+ %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" }
+ = icon('angle-double-left')
+
= render 'shared/milestones/deprecation_message' if is_dynamic_milestone
.detail-page-description.milestone-detail
@@ -42,9 +45,8 @@
= markdown_field(milestone, :title)
- if milestone.group_milestone? && milestone.description.present?
%div
- .description
- .wiki
- = markdown_field(milestone, :description)
+ .description.md
+ = markdown_field(milestone, :description)
- if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default
@@ -62,20 +64,19 @@
%th Open issues
%th State
%th Due date
- - milestone.milestones.each do |ms|
%tr
%td
- - project_name = group ? ms.project.name : ms.project.full_name
- = link_to project_name, project_milestone_path(ms.project, ms)
+ - project_name = group ? milestone.project.name : milestone.project.full_name
+ = link_to project_name, milestone_path(milestone.milestone)
%td
- = ms.issues_visible_to_user(current_user).opened.count
+ = milestone.milestone.issues_visible_to_user(current_user).opened.count
%td
- - if ms.closed?
+ - if milestone.closed?
Closed
- else
Open
%td
- = ms.expires_at
+ = milestone.expires_at
- elsif milestone.group_milestone?
%br
View
diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml
index 0674c822d63..c3f5eeb0da6 100644
--- a/app/views/shared/notes/_comment_button.html.haml
+++ b/app/views/shared/notes/_comment_button.html.haml
@@ -1,30 +1,31 @@
- noteable_name = @note.noteable.human_class_name
.float-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
- %input.btn.btn-nr.btn-success.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' }
+ %input.btn.btn-nr.btn-success.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
+ = button_tag type: 'button', class: 'btn btn-nr dropdown-toggle btn-success 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.dropdown-open-top{ 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}" } }
+ %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => _('Comment'), 'close-text' => _("Comment & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Comment & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
- %strong Comment
+ %strong= _("Comment")
%p
- Add a general comment to this #{noteable_name}.
+ = _("Add a general comment to this %{noteable_name}.") % { noteable_name: noteable_name }
%li.divider.droplab-item-ignore
- %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
+ %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => _('Start discussion'), 'close-text' => _("Start discussion & close %{noteable_name}") % { noteable_name: noteable_name }, 'reopen-text' => _("Start discussion & reopen %{noteable_name}") % { noteable_name: noteable_name } } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
- %strong Start discussion
+ %strong= _("Start discussion")
%p
= succeed '.' do
- Discuss a specific suggestion or question
- if @note.noteable.supports_resolvable_notes?
- that needs to be resolved
+ = _('Discuss a specific suggestion or question that needs to be resolved')
+ - else
+ = _('Discuss a specific suggestion or question')
diff --git a/app/views/shared/notes/_edit.html.haml b/app/views/shared/notes/_edit.html.haml
index f4b3aac29b4..84a3ef9d8fe 100644
--- a/app/views/shared/notes/_edit.html.haml
+++ b/app/views/shared/notes/_edit.html.haml
@@ -1 +1 @@
-%textarea.hidden.js-task-list-field.original-task-list{ data: {update_url: note_url(note) } }= note.note
+%textarea.hidden.js-task-list-field.original-task-list{ data: { update_url: note_url(note) } }= note.note
diff --git a/app/views/shared/notes/_edit_form.html.haml b/app/views/shared/notes/_edit_form.html.haml
index fec966069b9..6fe511c2999 100644
--- a/app/views/shared/notes/_edit_form.html.haml
+++ b/app/views/shared/notes/_edit_form.html.haml
@@ -3,12 +3,12 @@
= hidden_field_tag :target_id, '', class: 'js-form-target-id'
= hidden_field_tag :target_type, '', class: 'js-form-target-type'
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(project), referenced_users: true } do
- = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: "Write a comment or drag your files here…"
+ = render 'projects/zen', attr: 'note[note]', classes: 'note-textarea js-note-text js-task-list-field', placeholder: _("Write a comment or drag your files here…")
= render 'shared/notes/hints'
.note-form-actions.clearfix
.settings-message.note-edit-warning.js-finish-edit-warning
- Finish editing this message first!
- = submit_tag 'Save comment', class: 'btn btn-nr btn-success js-comment-save-button'
+ = _("Finish editing this message first!")
+ = submit_tag _('Save comment'), class: 'btn btn-nr btn-success js-comment-save-button'
%button.btn.btn-nr.btn-cancel.note-edit-cancel{ type: 'button' }
- Cancel
+ = _("Cancel")
diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml
index c360f1ffe2a..d91bc6e57c9 100644
--- a/app/views/shared/notes/_form.html.haml
+++ b/app/views/shared/notes/_form.html.haml
@@ -1,7 +1,7 @@
- supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true)
- supports_quick_actions = note_supports_quick_actions?(@note)
- if supports_quick_actions
- - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id)
+ - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id)
- else
- preview_url = preview_markdown_path(@project)
@@ -29,7 +29,7 @@
= render 'projects/zen', f: f,
attr: :note,
classes: 'note-textarea js-note-text',
- placeholder: "Write a comment or drag your files here…",
+ placeholder: _("Write a comment or drag your files here…"),
supports_quick_actions: supports_quick_actions,
supports_autocomplete: supports_autocomplete
= render 'shared/notes/hints', supports_quick_actions: supports_quick_actions
@@ -40,5 +40,5 @@
= yield(:note_actions)
- %a.btn.btn-cancel.js-note-discard{ role: "button", data: {cancel_text: "Cancel" } }
- Discard draft
+ %a.btn.btn-cancel.js-close-discussion-note-form.hide{ role: "button", data: { cancel_text: _("Cancel") } }
+ = _('Cancel')
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index 00eae553279..46f3f8428f1 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,10 +1,10 @@
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
.comment-toolbar.clearfix
.toolbar-text
- = link_to 'Markdown', help_page_path('user/markdown'), target: '_blank', tabindex: -1
+ = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank', tabindex: -1
- if supports_quick_actions
and
- = link_to 'quick actions', help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
+ = link_to _('quick actions'), help_page_path('user/project/quick_actions'), target: '_blank', tabindex: -1
are
- else
is
@@ -24,12 +24,12 @@
= icon('file-image-o', class: 'toolbar-button-icon')
%span.uploading-error-message
-# Populated by app/assets/javascripts/dropzone_input.js
- %button.retry-uploading-link{ type: 'button' } Try again
+ %button.retry-uploading-link{ type: 'button' }= _("Try again")
or
- %button.attach-new-file.markdown-selector{ type: 'button' } attach a new file
+ %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file")
%button.markdown-selector.button-attach-file{ type: 'button', tabindex: '-1' }
= icon('file-image-o', class: 'toolbar-button-icon')
- Attach a file
+ = _("Attach a file")
- %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' } Cancel
+ %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel")
diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml
index e125d7f108a..6fec435cc87 100644
--- a/app/views/shared/notes/_note.html.haml
+++ b/app/views/shared/notes/_note.html.haml
@@ -36,14 +36,13 @@
= user_status(note.author)
%span.note-headline-light
= note.author.to_reference
- %span.note-headline-light
- %span.note-headline-meta
- - if note.system
- %span.system-note-message
- = markdown_field(note, :note)
- %span.system-note-separator
- &middot;
- %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
+ %span.note-headline-light.note-headline-meta
+ - if note.system
+ %span.system-note-message
+ = markdown_field(note, :note)
+ %span.system-note-separator
+ &middot;
+ %a.system-note-separator{ href: "##{dom_id(note)}" }= time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago')
- unless note.system?
.note-actions
- if note.for_personal_snippet?
@@ -54,7 +53,7 @@
.note-text.md
= markdown_field(note, :note)
= edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago')
- .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore, markdown_version: note.cached_markdown_version } }
+ .original-note-content.hidden{ data: { post_url: note_url(note), target_id: note.noteable.id, target_type: note.noteable.class.name.underscore } }
#{note.note}
- if note_editable
= render 'shared/notes/edit', note: note
@@ -62,7 +61,7 @@
= render 'award_emoji/awards_block', awardable: note, inline: false
- if note.system
.system-note-commit-list-toggler.hide
- Toggle commit list
+ = _("Toggle commit list")
%i.fa.fa-angle-down
- if note.attachment.url
.note-attachment
@@ -74,5 +73,5 @@
= icon('paperclip')
= note.attachment_identifier
= link_to delete_attachment_project_note_path(note.project, note),
- title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do
+ title: _('Delete this attachment'), method: :delete, remote: true, data: { confirm: _('Are you sure you want to remove the attachment?') }, class: 'danger js-note-attachment-delete' do
= icon('trash-o', class: 'cred')
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index 4c4050c6054..002189e6ecd 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -19,20 +19,14 @@
= render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete
- elsif !current_user
.disabled-comment.text-center.prepend-top-default
- Please
- = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link'
- or
- = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
- to comment
+ - link_to_register = link_to(_("register"), new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link')
+ - link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link')
+ = _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in }
- elsif discussion_locked
.disabled-comment.text-center.prepend-top-default
%span.issuable-note-warning
= sprite_icon('lock', size: 16, css_class: 'icon')
%span
- This
- = issuable.class.to_s.titleize.downcase
- is locked. Only
- %b project members
- can comment.
+ = _("This %{issuable} is locked. Only <strong>project members</strong> can comment.").html_safe % { issuable: issuable.class.to_s.titleize.downcase }
-# haml-lint:disable InlineJavaScript
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml
index 30860988bbb..31cc0c091dd 100644
--- a/app/views/shared/notifications/_button.html.haml
+++ b/app/views/shared/notifications/_button.html.haml
@@ -1,7 +1,7 @@
- btn_class = local_assigns.fetch(:btn_class, nil)
- if notification_setting
- .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline
+ .js-notification-dropdown.notification-dropdown.home-panel-action-button.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form" } do |f|
= hidden_setting_source_input(notification_setting)
= f.hidden_field :level, class: "notification_setting_level"
@@ -15,7 +15,7 @@
= icon('caret-down')
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/shared/notifications/_new_button.html.haml
index 745983ace7e..052e6da5bae 100644
--- a/app/views/projects/buttons/_notifications.html.haml
+++ b/app/views/shared/notifications/_new_button.html.haml
@@ -1,7 +1,7 @@
-- btn_class = local_assigns.fetch(:btn_class, "btn-xs")
+- btn_class = local_assigns.fetch(:btn_class, nil)
- if notification_setting
- .js-notification-dropdown.notification-dropdown.project-action-button.dropdown.inline
+ .js-notification-dropdown.notification-dropdown.home-panel-action-button.prepend-top-default.append-right-8.dropdown.inline
= form_for notification_setting, remote: true, html: { class: "inline notification-form no-label" } do |f|
= hidden_setting_source_input(notification_setting)
= hidden_field_tag "hide_label", true
@@ -9,15 +9,15 @@
.js-notification-toggle-btns
%div{ class: ("btn-group" if notification_setting.custom?) }
- if notification_setting.custom?
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
- = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } }
+ = notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
- %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = sprite_icon("arrow-down", css_class: "icon")
+ %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" }, class: "#{btn_class}" }
+ = sprite_icon("arrow-down", css_class: "icon mr-0")
.sr-only Toggle dropdown
- else
- %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
- = sprite_icon("notifications", css_class: "icon notifications-icon js-notifications-icon")
+ %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
+ = notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
= sprite_icon("arrow-down", css_class: "icon")
diff --git a/app/views/shared/notifications/_notification_dropdown.html.haml b/app/views/shared/notifications/_notification_dropdown.html.haml
index 85ad74f9a39..a6ef2d51171 100644
--- a/app/views/shared/notifications/_notification_dropdown.html.haml
+++ b/app/views/shared/notifications/_notification_dropdown.html.haml
@@ -8,5 +8,5 @@
%li.divider
%li
%a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } }
- %strong.dropdown-menu-inner-title Custom
+ %strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom')
%span.dropdown-menu-inner-content= notification_description("custom")
diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml
index 98b258d9275..88ac03bf9e3 100644
--- a/app/views/shared/projects/_dropdown.html.haml
+++ b/app/views/shared/projects/_dropdown.html.haml
@@ -1,10 +1,9 @@
- @sort ||= sort_value_latest_activity
.dropdown.js-project-filter-dropdown-wrap
- - toggle_text = projects_sort_options_hash[@sort]
- = dropdown_toggle(toggle_text, { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' })
+ = dropdown_toggle(projects_sort_options_hash[@sort], { toggle: 'dropdown', display: 'static' }, { id: 'sort-projects-dropdown' })
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
%li.dropdown-header
- Sort by
+ = _("Sort by")
- projects_sort_options_hash.each do |value, title|
%li
= link_to filter_projects_path(sort: value), class: ("is-active" if @sort == value) do
@@ -13,29 +12,29 @@
%li.divider
%li
= link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
- Hide archived projects
+ = _("Hide archived projects")
%li
= link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
- Show archived projects
+ = _("Show archived projects")
%li
= link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
- Show archived projects only
+ = _("Show archived projects only")
- if current_user
%li.divider
%li
= link_to filter_projects_path(personal: nil), class: ("is-active" unless params[:personal].present?) do
- Owned by anyone
+ = _("Owned by anyone")
%li
= link_to filter_projects_path(personal: true), class: ("is-active" if params[:personal].present?) do
- Owned by me
+ = _("Owned by me")
- if @group && @group.shared_projects.present?
%li.divider
%li
= link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do
- All projects
+ = _("All projects")
%li
= link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do
- Hide shared projects
+ = _("Hide shared projects")
%li
= link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do
- Hide group projects
+ = _("Hide group projects")
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 15c29e14cc0..13847cd9be1 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -14,6 +14,17 @@
- skip_pagination = false unless local_assigns[:skip_pagination] == true
- compact_mode = false unless local_assigns[:compact_mode] == true
- css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
+- contributed_projects_illustration_path = 'illustrations/profile-page/contributed-projects.svg'
+- contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
+- contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
+- own_projects_illustration_path = 'illustrations/profile-page/personal-project.svg'
+- own_projects_current_user_empty_message_header = s_('UserProfile|You haven\'t created any personal projects.')
+- own_projects_current_user_empty_message_description = s_('UserProfile|Your projects can be available publicly, internally, or privately, at your choice.')
+- own_projects_visitor_empty_message = s_('UserProfile|This user doesn\'t have any personal projects')
+- primary_button_label = _('New project')
+- primary_button_link = new_project_path
+- secondary_button_label = _('Explore groups')
+- secondary_button_link = explore_groups_path
.js-projects-list-holder
- if any_projects?(projects)
@@ -33,4 +44,18 @@
%span &nbsp;you have no access to.
= paginate_collection(projects, remote: remote) unless skip_pagination
- else
- .nothing-here-block No projects found
+ - if @contributed_projects
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path,
+ current_user_empty_message_header: contributed_projects_current_user_empty_message_header,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ secondary_button_label: secondary_button_label,
+ secondary_button_link: secondary_button_link,
+ visitor_empty_message: contributed_projects_visitor_empty_message }
+ - else
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: own_projects_illustration_path,
+ current_user_empty_message_header: own_projects_current_user_empty_message_header,
+ current_user_empty_message_description: own_projects_current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: own_projects_visitor_empty_message }
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 9dde77fccef..90fb067e75d 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -10,23 +10,23 @@
- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
- css_class = '' unless local_assigns[:css_class]
- css_class += " no-description" if project.description.blank? && !show_last_commit_as_description
-- cache_key = project_list_cache_key(project)
+- cache_key = project_list_cache_key(project, pipeline_status: pipeline_status)
- updated_tooltip = time_ago_with_tooltip(project.last_activity_date)
-- css_details_class = compact_mode ? "d-flex flex-column flex-sm-row flex-md-row align-items-sm-center" : "align-items-center flex-md-fill flex-lg-column d-sm-flex d-lg-block"
-- css_controls_class = compact_mode ? "" : "align-items-md-end align-items-lg-center flex-lg-row"
+- css_controls_class = compact_mode ? "" : "flex-lg-row justify-content-lg-between"
+- avatar_container_class = project.creator && use_creator_avatar ? '' : 'rect-avatar'
%li.project-row.d-flex{ class: css_class }
= cache(cache_key) do
- if avatar
- .avatar-container.s64.flex-grow-0.flex-shrink-0
+ .avatar-container.s48.flex-grow-0.flex-shrink-0{ class: avatar_container_class }
= link_to project_path(project), class: dom_class(project) do
- if project.creator && use_creator_avatar
- = image_tag avatar_icon_for_user(project.creator, 64), class: "avatar s65", alt:''
+ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:''
- else
- = project_icon(project, alt: '', class: 'avatar project-avatar s64', width: 64, height: 64)
- .project-details.flex-sm-fill{ class: css_details_class }
- .flex-wrapper.flex-fill
- .d-flex.align-items-center.flex-wrap
+ = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48)
+ .project-details.d-sm-flex.flex-sm-fill.align-items-center
+ .flex-wrapper
+ .d-flex.align-items-center.flex-wrap.project-title
%h2.d-flex.prepend-top-8
= link_to project_path(project), class: 'text-plain' do
%span.project-full-name.append-right-8><
@@ -52,13 +52,13 @@
%span.user-access-role.d-block= Gitlab::Access.human_access(access)
- if show_last_commit_as_description
- .description.d-none.d-sm-block.prepend-top-8.append-right-default
+ .description.d-none.d-sm-block.append-right-default
= link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
- elsif project.description.present?
- .description.d-none.d-sm-block.prepend-top-8.append-right-default
+ .description.d-none.d-sm-block.append-right-default
= markdown_field(project, :description)
- .controls.d-flex.flex-row.flex-sm-column.flex-md-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class }
+ .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0{ class: css_controls_class }
.icon-container.d-flex.align-items-center
- if project.archived
%span.d-flex.icon-wrapper.badge.badge-warning archived
@@ -72,36 +72,21 @@
title: _('Forks'), data: { container: 'body', placement: 'top' } do
= sprite_icon('fork', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.forks_count)
- - if show_merge_request_count?(merge_requests, compact_mode)
+ - if show_merge_request_count?(disabled: !merge_requests, compact_mode: compact_mode)
= link_to project_merge_requests_path(project),
- class: "d-none d-lg-flex align-items-center icon-wrapper merge-requests has-tooltip",
+ class: "d-none d-xl-flex align-items-center icon-wrapper merge-requests has-tooltip",
title: _('Merge Requests'), data: { container: 'body', placement: 'top' } do
= sprite_icon('git-merge', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.open_merge_requests_count)
- - if show_issue_count?(issues, compact_mode)
+ - if show_issue_count?(disabled: !issues, compact_mode: compact_mode)
= link_to project_issues_path(project),
- class: "d-none d-lg-flex align-items-center icon-wrapper issues has-tooltip",
+ class: "d-none d-xl-flex align-items-center icon-wrapper issues has-tooltip",
title: _('Issues'), data: { container: 'body', placement: 'top' } do
= sprite_icon('issues', size: 14, css_class: 'append-right-4')
= number_with_delimiter(project.open_issues_count)
- - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status?
+ - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+ - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
%span.icon-wrapper.pipeline-status
- = render_project_pipeline_status(project.pipeline_status, tooltip_placement: 'top')
+ = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), type: 'commit', tooltip_placement: 'top', path: pipeline_path
.updated-note
%span Updated #{updated_tooltip}
-
- .d-none.d-lg-flex.align-item-stretch
- - unless compact_mode
- - if current_user
- %button.star-button.btn.btn-default.d-flex.align-items-center.star-btn.toggle-star{ type: "button", data: { endpoint: toggle_star_project_path(project, :json) } }
- - if current_user.starred?(project)
- = sprite_icon('star', { css_class: 'icon' })
- %span.starred= s_('ProjectOverview|Unstar')
- - else
- = sprite_icon('star-o', { css_class: 'icon' })
- %span= s_('ProjectOverview|Star')
-
- - else
- = link_to new_user_session_path, class: 'btn btn-default has-tooltip count-badge-button d-flex align-items-center star-btn', title: s_('ProjectOverview|You must sign in to star a project') do
- = sprite_icon('star-o', { css_class: 'icon' })
- %span= s_('ProjectOverview|Star')
diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml
new file mode 100644
index 00000000000..c1f2eaba284
--- /dev/null
+++ b/app/views/shared/projects/_search_bar.html.haml
@@ -0,0 +1,28 @@
+- @sort ||= sort_value_latest_activity
+- project_tab_filter = local_assigns.fetch(:project_tab_filter, "")
+- flex_grow_and_shrink_xs = 'd-flex flex-xs-grow-1 flex-xs-shrink-1 flex-grow-0 flex-shrink-0'
+
+.filtered-search-block.row-content-block.bt-0
+ .filtered-search-wrapper.d-flex.flex-nowrap.flex-column.flex-sm-wrap.flex-sm-row.flex-xl-nowrap
+ - unless project_tab_filter == :starred
+ .filtered-search-nav.mb-2.mb-lg-0{ class: flex_grow_and_shrink_xs }
+ = render 'dashboard/projects/nav', project_tab_filter: project_tab_filter
+ .filtered-search.d-flex.flex-grow-1.flex-shrink-1.w-100.mb-2.mb-lg-0.ml-0{ class: project_tab_filter == :starred ? "extended-filtered-search-box mb-2 mb-lg-0" : "ml-sm-3" }
+ .btn-group.w-100{ role: "group" }
+ .btn-group.w-100{ role: "group" }
+ .filtered-search-box.m-0
+ .filtered-search-box-input-container.pl-2
+ = render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...")
+ %button.btn.btn-secondary{ type: 'submit', form: 'project-filter-form' }
+ = sprite_icon('search', size: 16, css_class: 'search-icon ')
+ .filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs }
+ .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold
+ %span
+ = _("Visibility")
+ = render 'explore/projects/filter', has_label: true
+ .filtered-search-dropdown.flex-row.align-items-center.m-sm-0#filtered-search-sorting-dropdown{ class: flex_grow_and_shrink_xs }
+ .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold
+ %span
+ = _("Sort by")
+ = render 'shared/projects/sort_dropdown'
+
diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml
index 3b5c13ed93a..7c7c0a363ac 100644
--- a/app/views/shared/projects/_search_form.html.haml
+++ b/app/views/shared/projects/_search_form.html.haml
@@ -1,7 +1,10 @@
+- form_field_classes = local_assigns[:admin_view] || !Feature.enabled?(:project_list_filter_bar) ? 'input-short js-projects-list-filter' : ''
+- placeholder = local_assigns[:search_form_placeholder] ? search_form_placeholder : 'Filter by name...'
+
= form_tag filter_projects_path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f|
= search_field_tag :name, params[:name],
- placeholder: 'Filter by name...',
- class: 'project-filter-form-field form-control input-short js-projects-list-filter',
+ placeholder: placeholder,
+ class: "project-filter-form-field form-control #{form_field_classes}",
spellcheck: false,
id: 'project-filter-form-field',
tabindex: "2",
diff --git a/app/views/shared/projects/_sort_dropdown.html.haml b/app/views/shared/projects/_sort_dropdown.html.haml
new file mode 100644
index 00000000000..f5f940db189
--- /dev/null
+++ b/app/views/shared/projects/_sort_dropdown.html.haml
@@ -0,0 +1,39 @@
+- @sort ||= sort_value_latest_activity
+- toggle_text = projects_sort_option_titles[@sort]
+
+.btn-group.w-100{ role: "group" }
+ .btn-group.w-100.dropdown.js-project-filter-dropdown-wrap{ role: "group" }
+ %button#sort-projects-dropdown.btn.btn-default.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
+ = toggle_text
+ = icon('chevron-down')
+ %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable
+ %li.dropdown-header
+ = _("Sort by")
+ - projects_sort_options_hash.each do |value, title|
+ %li
+ = link_to title, filter_projects_path(sort: value), class: ("is-active" if toggle_text == title)
+
+ %li.divider
+ %li
+ = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do
+ = _("Hide archived projects")
+ %li
+ = link_to filter_projects_path(archived: true), class: ("is-active" if Gitlab::Utils.to_boolean(params[:archived])) do
+ = _("Show archived projects")
+ %li
+ = link_to filter_projects_path(archived: 'only'), class: ("is-active" if params[:archived] == 'only') do
+ = _("Show archived projects only")
+
+ - if current_user && @group && @group.shared_projects.present?
+ %li.divider
+ %li
+ = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do
+ = _("All projects")
+ %li
+ = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do
+ = _("Hide shared projects")
+ %li
+ = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do
+ = _("Hide group projects")
+
+ = project_sort_direction_button(@sort)
diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml
index cf9c3055499..2d2382e469a 100644
--- a/app/views/shared/snippets/_form.html.haml
+++ b/app/views/shared/snippets/_form.html.haml
@@ -3,14 +3,14 @@
.snippet-form-holder
= form_for @snippet, url: url,
- html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" },
- data: { markdown_version: @snippet.cached_markdown_version } do |f|
+ html: { class: "snippet-form js-requires-input js-quick-submit common-note-form" } do |f|
= form_errors(@snippet)
.form-group.row
- = f.label :title, class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :title
.col-sm-10
- = f.text_field :title, class: 'form-control', required: true, autofocus: true
+ = f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
= render 'shared/form_elements/description', model: @snippet, project: @project, form: f
@@ -18,11 +18,12 @@
.file-editor
.form-group.row
- = f.label :file_name, "File", class: 'col-form-label col-sm-2'
+ .col-sm-2.col-form-label
+ = f.label :file_name, "File"
.col-sm-10
.file-holder.snippet
.js-file-title.file-title
- = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name'
+ = f.text_field :file_name, placeholder: "Optionally name this file to add code highlighting, e.g. example.rb for Ruby.", class: 'form-control snippet-file-name qa-snippet-file-name'
.file-content.code
%pre#editor= @snippet.content
= f.hidden_field :content, class: 'snippet-file-content'
@@ -32,7 +33,7 @@
.form-actions
- if @snippet.new_record?
- = f.submit 'Create snippet', class: "btn-success btn"
+ = f.submit 'Create snippet', class: "btn-success btn qa-create-snippet-button"
- else
= f.submit 'Save changes', class: "btn-success btn"
diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml
index 10bfc30492a..ebb634fe75f 100644
--- a/app/views/shared/snippets/_header.html.haml
+++ b/app/views/shared/snippets/_header.html.haml
@@ -1,6 +1,6 @@
.detail-page-header
.detail-page-header-body
- .snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
+ .snippet-box.qa-snippet-box.has-tooltip.inline.append-right-5{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } }
%span.sr-only
= visibility_level_label(@snippet.visibility_level)
= visibility_level_icon(@snippet.visibility_level, fw: false)
@@ -17,12 +17,12 @@
= render "snippets/actions"
.snippet-header.limited-header-width
- %h2.snippet-title.prepend-top-0.append-bottom-0
+ %h2.snippet-title.prepend-top-0.append-bottom-0.qa-snippet-title
= markdown_field(@snippet, :title)
- if @snippet.description.present?
- .description
- .wiki
+ .description.qa-snippet-description
+ .md
= markdown_field(@snippet, :description)
%textarea.hidden.js-task-list-field
= @snippet.description
@@ -30,11 +30,11 @@
- if @snippet.updated_at != @snippet.created_at
= edited_time_ago_with_tooltip(@snippet, placement: 'bottom', html_class: 'snippet-edited-ago', exclude_author: true)
- - if public_snippet?
+ - if @snippet.embeddable?
.embed-snippet
.input-group
.input-group-prepend
- %button.btn.btn-svg.embed-toggle.input-group-text{ 'data-toggle': 'dropdown', type: 'button' }
+ %button.btn.btn-svg.embed-toggle.input-group-text.qa-embed-type{ 'data-toggle': 'dropdown', type: 'button' }
%span.js-embed-action= _("Embed")
= sprite_icon('angle-down', size: 12, css_class: 'caret-down')
%ul.dropdown-menu.dropdown-menu-selectable.embed-toggle-list
diff --git a/app/views/shared/snippets/_list.html.haml b/app/views/shared/snippets/_list.html.haml
new file mode 100644
index 00000000000..5d2152eb411
--- /dev/null
+++ b/app/views/shared/snippets/_list.html.haml
@@ -0,0 +1,12 @@
+- remote = local_assigns.fetch(:remote, false)
+- link_project = local_assigns.fetch(:link_project, false)
+
+- if @snippets.exists?
+ .snippets-list-holder
+ %ul.content-list
+ = render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
+
+ = paginate @snippets, theme: 'gitlab', remote: remote
+
+- else
+ .nothing-here-block= s_("SnippetsEmptyState|No snippets found")
diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml
index 5069e2e4ca6..42af97bc6af 100644
--- a/app/views/shared/snippets/_snippet.html.haml
+++ b/app/views/shared/snippets/_snippet.html.haml
@@ -25,7 +25,7 @@
#{snippet.to_reference} &middot;
authored #{time_ago_with_tooltip(snippet.created_at, placement: 'bottom', html_class: 'snippet-created-ago')}
by
- = link_to user_snippets_path(snippet.author) do
+ = link_to user_snippets_path(snippet.author), class: "js-user-link", data: { user_id: snippet.author.id } do
= snippet.author_name
- if link_project && snippet.project_id?
%span.d-none.d-sm-inline-block
diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml
index af9db5f59a8..a5d3e1c8de0 100644
--- a/app/views/shared/tokens/_scopes_form.html.haml
+++ b/app/views/shared/tokens/_scopes_form.html.haml
@@ -4,6 +4,6 @@
- scopes.each do |scope|
%fieldset.form-group.form-check
- = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: 'form-check-input'
+ = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}", class: "form-check-input qa-#{scope}-radio"
= label_tag ("#{prefix}_scopes_#{scope}"), scope, class: 'label-bold form-check-label'
.text-secondary= t scope, scope: [:doorkeeper, :scope_desc]
diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml
index ef8664e6f47..9952f373156 100644
--- a/app/views/snippets/_actions.html.haml
+++ b/app/views/snippets/_actions.html.haml
@@ -7,7 +7,7 @@
- if can?(current_user, :admin_personal_snippet, @snippet)
= link_to snippet_path(@snippet), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-inverted btn-remove", title: _('Delete Snippet') do
= _("Delete")
- = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: _("New snippet") do
+ = link_to new_snippet_path, class: "btn btn-grouped btn-success btn-inverted", title: _("New snippet") do
= _("New snippet")
- if @snippet.submittable_as_spam_by?(current_user)
= link_to _('Submit as spam'), mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: _('Submit as spam')
diff --git a/app/views/snippets/_snippets.html.haml b/app/views/snippets/_snippets.html.haml
index 69d41f8fe5e..dab247da251 100644
--- a/app/views/snippets/_snippets.html.haml
+++ b/app/views/snippets/_snippets.html.haml
@@ -1,10 +1,21 @@
- link_project = local_assigns.fetch(:link_project, false)
+- illustration_path = 'illustrations/profile-page/activity.svg'
+- current_user_empty_message_header = s_('UserProfile|You haven\'t created any snippets.')
+- current_user_empty_message_description = s_('UserProfile|Snippets in GitLab can either be private, internal, or public.')
+- primary_button_label = _('New snippet')
+- primary_button_link = new_snippet_path
+- visitor_empty_message = s_('UserProfile|No snippets found.')
.snippets-list-holder
%ul.content-list
= render partial: 'shared/snippets/snippet', collection: @snippets, locals: { link_project: link_project }
- if @snippets.empty?
%li
- .nothing-here-block= _("Nothing here.")
+ = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: illustration_path,
+ current_user_empty_message_header: current_user_empty_message_header,
+ current_user_empty_message_description: current_user_empty_message_description,
+ primary_button_label: primary_button_label,
+ primary_button_link: primary_button_link,
+ visitor_empty_message: visitor_empty_message }
= paginate @snippets, theme: 'gitlab'
diff --git a/app/views/snippets/index.html.haml b/app/views/snippets/index.html.haml
index 4f418e2381f..f5fe75bed5a 100644
--- a/app/views/snippets/index.html.haml
+++ b/app/views/snippets/index.html.haml
@@ -10,4 +10,4 @@
= link_to user_path(@user) do
= _("%{user_name} profile page") % { user_name: @user.name }
-= render 'snippets'
+= render 'shared/snippets/list'
diff --git a/app/views/snippets/new.html.haml b/app/views/snippets/new.html.haml
index 114c777bdc2..9d462865471 100644
--- a/app/views/snippets/new.html.haml
+++ b/app/views/snippets/new.html.haml
@@ -2,7 +2,7 @@
- @hide_breadcrumbs = true
- page_title _("New Snippet")
-.page-title-holder
+.page-title-holder.d-flex.align-items-center
%h1.page-title= _('New Snippet')
.prepend-top-default
diff --git a/app/views/snippets/notes/_actions.html.haml b/app/views/snippets/notes/_actions.html.haml
index 01b95145937..6e20890a47f 100644
--- a/app/views/snippets/notes/_actions.html.haml
+++ b/app/views/snippets/notes/_actions.html.haml
@@ -3,9 +3,9 @@
.note-actions-item
= link_to '#', title: _('Add reaction'), class: "note-action-button note-emoji-button js-add-award js-note-emoji has-tooltip", data: { position: 'right' } do
= icon('spinner spin')
- %span{ class: 'link-highlight award-control-icon-neutral' }= custom_icon('emoji_slightly_smiling_face')
- %span{ class: 'link-highlight award-control-icon-positive' }= custom_icon('emoji_smiley')
- %span{ class: 'link-highlight award-control-icon-super-positive' }= custom_icon('emoji_smile')
+ %span{ class: 'link-highlight award-control-icon-neutral' }= sprite_icon('slight-smile')
+ %span{ class: 'link-highlight award-control-icon-positive' }= sprite_icon('smiley')
+ %span{ class: 'link-highlight award-control-icon-super-positive' }= sprite_icon('smile')
- if note_editable
.note-actions-item
diff --git a/app/views/users/_groups.html.haml b/app/views/users/_groups.html.haml
index 55799e10a46..6d7a52c7688 100644
--- a/app/views/users/_groups.html.haml
+++ b/app/views/users/_groups.html.haml
@@ -1,5 +1,5 @@
.clearfix
- groups.each do |group|
= link_to group, class: 'profile-groups-avatars inline', title: group.name do
- .avatar-container.s40
+ .avatar-container.rect-avatar.s40
= group_icon(group, class: 'avatar group-avatar s40')
diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml
index 01acbf8eadd..3191eaa1e2c 100644
--- a/app/views/users/calendar_activities.html.haml
+++ b/app/views/users/calendar_activities.html.haml
@@ -9,7 +9,7 @@
%i.fa.fa-clock-o
= event.created_at.to_time.in_time_zone.strftime('%-I:%M%P')
- if event.visible_to_user?(current_user)
- - if event.push?
+ - if event.push_action?
#{event.action_name} #{event.ref_type}
%strong
- commits_path = project_commits_path(event.project, event.ref_name)
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index dd2cd36eac2..6dc61088e65 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -45,7 +45,7 @@
= emoji_icon(@user.status.emoji)
= markdown_field(@user.status, :message)
- .cover-desc.member-date
+ .cover-desc.member-date.cgray
%p
%span.middle-dot-divider
@#{@user.username}
@@ -53,7 +53,7 @@
%span.middle-dot-divider
= s_('Member since %{date}') % { date: @user.created_at.to_date.to_s(:long) }
- .cover-desc
+ .cover-desc.cgray
- unless @user.public_email.blank?
.profile-link-holder.middle-dot-divider
= link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link'
@@ -71,18 +71,18 @@
= icon('twitter-square')
- unless @user.website_url.blank?
.profile-link-holder.middle-dot-divider
- = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'noopener noreferrer nofollow'
+ = link_to @user.short_website_url, @user.full_website_url, class: 'text-link', target: '_blank', rel: 'me noopener noreferrer nofollow'
- unless @user.location.blank?
.profile-link-holder.middle-dot-divider
- = icon('map-marker')
+ = sprite_icon('location', size: 16, css_class: 'vertical-align-sub')
= @user.location
- unless @user.organization.blank?
.profile-link-holder.middle-dot-divider
- = icon('briefcase')
+ = sprite_icon('work', size: 16, css_class: 'vertical-align-sub')
= @user.organization
- if @user.bio.present?
- .cover-desc
+ .cover-desc.cgray
%p.profile-user-bio
= @user.bio
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index bc26b3f8ef2..e4e85de93da 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -6,6 +6,7 @@
- cronjob:gitlab_usage_ping
- cronjob:import_export_project_cleanup
- cronjob:pages_domain_verification_cron
+- cronjob:pages_domain_removal_cron
- cronjob:pipeline_schedule
- cronjob:prune_old_events
- cronjob:remove_expired_group_links
@@ -21,14 +22,19 @@
- cronjob:trending_projects
- cronjob:issue_due_scheduler
- cronjob:prune_web_hook_logs
+- cronjob:schedule_migrate_external_diffs
- gcp_cluster:cluster_install_app
+- gcp_cluster:cluster_patch_app
+- gcp_cluster:cluster_upgrade_app
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:wait_for_cluster_creation
- gcp_cluster:cluster_wait_for_ingress_ip_address
-- gcp_cluster:cluster_platform_configure
+- gcp_cluster:cluster_configure
- gcp_cluster:cluster_project_configure
+- gcp_cluster:clusters_applications_wait_for_uninstall_app
+- gcp_cluster:clusters_applications_uninstall
- github_import_advance_stage
- github_importer:github_import_import_diff_note
@@ -45,6 +51,11 @@
- github_importer:github_import_stage_import_pull_requests
- github_importer:github_import_stage_import_repository
+- hashed_storage:hashed_storage_migrator
+- hashed_storage:hashed_storage_rollbacker
+- hashed_storage:hashed_storage_project_migrate
+- hashed_storage:hashed_storage_project_rollback
+
- mail_scheduler:mail_scheduler_issue_due
- mail_scheduler:mail_scheduler_notification_service
@@ -64,6 +75,7 @@
- pipeline_hooks:build_hooks
- pipeline_hooks:pipeline_hooks
- pipeline_processing:build_finished
+- pipeline_processing:ci_build_prepare
- pipeline_processing:build_queue
- pipeline_processing:build_success
- pipeline_processing:pipeline_process
@@ -74,6 +86,7 @@
- pipeline_processing:ci_build_schedule
- deployment:deployments_success
+- deployment:deployments_finished
- repository_check:repository_check_clear
- repository_check:repository_check_batch
@@ -88,14 +101,18 @@
- object_pool:object_pool_create
- object_pool:object_pool_schedule_join
- object_pool:object_pool_join
+- object_pool:object_pool_destroy
+
+- container_repository:delete_container_repository
+- container_repository:cleanup_container_repository
- default
- mailers # ActionMailer::DeliveryJob.queue_name
- authorized_projects
- background_migration
+- chat_notification
- create_gpg_signature
-- delete_container_repository
- delete_merged_branches
- delete_user
- email_receiver
@@ -107,6 +124,7 @@
- invalid_gpg_signature_update
- irker
- merge
+- migrate_external_diffs
- namespaceless_project_destroy
- new_issue
- new_merge_request
@@ -119,7 +137,6 @@
- project_cache
- project_destroy
- project_export
-- project_migrate_hashed_storage
- project_service
- propagate_service_template
- reactive_caching
@@ -128,9 +145,9 @@
- repository_fork
- repository_import
- repository_remove_remote
-- storage_migrator
- system_hook_push
- update_merge_requests
+- update_project_statistics
- upload_checksum
- web_hook
- repository_update_remote_mirror
@@ -139,3 +156,5 @@
- detect_repository_languages
- repository_cleanup
- delete_stored_files
+- import_issues_csv
+- project_daily_statistics
diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb
index 61d866b1f02..8e2a18a8fd8 100644
--- a/app/workers/build_finished_worker.rb
+++ b/app/workers/build_finished_worker.rb
@@ -9,14 +9,28 @@ class BuildFinishedWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(build_id)
Ci::Build.find_by(id: build_id).try do |build|
- # We execute that in sync as this access the files in order to access local file, and reduce IO
- BuildTraceSectionsWorker.new.perform(build.id)
- BuildCoverageWorker.new.perform(build.id)
-
- # We execute that async as this are two independent operations that can be executed after TraceSections and Coverage
- BuildHooksWorker.perform_async(build.id)
- ArchiveTraceWorker.perform_async(build.id)
+ process_build(build)
end
end
# rubocop: enable CodeReuse/ActiveRecord
+
+ private
+
+ # Processes a single CI build that has finished.
+ #
+ # This logic resides in a separate method so that EE can extend it more
+ # easily.
+ #
+ # @param [Ci::Build] build The build to process.
+ def process_build(build)
+ # We execute these in sync to reduce IO.
+ BuildTraceSectionsWorker.new.perform(build.id)
+ BuildCoverageWorker.new.perform(build.id)
+
+ # We execute these async as these are independent operations.
+ BuildHooksWorker.perform_async(build.id)
+ ArchiveTraceWorker.perform_async(build.id)
+ ExpirePipelineCacheWorker.perform_async(build.pipeline_id)
+ ChatNotificationWorker.perform_async(build.id) if build.pipeline.chat?
+ end
end
diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb
new file mode 100644
index 00000000000..25a306e94d8
--- /dev/null
+++ b/app/workers/chat_notification_worker.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+class ChatNotificationWorker
+ include ApplicationWorker
+
+ RESCHEDULE_INTERVAL = 2.seconds
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(build_id)
+ Ci::Build.find_by(id: build_id).try do |build|
+ send_response(build)
+ end
+ rescue Gitlab::Chat::Output::MissingBuildSectionError
+ # The creation of traces and sections appears to be eventually consistent.
+ # As a result it's possible for us to run the above code before the trace
+ # sections are present. To better handle such cases we'll just reschedule
+ # the job instead of producing an error.
+ self.class.perform_in(RESCHEDULE_INTERVAL, build_id)
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def send_response(build)
+ Gitlab::Chat::Responder.responder_for(build).try do |responder|
+ if build.success?
+ output = Gitlab::Chat::Output.new(build)
+
+ responder.success(output.to_s)
+ else
+ responder.failure
+ end
+ end
+ end
+end
diff --git a/app/workers/ci/build_prepare_worker.rb b/app/workers/ci/build_prepare_worker.rb
new file mode 100644
index 00000000000..1a35a74ae53
--- /dev/null
+++ b/app/workers/ci/build_prepare_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Ci
+ class BuildPrepareWorker
+ include ApplicationWorker
+ include PipelineQueue
+
+ queue_namespace :pipeline_processing
+
+ def perform(build_id)
+ Ci::Build.find_by_id(build_id).try do |build|
+ Ci::PrepareBuildService.new(build).execute
+ end
+ end
+ end
+end
diff --git a/app/workers/cleanup_container_repository_worker.rb b/app/workers/cleanup_container_repository_worker.rb
new file mode 100644
index 00000000000..974ee8c8146
--- /dev/null
+++ b/app/workers/cleanup_container_repository_worker.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+class CleanupContainerRepositoryWorker
+ include ApplicationWorker
+ include ExclusiveLeaseGuard
+
+ queue_namespace :container_repository
+
+ LEASE_TIMEOUT = 1.hour
+
+ attr_reader :container_repository, :current_user
+
+ def perform(current_user_id, container_repository_id, params)
+ @current_user = User.find_by_id(current_user_id)
+ @container_repository = ContainerRepository.find_by_id(container_repository_id)
+
+ return unless valid?
+
+ try_obtain_lease do
+ Projects::ContainerRepository::CleanupTagsService
+ .new(project, current_user, params)
+ .execute(container_repository)
+ end
+ end
+
+ private
+
+ def valid?
+ current_user && container_repository && project
+ end
+
+ def project
+ container_repository&.project
+ end
+
+ # For ExclusiveLeaseGuard concern
+ def lease_key
+ @lease_key ||= "container_repository:cleanup_tags:#{container_repository.id}"
+ end
+
+ # For ExclusiveLeaseGuard concern
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+
+ # For ExclusiveLeaseGuard concern
+ def lease_release?
+ # we don't allow to execute this worker
+ # more often than LEASE_TIMEOUT
+ # for given container repository
+ false
+ end
+end
diff --git a/app/workers/cluster_configure_worker.rb b/app/workers/cluster_configure_worker.rb
new file mode 100644
index 00000000000..6f64b7ea0ab
--- /dev/null
+++ b/app/workers/cluster_configure_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class ClusterConfigureWorker
+ include ApplicationWorker
+ include ClusterQueue
+
+ def perform(cluster_id)
+ Clusters::Cluster.managed.find_by_id(cluster_id).try do |cluster|
+ if cluster.project_type?
+ Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster)
+ end
+ end
+ end
+end
diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb
new file mode 100644
index 00000000000..0549e81ed05
--- /dev/null
+++ b/app/workers/cluster_patch_app_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ClusterPatchAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::PatchService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/cluster_platform_configure_worker.rb b/app/workers/cluster_platform_configure_worker.rb
deleted file mode 100644
index aa7570caa79..00000000000
--- a/app/workers/cluster_platform_configure_worker.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-
-class ClusterPlatformConfigureWorker
- include ApplicationWorker
- include ClusterQueue
-
- def perform(cluster_id)
- Clusters::Cluster.find_by_id(cluster_id).try do |cluster|
- Clusters::RefreshService.create_or_update_namespaces_for_cluster(cluster)
- end
- end
-end
diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb
index 3d5894b73ec..926ae2b7286 100644
--- a/app/workers/cluster_provision_worker.rb
+++ b/app/workers/cluster_provision_worker.rb
@@ -10,7 +10,7 @@ class ClusterProvisionWorker
Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp?
end
- ClusterPlatformConfigureWorker.perform_async(cluster.id) if cluster.user?
+ ClusterConfigureWorker.perform_async(cluster.id) if cluster.user?
end
end
end
diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb
new file mode 100644
index 00000000000..d1a538859b4
--- /dev/null
+++ b/app/workers/cluster_upgrade_app_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ClusterUpgradeAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::UpgradeService.new(app).execute
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb
new file mode 100644
index 00000000000..85e8ecc4ad5
--- /dev/null
+++ b/app/workers/clusters/applications/uninstall_worker.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class UninstallWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::UninstallService.new(app).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
new file mode 100644
index 00000000000..163c99d3c3c
--- /dev/null
+++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Applications
+ class WaitForUninstallAppWorker
+ include ApplicationWorker
+ include ClusterQueue
+ include ClusterApplications
+
+ INTERVAL = 10.seconds
+ TIMEOUT = 20.minutes
+
+ def perform(app_name, app_id)
+ find_application(app_name, app_id) do |app|
+ Clusters::Applications::CheckUninstallProgressService.new(app).execute
+ end
+ end
+ end
+ end
+end
diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb
index d64c2f82a09..25c3a945077 100644
--- a/app/workers/concerns/application_worker.rb
+++ b/app/workers/concerns/application_worker.rb
@@ -53,7 +53,7 @@ module ApplicationWorker
schedule = now + delay.to_i
if schedule <= now
- raise ArgumentError, 'The schedule time must be in the future!'
+ raise ArgumentError, _('The schedule time must be in the future!')
end
Sidekiq::Client.push_bulk('class' => self, 'args' => args_list, 'at' => schedule)
diff --git a/app/workers/concerns/waitable_worker.rb b/app/workers/concerns/waitable_worker.rb
index 27b94a82444..17946bbc5ca 100644
--- a/app/workers/concerns/waitable_worker.rb
+++ b/app/workers/concerns/waitable_worker.rb
@@ -25,11 +25,9 @@ module WaitableWorker
failed = []
args_list.each do |args|
- begin
- new.perform(*args)
- rescue
- failed << args
- end
+ new.perform(*args)
+ rescue
+ failed << args
end
bulk_perform_async(failed) if failed.present?
diff --git a/app/workers/create_gpg_signature_worker.rb b/app/workers/create_gpg_signature_worker.rb
index 49c7a403838..7fac7822cf7 100644
--- a/app/workers/create_gpg_signature_worker.rb
+++ b/app/workers/create_gpg_signature_worker.rb
@@ -5,8 +5,8 @@ class CreateGpgSignatureWorker
# rubocop: disable CodeReuse/ActiveRecord
def perform(commit_shas, project_id)
- # Older versions of GitPushService may push a single commit ID on the stack.
- # We need this to be backwards compatible.
+ # Older versions of Git::BranchPushService may push a single commit ID on
+ # the stack. We need this to be backwards compatible.
commit_shas = Array(commit_shas)
return if commit_shas.empty?
@@ -20,11 +20,9 @@ class CreateGpgSignatureWorker
# This calculates and caches the signature in the database
commits.each do |commit|
- begin
- Gitlab::Gpg::Commit.new(commit).signature
- rescue => e
- Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}")
- end
+ Gitlab::Gpg::Commit.new(commit).signature
+ rescue => e
+ Rails.logger.error("Failed to create signature for commit #{commit.id}. Error: #{e.message}")
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/delete_container_repository_worker.rb b/app/workers/delete_container_repository_worker.rb
index e8fe9d82797..42e66513ff1 100644
--- a/app/workers/delete_container_repository_worker.rb
+++ b/app/workers/delete_container_repository_worker.rb
@@ -4,6 +4,8 @@ class DeleteContainerRepositoryWorker
include ApplicationWorker
include ExclusiveLeaseGuard
+ queue_namespace :container_repository
+
LEASE_TIMEOUT = 1.hour
attr_reader :container_repository
diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb
new file mode 100644
index 00000000000..c9d448d5d18
--- /dev/null
+++ b/app/workers/deployments/finished_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Deployments
+ class FinishedWorker
+ include ApplicationWorker
+
+ queue_namespace :deployment
+
+ def perform(deployment_id)
+ Deployment.find_by_id(deployment_id).try(:execute_hooks)
+ end
+ end
+end
diff --git a/app/workers/detect_repository_languages_worker.rb b/app/workers/detect_repository_languages_worker.rb
index 64bc9776d48..838c3be78f0 100644
--- a/app/workers/detect_repository_languages_worker.rb
+++ b/app/workers/detect_repository_languages_worker.rb
@@ -12,13 +12,12 @@ class DetectRepositoryLanguagesWorker
attr_reader :project
# rubocop: disable CodeReuse/ActiveRecord
- def perform(project_id, user_id)
+ def perform(project_id, user_id = nil)
@project = Project.find_by(id: project_id)
- user = User.find_by(id: user_id)
- return unless project && user
+ return unless project
try_obtain_lease do
- ::Projects::DetectRepositoryLanguagesService.new(project, user).execute
+ ::Projects::DetectRepositoryLanguagesService.new(project).execute
end
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb
index bf637f82df2..c4bcda2da16 100644
--- a/app/workers/email_receiver_worker.rb
+++ b/app/workers/email_receiver_worker.rb
@@ -24,22 +24,22 @@ class EmailReceiverWorker
reason =
case error
when Gitlab::Email::UnknownIncomingEmail
- "We couldn't figure out what the email is for. Please create your issue or comment through the web interface."
+ s_("EmailError|We couldn't figure out what the email is for. Please create your issue or comment through the web interface.")
when Gitlab::Email::SentNotificationNotFoundError
- "We couldn't figure out what the email is in reply to. Please create your comment through the web interface."
+ s_("EmailError|We couldn't figure out what the email is in reply to. Please create your comment through the web interface.")
when Gitlab::Email::ProjectNotFound
- "We couldn't find the project. Please check if there's any typo."
+ s_("EmailError|We couldn't find the project. Please check if there's any typo.")
when Gitlab::Email::EmptyEmailError
can_retry = true
- "It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies."
+ s_("EmailError|It appears that the email is blank. Make sure your reply is at the top of the email, we can't process inline replies.")
when Gitlab::Email::UserNotFoundError
- "We couldn't figure out what user corresponds to the email. Please create your comment through the web interface."
+ s_("EmailError|We couldn't figure out what user corresponds to the email. Please create your comment through the web interface.")
when Gitlab::Email::UserBlockedError
- "Your account has been blocked. If you believe this is in error, contact a staff member."
+ s_("EmailError|Your account has been blocked. If you believe this is in error, contact a staff member.")
when Gitlab::Email::UserNotAuthorizedError
- "You are not allowed to perform this action. If you believe this is in error, contact a staff member."
+ s_("EmailError|You are not allowed to perform this action. If you believe this is in error, contact a staff member.")
when Gitlab::Email::NoteableNotFoundError
- "The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member."
+ s_("EmailError|The thread you are replying to no longer exists, perhaps it was deleted? If you believe this is in error, contact a staff member.")
when Gitlab::Email::InvalidAttachment
error.message
when Gitlab::Email::InvalidRecordError
diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb
index 17ad1d5ab88..ed3e354e4c2 100644
--- a/app/workers/emails_on_push_worker.rb
+++ b/app/workers/emails_on_push_worker.rb
@@ -52,24 +52,22 @@ class EmailsOnPushWorker
end
valid_recipients(recipients).each do |recipient|
- begin
- send_email(
- recipient,
- project_id,
- author_id: author_id,
- ref: ref,
- action: action,
- compare: compare,
- reverse_compare: reverse_compare,
- diff_refs: diff_refs,
- send_from_committer_email: send_from_committer_email,
- disable_diffs: disable_diffs
- )
-
- # These are input errors and won't be corrected even if Sidekiq retries
- rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
- logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
- end
+ send_email(
+ recipient,
+ project_id,
+ author_id: author_id,
+ ref: ref,
+ action: action,
+ compare: compare,
+ reverse_compare: reverse_compare,
+ diff_refs: diff_refs,
+ send_from_committer_email: send_from_committer_email,
+ disable_diffs: disable_diffs
+ )
+
+ # These are input errors and won't be corrected even if Sidekiq retries
+ rescue Net::SMTPFatalError, Net::SMTPSyntaxError => e
+ logger.info("Failed to send e-mail for project '#{project.full_name}' to #{recipient}: #{e}")
end
ensure
@email = nil
diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb
index dce812d1ae2..251e95c68d5 100644
--- a/app/workers/expire_build_artifacts_worker.rb
+++ b/app/workers/expire_build_artifacts_worker.rb
@@ -4,8 +4,20 @@ class ExpireBuildArtifactsWorker
include ApplicationWorker
include CronjobQueue
- # rubocop: disable CodeReuse/ActiveRecord
def perform
+ if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true)
+ perform_efficient_artifacts_removal
+ else
+ perform_legacy_artifacts_removal
+ end
+ end
+
+ def perform_efficient_artifacts_removal
+ Ci::DestroyExpiredJobArtifactsService.new.execute
+ end
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform_legacy_artifacts_removal
Rails.logger.info 'Scheduling removal of build artifacts'
build_ids = Ci::Build.with_expired_artifacts.pluck(:id)
diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb
index c96e8a0379b..78e68d7bf46 100644
--- a/app/workers/expire_pipeline_cache_worker.rb
+++ b/app/workers/expire_pipeline_cache_worker.rb
@@ -11,44 +11,7 @@ class ExpirePipelineCacheWorker
pipeline = Ci::Pipeline.find_by(id: pipeline_id)
return unless pipeline
- project = pipeline.project
- store = Gitlab::EtagCaching::Store.new
-
- store.touch(project_pipelines_path(project))
- store.touch(project_pipeline_path(project, pipeline))
- store.touch(commit_pipelines_path(project, pipeline.commit)) unless pipeline.commit.nil?
- store.touch(new_merge_request_pipelines_path(project))
- each_pipelines_merge_request_path(project, pipeline) do |path|
- store.touch(path)
- end
-
- Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(pipeline)
+ Ci::ExpirePipelineCacheService.new.execute(pipeline)
end
# rubocop: enable CodeReuse/ActiveRecord
-
- private
-
- def project_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_pipelines_path(project, format: :json)
- end
-
- def project_pipeline_path(project, pipeline)
- Gitlab::Routing.url_helpers.project_pipeline_path(project, pipeline, format: :json)
- end
-
- def commit_pipelines_path(project, commit)
- Gitlab::Routing.url_helpers.pipelines_project_commit_path(project, commit.id, format: :json)
- end
-
- def new_merge_request_pipelines_path(project)
- Gitlab::Routing.url_helpers.project_new_merge_request_path(project, format: :json)
- end
-
- def each_pipelines_merge_request_path(project, pipeline)
- pipeline.all_merge_requests.each do |merge_request|
- path = Gitlab::Routing.url_helpers.pipelines_project_merge_request_path(project, merge_request, format: :json)
-
- yield(path)
- end
- end
end
diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb
index d3628b23189..489d6215774 100644
--- a/app/workers/git_garbage_collect_worker.rb
+++ b/app/workers/git_garbage_collect_worker.rb
@@ -23,12 +23,15 @@ class GitGarbageCollectWorker
end
task = task.to_sym
+
+ ::Projects::GitDeduplicationService.new(project).execute
+
gitaly_call(task, project.repository.raw_repository)
# Refresh the branch cache in case garbage collection caused a ref lookup to fail
flush_ref_caches(project) if task == :gc
- project.repository.expire_statistics_caches
+ project.repository.expire_statistics_caches if task != :pack_refs
# In case pack files are deleted, release libgit2 cache and open file
# descriptors ASAP instead of waiting for Ruby garbage collection
@@ -57,7 +60,12 @@ class GitGarbageCollectWorker
## `repository` has to be a Gitlab::Git::Repository
def gitaly_call(task, repository)
- client = Gitlab::GitalyClient::RepositoryService.new(repository)
+ client = if task == :pack_refs
+ Gitlab::GitalyClient::RefService.new(repository)
+ else
+ Gitlab::GitalyClient::RepositoryService.new(repository)
+ end
+
case task
when :gc
client.garbage_collect(bitmaps_enabled?)
@@ -65,6 +73,8 @@ class GitGarbageCollectWorker
client.repack_full(bitmaps_enabled?)
when :incremental_repack
client.repack_incremental
+ when :pack_refs
+ client.pack_refs
end
rescue GRPC::NotFound => e
Gitlab::GitLogger.error("#{__method__} failed:\nRepository not found")
diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb
new file mode 100644
index 00000000000..816e0504db6
--- /dev/null
+++ b/app/workers/hashed_storage/base_worker.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class BaseWorker
+ include ExclusiveLeaseGuard
+
+ LEASE_TIMEOUT = 30.seconds.to_i
+ LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker'.freeze
+
+ protected
+
+ def lease_key
+ # we share the same lease key for both migration and rollback so they don't run simultaneously
+ "#{LEASE_KEY_SEGMENT}:#{project_id}"
+ end
+
+ def lease_timeout
+ LEASE_TIMEOUT
+ end
+ end
+end
diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb
new file mode 100644
index 00000000000..49e347d4060
--- /dev/null
+++ b/app/workers/hashed_storage/migrator_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class MigratorWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ # @param [Integer] start initial ID of the batch
+ # @param [Integer] finish last ID of the batch
+ def perform(start, finish)
+ migrator = Gitlab::HashedStorage::Migrator.new
+ migrator.bulk_migrate(start: start, finish: finish)
+ end
+ end
+end
diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb
new file mode 100644
index 00000000000..f00a459a097
--- /dev/null
+++ b/app/workers/hashed_storage/project_migrate_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class ProjectMigrateWorker < BaseWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ attr_reader :project_id
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, old_disk_path = nil)
+ @project_id = project_id # we need to set this in order to create the lease_key
+
+ try_obtain_lease do
+ project = Project.without_deleted.find_by(id: project_id)
+ break unless project
+
+ old_disk_path ||= project.disk_path
+
+ ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb
new file mode 100644
index 00000000000..55e1d7ab23e
--- /dev/null
+++ b/app/workers/hashed_storage/project_rollback_worker.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class ProjectRollbackWorker < BaseWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ attr_reader :project_id
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, old_disk_path = nil)
+ @project_id = project_id # we need to set this in order to create the lease_key
+
+ try_obtain_lease do
+ project = Project.without_deleted.find_by(id: project_id)
+ break unless project
+
+ old_disk_path ||= project.disk_path
+
+ ::Projects::HashedStorage::RollbackService.new(project, old_disk_path, logger: logger).execute
+ end
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+end
diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb
new file mode 100644
index 00000000000..a4da8443787
--- /dev/null
+++ b/app/workers/hashed_storage/rollbacker_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module HashedStorage
+ class RollbackerWorker
+ include ApplicationWorker
+
+ queue_namespace :hashed_storage
+
+ # @param [Integer] start initial ID of the batch
+ # @param [Integer] finish last ID of the batch
+ def perform(start, finish)
+ migrator = Gitlab::HashedStorage::Migrator.new
+ migrator.bulk_rollback(start: start, finish: finish)
+ end
+ end
+end
diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb
new file mode 100644
index 00000000000..d44fdfec8ae
--- /dev/null
+++ b/app/workers/import_issues_csv_worker.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class ImportIssuesCsvWorker
+ include ApplicationWorker
+
+ sidekiq_retries_exhausted do |job|
+ Upload.find(job['args'][2]).destroy
+ end
+
+ def perform(current_user_id, project_id, upload_id)
+ @user = User.find(current_user_id)
+ @project = Project.find(project_id)
+ @upload = Upload.find(upload_id)
+
+ importer = Issues::ImportCsvService.new(@user, @project, @upload.build_uploader)
+ importer.execute
+
+ @upload.destroy
+ end
+end
diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb
index 4726e416182..421fbf04e28 100644
--- a/app/workers/mail_scheduler/notification_service_worker.rb
+++ b/app/workers/mail_scheduler/notification_service_worker.rb
@@ -8,14 +8,64 @@ module MailScheduler
include MailSchedulerQueue
def perform(meth, *args)
- deserialized_args = ActiveJob::Arguments.deserialize(args)
+ check_arguments!(args)
+ deserialized_args = ActiveJob::Arguments.deserialize(args)
notification_service.public_send(meth, *deserialized_args) # rubocop:disable GitlabSecurity/PublicSend
rescue ActiveJob::DeserializationError
+ # No-op.
+ # This exception gets raised when an argument
+ # is correct (deserializeable), but it still cannot be deserialized.
+ # This can happen when an object has been deleted after
+ # rails passes this job to sidekiq, but before
+ # sidekiq gets it for execution.
+ # In this case just do nothing.
end
def self.perform_async(*args)
- super(*ActiveJob::Arguments.serialize(args))
+ super(*Arguments.serialize(args))
+ end
+
+ private
+
+ # If an argument is in the ActiveJob::Arguments::TYPE_WHITELIST list,
+ # it means the argument cannot be deserialized.
+ # Which means there's something wrong with our code.
+ def check_arguments!(args)
+ args.each do |arg|
+ if arg.class.in?(ActiveJob::Arguments::TYPE_WHITELIST)
+ raise(ArgumentError, "Argument `#{arg}` cannot be deserialized because of its type")
+ end
+ end
+ end
+
+ # Permit ActionController::Parameters for serializable Hash
+ #
+ # Port of
+ # https://github.com/rails/rails/commit/945fdd76925c9f615bf016717c4c8db2b2955357#diff-fc90ec41ef75be8b2259526fe1a8b663
+ module Arguments
+ include ActiveJob::Arguments
+ extend self
+
+ private
+
+ def serialize_argument(argument)
+ case argument
+ when -> (arg) { arg.respond_to?(:permitted?) }
+ serialize_hash(argument.to_h).tap do |result|
+ result[WITH_INDIFFERENT_ACCESS_KEY] = serialize_argument(true)
+ end
+ else
+ super
+ end
+ end
+ end
+
+ # Make sure we remove this patch starting with Rails 6.0.
+ if Rails.version.start_with?('6.0')
+ raise <<~MSG
+ Please remove the patch `Arguments` module and use `ActiveJob::Arguments` again.
+ MSG
end
end
end
diff --git a/app/workers/migrate_external_diffs_worker.rb b/app/workers/migrate_external_diffs_worker.rb
new file mode 100644
index 00000000000..debac97af2c
--- /dev/null
+++ b/app/workers/migrate_external_diffs_worker.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: false
+
+class MigrateExternalDiffsWorker
+ include ApplicationWorker
+
+ def perform(merge_request_diff_id)
+ diff = MergeRequestDiff.find_by_id(merge_request_diff_id)
+ return unless diff
+
+ MergeRequests::MigrateExternalDiffsService.new(diff).execute
+ end
+end
diff --git a/app/workers/namespaceless_project_destroy_worker.rb b/app/workers/namespaceless_project_destroy_worker.rb
index 2965f3b1150..f6e98746055 100644
--- a/app/workers/namespaceless_project_destroy_worker.rb
+++ b/app/workers/namespaceless_project_destroy_worker.rb
@@ -17,7 +17,7 @@ class NamespacelessProjectDestroyWorker
return
end
- return if project.namespace # Reject doing anything for projects that *do* have a namespace
+ return if project.namespace # Reject doing anything for projects that *do* have a namespace
project.team.truncate
diff --git a/app/workers/object_pool/destroy_worker.rb b/app/workers/object_pool/destroy_worker.rb
new file mode 100644
index 00000000000..ca00d467d9b
--- /dev/null
+++ b/app/workers/object_pool/destroy_worker.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module ObjectPool
+ class DestroyWorker
+ include ApplicationWorker
+ include ObjectPoolQueue
+
+ def perform(pool_repository_id)
+ pool = PoolRepository.find_by_id(pool_repository_id)
+ return unless pool&.obsolete?
+
+ pool.delete_object_pool
+ pool.destroy
+ end
+ end
+end
diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb
index 07676011b2a..9c5161fd55a 100644
--- a/app/workers/object_pool/join_worker.rb
+++ b/app/workers/object_pool/join_worker.rb
@@ -5,14 +5,13 @@ module ObjectPool
include ApplicationWorker
include ObjectPoolQueue
- def perform(pool_id, project_id)
- pool = PoolRepository.find_by_id(pool_id)
- return unless pool&.joinable?
-
+ # The use of pool id is deprecated. Keeping the argument allows old jobs to
+ # still be performed.
+ def perform(_pool_id, project_id)
project = Project.find_by_id(project_id)
- return unless project
+ return unless project&.pool_repository&.joinable?
- pool.link_repository(project.repository)
+ project.link_pool_repository
Projects::HousekeepingService.new(project).execute
end
diff --git a/app/workers/object_storage/migrate_uploads_worker.rb b/app/workers/object_storage/migrate_uploads_worker.rb
index fe5d27b087d..12400d4e025 100644
--- a/app/workers/object_storage/migrate_uploads_worker.rb
+++ b/app/workers/object_storage/migrate_uploads_worker.rb
@@ -20,7 +20,7 @@ module ObjectStorage
end
def to_s
- success? ? "Migration successful." : "Error while migrating #{upload.id}: #{error.message}"
+ success? ? _("Migration successful.") : _("Error while migrating %{upload_id}: %{error_message}") % { upload_id: upload.id, error_message: error.message }
end
end
@@ -47,7 +47,7 @@ module ObjectStorage
end
def header(success, failures)
- "Migrated #{success.count}/#{success.count + failures.count} files."
+ _("Migrated %{success_count}/%{total_count} files.") % { success_count: success.count, total_count: success.count + failures.count }
end
def failures(failures)
@@ -75,9 +75,9 @@ module ObjectStorage
model_types = uploads.map(&:model_type).uniq
model_has_mount = mounted_as.nil? || model_class.uploaders[mounted_as] == uploader_class
- raise(SanityCheckError, "Multiple uploaders found: #{uploader_types}") unless uploader_types.count == 1
- raise(SanityCheckError, "Multiple model types found: #{model_types}") unless model_types.count == 1
- raise(SanityCheckError, "Mount point #{mounted_as} not found in #{model_class}.") unless model_has_mount
+ raise(SanityCheckError, _("Multiple uploaders found: %{uploader_types}") % { uploader_types: uploader_types }) unless uploader_types.count == 1
+ raise(SanityCheckError, _("Multiple model types found: %{model_types}") % { model_types: model_types }) unless model_types.count == 1
+ raise(SanityCheckError, _("Mount point %{mounted_as} not found in %{model_class}.") % { mounted_as: mounted_as, model_class: model_class }) unless model_has_mount
end
# rubocop: disable CodeReuse/ActiveRecord
@@ -110,9 +110,9 @@ module ObjectStorage
return if args.count == 4
case args.count
- when 3 then raise SanityCheckError, "Job is missing the `model_type` argument."
+ when 3 then raise SanityCheckError, _("Job is missing the `model_type` argument.")
else
- raise SanityCheckError, "Job has wrong arguments format."
+ raise SanityCheckError, _("Job has wrong arguments format.")
end
end
@@ -126,11 +126,9 @@ module ObjectStorage
def process_uploader(uploader)
MigrationResult.new(uploader.upload).tap do |result|
- begin
- uploader.migrate!(@to_store)
- rescue => e
- result.error = e
- end
+ uploader.migrate!(@to_store)
+ rescue => e
+ result.error = e
end
end
end
diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb
new file mode 100644
index 00000000000..79f38e1b89f
--- /dev/null
+++ b/app/workers/pages_domain_removal_cron_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class PagesDomainRemovalCronWorker
+ include ApplicationWorker
+ include CronjobQueue
+
+ def perform
+ PagesDomain.for_removal.find_each do |domain|
+ domain.destroy!
+ rescue => e
+ Raven.capture_exception(e)
+ end
+ end
+end
diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb
index c2fbfd2b3a5..0ddad43b8d5 100644
--- a/app/workers/pipeline_metrics_worker.rb
+++ b/app/workers/pipeline_metrics_worker.rb
@@ -30,6 +30,6 @@ class PipelineMetricsWorker
# rubocop: enable CodeReuse/ActiveRecord
def merge_requests(pipeline)
- pipeline.merge_requests.map(&:id)
+ pipeline.merge_requests_as_head_pipeline.map(&:id)
end
end
diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb
index ac4e9710f33..8a9ee7808e4 100644
--- a/app/workers/pipeline_schedule_worker.rb
+++ b/app/workers/pipeline_schedule_worker.rb
@@ -3,20 +3,25 @@
class PipelineScheduleWorker
include ApplicationWorker
include CronjobQueue
+ include ::Gitlab::ExclusiveLeaseHelpers
+
+ EXCLUSIVE_LOCK_KEY = 'pipeline_schedules:run:lock'
+ LOCK_TIMEOUT = 50.minutes
# rubocop: disable CodeReuse/ActiveRecord
def perform
- Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
- .preload(:owner, :project).find_each do |schedule|
- begin
+ in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do
+ Ci::PipelineSchedule.active.where("next_run_at < ?", Time.now)
+ .preload(:owner, :project).find_each do |schedule|
+
+ schedule.schedule_next_run!
+
Ci::CreatePipelineService.new(schedule.project,
schedule.owner,
ref: schedule.ref)
.execute!(:schedule, ignore_skip_ci: true, save_on_errors: true, schedule: schedule)
rescue => e
error(schedule, e)
- ensure
- schedule.schedule_next_run!
end
end
end
diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb
index 72a1733a2a8..9a9c0c9d803 100644
--- a/app/workers/post_receive.rb
+++ b/app/workers/post_receive.rb
@@ -3,8 +3,10 @@
class PostReceive
include ApplicationWorker
- def perform(gl_repository, identifier, changes)
- project, is_wiki = Gitlab::GlRepository.parse(gl_repository)
+ PIPELINE_PROCESS_LIMIT = 4
+
+ def perform(gl_repository, identifier, changes, push_options = {})
+ project, repo_type = Gitlab::GlRepository.parse(gl_repository)
if project.nil?
log("Triggered hook for non-existing project with gl_repository \"#{gl_repository}\"")
@@ -15,12 +17,14 @@ class PostReceive
# Use Sidekiq.logger so arguments can be correlated with execution
# time and thread ID's.
Sidekiq.logger.info "changes: #{changes.inspect}" if ENV['SIDEKIQ_LOG_ARGUMENTS']
- post_received = Gitlab::GitPostReceive.new(project, identifier, changes)
+ post_received = Gitlab::GitPostReceive.new(project, identifier, changes, push_options)
- if is_wiki
+ if repo_type.wiki?
process_wiki_changes(post_received)
- else
+ elsif repo_type.project?
process_project_changes(post_received)
+ else
+ # Other repos don't have hooks for now
end
end
@@ -36,11 +40,24 @@ class PostReceive
return false
end
- post_received.changes_refs do |oldrev, newrev, ref|
- if Gitlab::Git.tag_ref?(ref)
- GitTagPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute
- elsif Gitlab::Git.branch_ref?(ref)
- GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute
+ post_received.enum_for(:changes_refs).with_index do |(oldrev, newrev, ref), index|
+ service_klass =
+ if Gitlab::Git.tag_ref?(ref)
+ Git::TagPushService
+ elsif Gitlab::Git.branch_ref?(ref)
+ Git::BranchPushService
+ end
+
+ if service_klass
+ service_klass.new(
+ post_received.project,
+ @user,
+ oldrev: oldrev,
+ newrev: newrev,
+ ref: ref,
+ push_options: post_received.push_options,
+ create_pipelines: index < PIPELINE_PROCESS_LIMIT || Feature.enabled?(:git_push_create_all_pipelines, post_received.project)
+ ).execute
end
changes << Gitlab::DataBuilder::Repository.single_change(oldrev, newrev, ref)
diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb
index 29a7f8e691a..3efb5343a96 100644
--- a/app/workers/process_commit_worker.rb
+++ b/app/workers/process_commit_worker.rb
@@ -48,7 +48,7 @@ class ProcessCommitWorker
# Issues::CloseService#execute.
IssueCollection.new(issues).updatable_by_user(user).each do |issue|
Issues::CloseService.new(project, author)
- .close_issue(issue, commit: commit)
+ .close_issue(issue, closed_via: commit)
end
end
diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb
index d27b5e62574..b2e0701008a 100644
--- a/app/workers/project_cache_worker.rb
+++ b/app/workers/project_cache_worker.rb
@@ -18,7 +18,7 @@ class ProjectCacheWorker
return unless project && project.repository.exists?
- update_statistics(project, statistics.map(&:to_sym))
+ update_statistics(project, statistics)
project.repository.refresh_method_caches(files.map(&:to_sym))
@@ -26,19 +26,28 @@ class ProjectCacheWorker
end
# rubocop: enable CodeReuse/ActiveRecord
+ # NOTE: triggering both an immediate update and one in 15 minutes if we
+ # successfully obtain the lease. That way, we only need to wait for the
+ # statistics to become accurate if they were already updated once in the
+ # last 15 minutes.
def update_statistics(project, statistics = [])
- return unless try_obtain_lease_for(project.id, :update_statistics)
+ return if Gitlab::Database.read_only?
+ return unless try_obtain_lease_for(project.id, statistics)
- Rails.logger.info("Updating statistics for project #{project.id}")
+ Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
- project.statistics.refresh!(only: statistics)
+ UpdateProjectStatisticsWorker.perform_in(LEASE_TIMEOUT, project.id, statistics)
end
private
- def try_obtain_lease_for(project_id, section)
+ def try_obtain_lease_for(project_id, statistics)
Gitlab::ExclusiveLease
- .new("project_cache_worker:#{project_id}:#{section}", timeout: LEASE_TIMEOUT)
+ .new(project_cache_worker_key(project_id, statistics), timeout: LEASE_TIMEOUT)
.try_obtain
end
+
+ def project_cache_worker_key(project_id, statistics)
+ ["project_cache_worker", project_id, *statistics.sort].join(":")
+ end
end
diff --git a/app/workers/project_daily_statistics_worker.rb b/app/workers/project_daily_statistics_worker.rb
new file mode 100644
index 00000000000..101f5c28459
--- /dev/null
+++ b/app/workers/project_daily_statistics_worker.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class ProjectDailyStatisticsWorker
+ include ApplicationWorker
+
+ def perform(project_id)
+ project = Project.find_by_id(project_id)
+
+ return unless project&.repository&.exists?
+
+ Projects::FetchStatisticsIncrementService.new(project).execute
+ end
+end
diff --git a/app/workers/project_migrate_hashed_storage_worker.rb b/app/workers/project_migrate_hashed_storage_worker.rb
deleted file mode 100644
index 4c6339f7701..00000000000
--- a/app/workers/project_migrate_hashed_storage_worker.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-# frozen_string_literal: true
-
-class ProjectMigrateHashedStorageWorker
- include ApplicationWorker
-
- LEASE_TIMEOUT = 30.seconds.to_i
-
- # rubocop: disable CodeReuse/ActiveRecord
- def perform(project_id, old_disk_path = nil)
- project = Project.find_by(id: project_id)
- return if project.nil? || project.pending_delete?
-
- uuid = lease_for(project_id).try_obtain
- if uuid
- ::Projects::HashedStorageMigrationService.new(project, old_disk_path || project.full_path, logger: logger).execute
- else
- false
- end
- rescue => ex
- cancel_lease_for(project_id, uuid) if uuid
- raise ex
- end
- # rubocop: enable CodeReuse/ActiveRecord
-
- def lease_for(project_id)
- Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT)
- end
-
- private
-
- def lease_key(project_id)
- "project_migrate_hashed_storage_worker:#{project_id}"
- end
-
- def cancel_lease_for(project_id, uuid)
- Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid)
- end
-end
diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb
index 96ff8cd6222..b30864db802 100644
--- a/app/workers/reactive_caching_worker.rb
+++ b/app/workers/reactive_caching_worker.rb
@@ -3,16 +3,17 @@
class ReactiveCachingWorker
include ApplicationWorker
- # rubocop: disable CodeReuse/ActiveRecord
def perform(class_name, id, *args)
klass = begin
- Kernel.const_get(class_name)
+ class_name.constantize
rescue NameError
nil
end
return unless klass
- klass.find_by(id: id).try(:exclusively_update_reactive_cache!, *args)
+ klass
+ .reactive_cache_worker_finder
+ .call(id, *args)
+ .try(:exclusively_update_reactive_cache!, *args)
end
- # rubocop: enable CodeReuse/ActiveRecord
end
diff --git a/app/workers/remote_mirror_notification_worker.rb b/app/workers/remote_mirror_notification_worker.rb
index 70c2e857d09..5bafe8e2046 100644
--- a/app/workers/remote_mirror_notification_worker.rb
+++ b/app/workers/remote_mirror_notification_worker.rb
@@ -9,7 +9,10 @@ class RemoteMirrorNotificationWorker
# We check again if there's an error because a newer run since this job was
# fired could've completed successfully.
return unless remote_mirror && remote_mirror.last_error.present?
+ return if remote_mirror.error_notification_sent?
NotificationService.new.remote_mirror_update_failed(remote_mirror)
+
+ remote_mirror.after_sent_notification
end
end
diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb
index 41913900571..3497a1f9280 100644
--- a/app/workers/remove_expired_members_worker.rb
+++ b/app/workers/remove_expired_members_worker.rb
@@ -6,11 +6,9 @@ class RemoveExpiredMembersWorker
def perform
Member.expired.find_each do |member|
- begin
- Members::DestroyService.new.execute(member, skip_authorization: true)
- rescue => ex
- logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
- end
+ Members::DestroyService.new.execute(member, skip_authorization: true)
+ rescue => ex
+ logger.error("Expired Member ID=#{member.id} cannot be removed - #{ex}")
end
end
end
diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb
index 7eae07d3f6b..a9b88a133be 100644
--- a/app/workers/repository_fork_worker.rb
+++ b/app/workers/repository_fork_worker.rb
@@ -15,19 +15,19 @@ class RepositoryForkWorker
return target_project.import_state.mark_as_failed(_('Source project cannot be found.'))
end
- fork_repository(target_project, source_project.repository_storage, source_project.disk_path)
+ fork_repository(target_project, source_project)
end
private
- def fork_repository(target_project, source_repository_storage_name, source_disk_path)
+ def fork_repository(target_project, source_project)
return unless start_fork(target_project)
Gitlab::Metrics.add_event(:fork_repository)
- result = gitlab_shell.fork_repository(source_repository_storage_name, source_disk_path,
- target_project.repository_storage, target_project.disk_path)
- raise "Unable to fork project #{target_project.id} for repository #{source_disk_path} -> #{target_project.disk_path}" unless result
+ result = gitlab_shell.fork_repository(source_project, target_project)
+
+ raise "Unable to fork project #{target_project.id} for repository #{source_project.disk_path} -> #{target_project.disk_path}" unless result
target_project.after_import
end
diff --git a/app/workers/schedule_migrate_external_diffs_worker.rb b/app/workers/schedule_migrate_external_diffs_worker.rb
new file mode 100644
index 00000000000..70910f7ca04
--- /dev/null
+++ b/app/workers/schedule_migrate_external_diffs_worker.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: false
+
+class ScheduleMigrateExternalDiffsWorker
+ include ApplicationWorker
+ include CronjobQueue
+ include Gitlab::ExclusiveLeaseHelpers
+
+ def perform
+ in_lock(self.class.name.underscore, ttl: 2.hours, retries: 0) do
+ MergeRequests::MigrateExternalDiffsService.enqueue!
+ end
+ rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError
+ end
+end
diff --git a/app/workers/storage_migrator_worker.rb b/app/workers/storage_migrator_worker.rb
deleted file mode 100644
index fa76fbac55c..00000000000
--- a/app/workers/storage_migrator_worker.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-class StorageMigratorWorker
- include ApplicationWorker
-
- def perform(start, finish)
- migrator = Gitlab::HashedStorage::Migrator.new
- migrator.bulk_migrate(start, finish)
- end
-end
diff --git a/app/workers/stuck_merge_jobs_worker.rb b/app/workers/stuck_merge_jobs_worker.rb
index 98c81956cba..f34ed6c4844 100644
--- a/app/workers/stuck_merge_jobs_worker.rb
+++ b/app/workers/stuck_merge_jobs_worker.rb
@@ -4,6 +4,10 @@ class StuckMergeJobsWorker
include ApplicationWorker
include CronjobQueue
+ def self.logger
+ Rails.logger
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def perform
stuck_merge_requests.find_in_batches(batch_size: 100) do |group|
@@ -35,7 +39,7 @@ class StuckMergeJobsWorker
# We rely on state machine callbacks to update head_pipeline_id
merge_requests_to_reopen.each(&:unlock_mr)
- Rails.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
+ self.class.logger.info("Updated state of locked merge jobs. JIDs: #{completed_jids.join(', ')}")
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb
index e8494ffa002..4ec2b9d8fbe 100644
--- a/app/workers/update_head_pipeline_for_merge_request_worker.rb
+++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb
@@ -7,25 +7,8 @@ class UpdateHeadPipelineForMergeRequestWorker
queue_namespace :pipeline_processing
def perform(merge_request_id)
- merge_request = MergeRequest.find(merge_request_id)
-
- sha = merge_request.diff_head_sha
- pipeline = merge_request.all_pipelines(shas: sha).first
-
- return unless pipeline && pipeline.latest?
-
- if merge_request.diff_head_sha != pipeline.sha
- log_error_message_for(merge_request)
-
- return
+ MergeRequest.find_by_id(merge_request_id).try do |merge_request|
+ merge_request.update_head_pipeline
end
-
- merge_request.update_attribute(:head_pipeline_id, pipeline.id)
- end
-
- def log_error_message_for(merge_request)
- Rails.logger.error(
- "Outdated head pipeline for active merge request: id=#{merge_request.id}, source_branch=#{merge_request.source_branch}, diff_head_sha=#{merge_request.diff_head_sha}"
- )
end
end
diff --git a/app/workers/update_project_statistics_worker.rb b/app/workers/update_project_statistics_worker.rb
new file mode 100644
index 00000000000..9a29cc12707
--- /dev/null
+++ b/app/workers/update_project_statistics_worker.rb
@@ -0,0 +1,18 @@
+
+# frozen_string_literal: true
+
+# Worker for updating project statistics.
+class UpdateProjectStatisticsWorker
+ include ApplicationWorker
+
+ # project_id - The ID of the project for which to flush the cache.
+ # statistics - An Array containing columns from ProjectStatistics to
+ # refresh, if empty all columns will be refreshed
+ # rubocop: disable CodeReuse/ActiveRecord
+ def perform(project_id, statistics = [])
+ project = Project.find_by(id: project_id)
+
+ Projects::UpdateStatisticsService.new(project, nil, statistics: statistics).execute
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+end